@fungi.computer/bonsai 0.0.0-stage → 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/LICENSE +21 -0
- package/README.md +196 -2
- package/backend/background.ts +259 -0
- package/backend/change-copy.ts +99 -0
- package/backend/changes.ts +530 -0
- package/backend/diff.ts +228 -0
- package/backend/file-edits.ts +850 -0
- package/backend/files-copy.ts +361 -0
- package/backend/gills.ts +125 -0
- package/backend/git.ts +123 -0
- package/backend/graph-commit-copies.ts +321 -0
- package/backend/history.ts +178 -0
- package/backend/index.ts +144 -0
- package/backend/insights.ts +256 -0
- package/backend/merge.ts +999 -0
- package/backend/methods.ts +835 -0
- package/backend/policy.ts +160 -0
- package/backend/read-copy-page.ts +77 -0
- package/backend/repository-copy.ts +70 -0
- package/backend/reviews.ts +317 -0
- package/backend/service.ts +892 -0
- package/backend/state.ts +914 -0
- package/demo/koi-pond/README.md +17 -0
- package/demo/koi-pond/index.html +11 -0
- package/demo/koi-pond/package.json +13 -0
- package/demo/koi-pond/pnpm-lock.yaml +450 -0
- package/demo/koi-pond/src/fish.ts +32 -0
- package/demo/koi-pond/src/main.ts +24 -0
- package/demo/koi-pond/src/pond.ts +27 -0
- package/docs/media/bonsai-insights.png +0 -0
- package/docs/media/overview-phone.png +0 -0
- package/docs/media/overview.png +0 -0
- package/package.json +152 -3
- package/scripts/agent.sh +55 -0
- package/scripts/demo.sh +233 -0
- package/scripts/rebuild-copies.mjs +34 -0
- package/scripts/release.mjs +52 -0
- package/scripts/runtime-proof.mjs +299 -0
- package/scripts/seed-demo.mjs +724 -0
- package/scripts/seed-step.mjs +37 -0
- package/scripts/shed-terminal-proof.mjs +774 -0
- package/shared/contract.ts +404 -0
- package/shared/hooks.ts +208 -0
- package/shared/insights.ts +90 -0
- package/shared/picker-options.ts +23 -0
- package/shared/presentation.ts +21 -0
- package/shared/repository-deletion.ts +4 -0
- package/test/apps-build-install.test.ts +302 -0
- package/test/background-runtime.test.ts +206 -0
- package/test/browser/board.browser.mjs +52 -0
- package/test/browser/capture.browser.mjs +1264 -0
- package/test/browser/edit-in-inkcap.browser.mjs +83 -0
- package/test/browser/repository-settings-fixture.tsx +108 -0
- package/test/browser/repository-settings.browser.mjs +136 -0
- package/test/browser/repository-settings.html +10 -0
- package/test/browser/visual-fixture.tsx +667 -0
- package/test/browser/visual.html +10 -0
- package/test/browser/vite.config.mjs +9 -0
- package/test/change-page.test.ts +916 -0
- package/test/changes.test.ts +1063 -0
- package/test/checks-workflow-template.test.mjs +234 -0
- package/test/diff.test.ts +70 -0
- package/test/edit-in-inkcap.test.ts +227 -0
- package/test/file-edits.test.ts +2714 -0
- package/test/files-read-copy-calls.test.ts +374 -0
- package/test/fork-adoption.test.ts +111 -0
- package/test/gills.test.ts +125 -0
- package/test/git-binding.fixture.ts +30 -0
- package/test/git-push.config.jsonc +9 -0
- package/test/git-push.native.mjs +413 -0
- package/test/git-push.worker.ts +280 -0
- package/test/graph-commit-read-copy-calls.test.ts +401 -0
- package/test/home-page.test.ts +170 -0
- package/test/hooks.test.ts +250 -0
- package/test/insight-data.test.ts +51 -0
- package/test/insight-layout.test.mjs +230 -0
- package/test/insights.test.ts +154 -0
- package/test/merge-background.test.ts +756 -0
- package/test/merge-conflict.test.ts +337 -0
- package/test/merge-declaration.test.ts +46 -0
- package/test/merge-repro.test.ts +561 -0
- package/test/merge-template-build.test.mjs +234 -0
- package/test/network-layout.test.ts +200 -0
- package/test/outbox.test.ts +424 -0
- package/test/parts.test.ts +86 -0
- package/test/pkt-line.test.ts +54 -0
- package/test/policy.test.ts +172 -0
- package/test/read-copy-calls.test.ts +691 -0
- package/test/read-copy-composer.test.ts +388 -0
- package/test/read-copy-lifetimes.test.ts +214 -0
- package/test/read-copy-rpc.config.jsonc +12 -0
- package/test/read-copy-rpc.native.mjs +47 -0
- package/test/read-copy-rpc.worker.ts +92 -0
- package/test/recent-activity.test.ts +62 -0
- package/test/repository-deletion.test.ts +500 -0
- package/test/seed-resume.test.mjs +91 -0
- package/test/storyboard-save-https-git.fixture.mjs +119 -0
- package/test/storyboard-save-template.test.mjs +545 -0
- package/test/ui-errors.test.ts +976 -0
- package/test/workflow-store.test.ts +115 -0
- package/tsconfig.json +36 -0
- package/turbo.jsonc +25 -0
- package/vite.config.ts +19 -0
- package/vitest.config.ts +5 -0
- package/web/api.ts +224 -0
- package/web/app-build-panel.tsx +171 -0
- package/web/board.tsx +259 -0
- package/web/bonsai.css +642 -0
- package/web/change-page.tsx +715 -0
- package/web/change-rows.tsx +135 -0
- package/web/code-tab.tsx +267 -0
- package/web/commit-tree.tsx +152 -0
- package/web/format.ts +54 -0
- package/web/home-page.tsx +274 -0
- package/web/index.html +17 -0
- package/web/insight-data.ts +76 -0
- package/web/insight-tiles.tsx +534 -0
- package/web/insights.tsx +250 -0
- package/web/issues-panel.tsx +305 -0
- package/web/main.tsx +72 -0
- package/web/network-graph.tsx +647 -0
- package/web/overview.tsx +339 -0
- package/web/pages.tsx +469 -0
- package/web/parts.tsx +381 -0
- package/web/public/favicon.svg +1 -0
- package/web/repo-navigation.tsx +256 -0
- package/web/repo-settings.tsx +145 -0
- package/web/router.tsx +151 -0
- package/web/transport.ts +91 -0
- package/web/views.tsx +151 -0
- package/web/working-now.tsx +98 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/** A native service binding retains the same RPC method semantics as the host's GIT slot. */
|
|
2
|
+
import { WorkerEntrypoint } from "cloudflare:workers";
|
|
3
|
+
import {
|
|
4
|
+
countingGit,
|
|
5
|
+
ReadCopyBudgetExceeded,
|
|
6
|
+
} from "@fungi.computer/agar/read-copy";
|
|
7
|
+
import type { GitBinding } from "@fungi.computer/agar/git";
|
|
8
|
+
|
|
9
|
+
export class GitRpc extends WorkerEntrypoint implements GitBinding {
|
|
10
|
+
create(name: string) {
|
|
11
|
+
return this.info(name);
|
|
12
|
+
}
|
|
13
|
+
fork(_source: string, name: string) {
|
|
14
|
+
return this.info(name);
|
|
15
|
+
}
|
|
16
|
+
cloneUrl() {
|
|
17
|
+
return Promise.resolve("https://git.test/pond.git");
|
|
18
|
+
}
|
|
19
|
+
info(name: string) {
|
|
20
|
+
return Promise.resolve({
|
|
21
|
+
name,
|
|
22
|
+
defaultBranch: "main",
|
|
23
|
+
lastPushAt: null,
|
|
24
|
+
source: null,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
delete() {
|
|
28
|
+
return Promise.resolve(true);
|
|
29
|
+
}
|
|
30
|
+
log() {
|
|
31
|
+
return Promise.resolve([]);
|
|
32
|
+
}
|
|
33
|
+
readCommit() {
|
|
34
|
+
return Promise.resolve(null);
|
|
35
|
+
}
|
|
36
|
+
readTree() {
|
|
37
|
+
return Promise.resolve(null);
|
|
38
|
+
}
|
|
39
|
+
readBlob() {
|
|
40
|
+
return Promise.resolve(null);
|
|
41
|
+
}
|
|
42
|
+
refs() {
|
|
43
|
+
return Promise.resolve([]);
|
|
44
|
+
}
|
|
45
|
+
exchange() {
|
|
46
|
+
return Promise.resolve({
|
|
47
|
+
status: 200,
|
|
48
|
+
headers: {},
|
|
49
|
+
body: new Uint8Array(),
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export default {
|
|
55
|
+
async fetch(_request, env) {
|
|
56
|
+
const direct = await env.GIT.info("pond");
|
|
57
|
+
const lifetime = new AbortController();
|
|
58
|
+
try {
|
|
59
|
+
const counted = countingGit(env.GIT, {
|
|
60
|
+
limit: 1,
|
|
61
|
+
signal: lifetime.signal,
|
|
62
|
+
});
|
|
63
|
+
const wrapped = await counted.git.info("pond");
|
|
64
|
+
let exhausted = false;
|
|
65
|
+
try {
|
|
66
|
+
await counted.git.info("pond");
|
|
67
|
+
} catch (error) {
|
|
68
|
+
if (!(error instanceof ReadCopyBudgetExceeded)) throw error;
|
|
69
|
+
exhausted = true;
|
|
70
|
+
}
|
|
71
|
+
lifetime.abort();
|
|
72
|
+
let aborted = false;
|
|
73
|
+
try {
|
|
74
|
+
await counted.git.info("pond");
|
|
75
|
+
} catch (error) {
|
|
76
|
+
if (!(error instanceof DOMException) || error.name !== "AbortError")
|
|
77
|
+
throw error;
|
|
78
|
+
aborted = true;
|
|
79
|
+
}
|
|
80
|
+
return Response.json({
|
|
81
|
+
direct,
|
|
82
|
+
wrapped,
|
|
83
|
+
total: counted.total(),
|
|
84
|
+
counts: counted.counts(),
|
|
85
|
+
exhausted,
|
|
86
|
+
aborted,
|
|
87
|
+
});
|
|
88
|
+
} finally {
|
|
89
|
+
lifetime.abort();
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
} satisfies ExportedHandler<{ GIT: Service<GitRpc> }>;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
|
3
|
+
import { act, createElement } from "react";
|
|
4
|
+
import { createRoot } from "react-dom/client";
|
|
5
|
+
import type { Activity } from "../shared/contract.js";
|
|
6
|
+
import { WorkingNow } from "../web/working-now.js";
|
|
7
|
+
|
|
8
|
+
let container: HTMLDivElement;
|
|
9
|
+
let root: ReturnType<typeof createRoot>;
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
vi.useFakeTimers();
|
|
13
|
+
vi.setSystemTime(new Date("2026-10-13T12:00:00Z"));
|
|
14
|
+
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
|
15
|
+
container = document.createElement("div");
|
|
16
|
+
document.body.appendChild(container);
|
|
17
|
+
root = createRoot(container);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
act(() => {
|
|
22
|
+
root.unmount();
|
|
23
|
+
});
|
|
24
|
+
container.remove();
|
|
25
|
+
vi.useRealTimers();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("expires recent push activity without another event or page reload", async () => {
|
|
29
|
+
const events: Activity[] = [
|
|
30
|
+
{
|
|
31
|
+
id: 1,
|
|
32
|
+
at: Date.now() - 8 * 60_000,
|
|
33
|
+
type: "push",
|
|
34
|
+
repo: "koi-pond--enoki",
|
|
35
|
+
actor: "enoki",
|
|
36
|
+
data: {
|
|
37
|
+
updates: [
|
|
38
|
+
{
|
|
39
|
+
ref: "refs/heads/main",
|
|
40
|
+
before: "0".repeat(40),
|
|
41
|
+
after: "a".repeat(40),
|
|
42
|
+
},
|
|
43
|
+
],
|
|
44
|
+
commits: [],
|
|
45
|
+
paths: ["src/pond.ts"],
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
];
|
|
49
|
+
act(() => {
|
|
50
|
+
root.render(
|
|
51
|
+
createElement(WorkingNow, {
|
|
52
|
+
events,
|
|
53
|
+
colors: new Map([["enoki", { color: "c1", kind: "agent" as const }]]),
|
|
54
|
+
}),
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
expect(container.querySelectorAll("li")).toHaveLength(1);
|
|
58
|
+
await act(async () => {
|
|
59
|
+
await vi.advanceTimersByTimeAsync(7 * 60_000 + 1);
|
|
60
|
+
});
|
|
61
|
+
expect(container.querySelectorAll("li")).toHaveLength(0);
|
|
62
|
+
});
|
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
import { DatabaseSync } from "node:sqlite";
|
|
2
|
+
import { rmSync } from "node:fs";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { createAdmittedGit, createGitDoor } from "@fungi.computer/oyster/git";
|
|
5
|
+
import { fromNodeSqlite } from "@fungi.computer/watchdog/node-sqlite";
|
|
6
|
+
import type { GitBinding } from "@fungi.computer/agar/git";
|
|
7
|
+
import type { AppMethodCallContext } from "@fungi.computer/puffball/app-method-runtime";
|
|
8
|
+
import { expect, it, vi } from "vitest";
|
|
9
|
+
import { appBackend } from "../backend/methods.js";
|
|
10
|
+
import { gitStore } from "../backend/git.js";
|
|
11
|
+
import { BonsaiState, ensureBonsaiTables } from "../backend/state.js";
|
|
12
|
+
import type { Bonsai } from "../backend/service.js";
|
|
13
|
+
import { realGit } from "@fungi.computer/agar/test-kit/git";
|
|
14
|
+
|
|
15
|
+
function fixture() {
|
|
16
|
+
const database = new DatabaseSync(":memory:");
|
|
17
|
+
const sql = fromNodeSqlite(database);
|
|
18
|
+
ensureBonsaiTables(sql.sql);
|
|
19
|
+
const state = new BonsaiState(sql.sql, sql.transactionSync);
|
|
20
|
+
state.registerRepo({
|
|
21
|
+
name: "pond",
|
|
22
|
+
description: null,
|
|
23
|
+
parent: null,
|
|
24
|
+
owner: null,
|
|
25
|
+
});
|
|
26
|
+
const unavailable = () =>
|
|
27
|
+
Promise.reject(new Error("Unexpected provider operation"));
|
|
28
|
+
const deletion = vi.fn<GitBinding["delete"]>(() => Promise.resolve(true));
|
|
29
|
+
const binding: GitBinding = {
|
|
30
|
+
create: unavailable,
|
|
31
|
+
fork: unavailable,
|
|
32
|
+
cloneUrl: unavailable,
|
|
33
|
+
info: unavailable,
|
|
34
|
+
delete: deletion,
|
|
35
|
+
log: unavailable,
|
|
36
|
+
readCommit: unavailable,
|
|
37
|
+
readTree: unavailable,
|
|
38
|
+
readBlob: unavailable,
|
|
39
|
+
refs: unavailable,
|
|
40
|
+
exchange: unavailable,
|
|
41
|
+
};
|
|
42
|
+
const emit = vi.fn(() => Promise.resolve());
|
|
43
|
+
const bonsai: Bonsai = { state, store: gitStore(binding), events: { emit } };
|
|
44
|
+
return { database, sql, state, binding, deletion, emit, bonsai };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
it("refuses repository deletion before state or provider writes for every non-owner caller", async () => {
|
|
48
|
+
const callers: readonly AppMethodCallContext[] = [
|
|
49
|
+
{ computerId: null, actor: { kind: "member", memberId: "Owner" } },
|
|
50
|
+
{
|
|
51
|
+
computerId: null,
|
|
52
|
+
actor: { kind: "member", memberId: "member" },
|
|
53
|
+
role: "member",
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
computerId: null,
|
|
57
|
+
actor: { kind: "agent", agentId: "agent" },
|
|
58
|
+
role: "owner",
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
computerId: null,
|
|
62
|
+
actor: { kind: "team-key", keyId: "key" },
|
|
63
|
+
role: "owner",
|
|
64
|
+
},
|
|
65
|
+
];
|
|
66
|
+
for (const caller of callers) {
|
|
67
|
+
const f = fixture();
|
|
68
|
+
try {
|
|
69
|
+
const before = f.state.repo("pond");
|
|
70
|
+
await expect(
|
|
71
|
+
appBackend.call(
|
|
72
|
+
"repos.delete",
|
|
73
|
+
{ repo: "pond" },
|
|
74
|
+
{ bonsai: f.bonsai },
|
|
75
|
+
caller,
|
|
76
|
+
),
|
|
77
|
+
).rejects.toMatchObject({ code: "forbidden" });
|
|
78
|
+
expect(f.state.repo("pond")).toEqual(before);
|
|
79
|
+
expect(f.deletion).not.toHaveBeenCalled();
|
|
80
|
+
expect(f.emit).not.toHaveBeenCalled();
|
|
81
|
+
} finally {
|
|
82
|
+
f.database.close();
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("withdraws the public listing and anonymous clone after owner deletion", async () => {
|
|
88
|
+
const f = fixture();
|
|
89
|
+
const provider = realGit();
|
|
90
|
+
try {
|
|
91
|
+
await provider.git.create("pond");
|
|
92
|
+
const binding: GitBinding = {
|
|
93
|
+
...provider.git,
|
|
94
|
+
delete: async (name) => {
|
|
95
|
+
const listing = await appBackend.call(
|
|
96
|
+
"repos.list",
|
|
97
|
+
{},
|
|
98
|
+
{ bonsai: f.bonsai },
|
|
99
|
+
owner,
|
|
100
|
+
);
|
|
101
|
+
expect(listing).toEqual({ repos: [] });
|
|
102
|
+
if ((await provider.git.info(name)) === null) return false;
|
|
103
|
+
rmSync(fileURLToPath(await provider.git.cloneUrl(name)), {
|
|
104
|
+
recursive: true,
|
|
105
|
+
});
|
|
106
|
+
return true;
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
const bonsai = { ...f.bonsai, store: gitStore(binding) };
|
|
110
|
+
const git = createAdmittedGit({
|
|
111
|
+
installationId: "bonsai",
|
|
112
|
+
store: binding,
|
|
113
|
+
grants: { admits: () => Promise.resolve(true) },
|
|
114
|
+
policy: {
|
|
115
|
+
authorize: () =>
|
|
116
|
+
Promise.reject(new Error("Anonymous clone never authorizes a push")),
|
|
117
|
+
},
|
|
118
|
+
journal: {
|
|
119
|
+
tip: () => Promise.reject(new Error("Clone has no write journal")),
|
|
120
|
+
record: () => Promise.reject(new Error("Clone never records a push")),
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
const door = createGitDoor({
|
|
124
|
+
authenticate: () => Promise.resolve(null),
|
|
125
|
+
isPublic: () => Promise.resolve(true),
|
|
126
|
+
git: () => Promise.resolve(git),
|
|
127
|
+
});
|
|
128
|
+
const path = "/git/bonsai/pond.git/info/refs?service=git-upload-pack";
|
|
129
|
+
expect((await door.request(path)).status).toBe(200);
|
|
130
|
+
await expect(
|
|
131
|
+
appBackend.call("repos.list", {}, { bonsai }, owner),
|
|
132
|
+
).resolves.toMatchObject({ repos: [{ name: "pond" }] });
|
|
133
|
+
await expect(
|
|
134
|
+
appBackend.call("repos.delete", { repo: "pond" }, { bonsai }, owner),
|
|
135
|
+
).resolves.toEqual({ deleted: true });
|
|
136
|
+
await expect(
|
|
137
|
+
appBackend.call("repos.list", {}, { bonsai }, owner),
|
|
138
|
+
).resolves.toEqual({ repos: [] });
|
|
139
|
+
expect((await door.request(path)).status).toBe(404);
|
|
140
|
+
await expect(
|
|
141
|
+
appBackend.call("repos.delete", { repo: "pond" }, { bonsai }, owner),
|
|
142
|
+
).resolves.toEqual({ deleted: true });
|
|
143
|
+
expect((await door.request(path)).status).toBe(404);
|
|
144
|
+
} finally {
|
|
145
|
+
provider.close();
|
|
146
|
+
f.database.close();
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("shares one same-name provider cleanup so a concurrent retry cannot outlive marker completion", async () => {
|
|
151
|
+
const f = fixture();
|
|
152
|
+
const entered = Promise.withResolvers<undefined>();
|
|
153
|
+
const provider = Promise.withResolvers<boolean>();
|
|
154
|
+
try {
|
|
155
|
+
f.deletion.mockImplementation(() => {
|
|
156
|
+
entered.resolve(undefined);
|
|
157
|
+
return provider.promise;
|
|
158
|
+
});
|
|
159
|
+
const first = appBackend.call(
|
|
160
|
+
"repos.delete",
|
|
161
|
+
{ repo: "pond" },
|
|
162
|
+
{ bonsai: f.bonsai },
|
|
163
|
+
owner,
|
|
164
|
+
);
|
|
165
|
+
await entered.promise;
|
|
166
|
+
const second = appBackend.call(
|
|
167
|
+
"repos.delete",
|
|
168
|
+
{ repo: "pond" },
|
|
169
|
+
{ bonsai: f.bonsai },
|
|
170
|
+
owner,
|
|
171
|
+
);
|
|
172
|
+
await expect(
|
|
173
|
+
appBackend.call(
|
|
174
|
+
"repos.create",
|
|
175
|
+
{ name: "pond" },
|
|
176
|
+
{ bonsai: f.bonsai },
|
|
177
|
+
owner,
|
|
178
|
+
),
|
|
179
|
+
).rejects.toMatchObject({ code: "conflict" });
|
|
180
|
+
provider.resolve(true);
|
|
181
|
+
await expect(Promise.all([first, second])).resolves.toEqual([
|
|
182
|
+
{ deleted: true },
|
|
183
|
+
{ deleted: true },
|
|
184
|
+
]);
|
|
185
|
+
expect(f.deletion).toHaveBeenCalledTimes(1);
|
|
186
|
+
} finally {
|
|
187
|
+
provider.resolve(true);
|
|
188
|
+
f.database.close();
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it.each(["create", "fork"] as const)(
|
|
193
|
+
"fences an older %s publication even after deletion cleanup completed",
|
|
194
|
+
async (kind) => {
|
|
195
|
+
const f = fixture();
|
|
196
|
+
const entered = Promise.withResolvers<undefined>();
|
|
197
|
+
const resume = Promise.withResolvers<string>();
|
|
198
|
+
const name = kind === "create" ? "pond" : "pond--Levi";
|
|
199
|
+
try {
|
|
200
|
+
let cloneCalls = 0;
|
|
201
|
+
const info = (name: string) =>
|
|
202
|
+
Promise.resolve({
|
|
203
|
+
name,
|
|
204
|
+
defaultBranch: "main",
|
|
205
|
+
lastPushAt: null,
|
|
206
|
+
source: null,
|
|
207
|
+
});
|
|
208
|
+
const binding: GitBinding = {
|
|
209
|
+
...f.binding,
|
|
210
|
+
create: info,
|
|
211
|
+
fork: async (source, name) => ({ ...(await info(name)), source }),
|
|
212
|
+
refs: () => Promise.resolve([]),
|
|
213
|
+
cloneUrl: (repo) => {
|
|
214
|
+
cloneCalls += 1;
|
|
215
|
+
if (cloneCalls === 2) {
|
|
216
|
+
entered.resolve(undefined);
|
|
217
|
+
return resume.promise;
|
|
218
|
+
}
|
|
219
|
+
return Promise.resolve(`https://git.test/${repo}.git`);
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
const bonsai = { ...f.bonsai, store: gitStore(binding) };
|
|
223
|
+
const pending = appBackend.call(
|
|
224
|
+
kind === "create" ? "repos.create" : "repos.fork",
|
|
225
|
+
kind === "create" ? { name } : { repo: "pond" },
|
|
226
|
+
{ bonsai },
|
|
227
|
+
owner,
|
|
228
|
+
);
|
|
229
|
+
const refused = expect(pending).rejects.toMatchObject({
|
|
230
|
+
code: "conflict",
|
|
231
|
+
});
|
|
232
|
+
await entered.promise;
|
|
233
|
+
await appBackend.call("repos.delete", { repo: name }, { bonsai }, owner);
|
|
234
|
+
resume.resolve(`https://git.test/${name}.git`);
|
|
235
|
+
await refused;
|
|
236
|
+
expect(f.state.repo(name)).toBeNull();
|
|
237
|
+
} finally {
|
|
238
|
+
resume.resolve(`https://git.test/${name}.git`);
|
|
239
|
+
f.database.close();
|
|
240
|
+
}
|
|
241
|
+
},
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
it("refuses a canonical repository with forks without modifying either repository", async () => {
|
|
245
|
+
const f = fixture();
|
|
246
|
+
try {
|
|
247
|
+
f.state.registerRepo({
|
|
248
|
+
name: "pond--Luna",
|
|
249
|
+
description: null,
|
|
250
|
+
parent: "pond",
|
|
251
|
+
owner: "Luna",
|
|
252
|
+
});
|
|
253
|
+
await expect(
|
|
254
|
+
appBackend.call(
|
|
255
|
+
"repos.delete",
|
|
256
|
+
{ repo: "pond" },
|
|
257
|
+
{ bonsai: f.bonsai },
|
|
258
|
+
owner,
|
|
259
|
+
),
|
|
260
|
+
).rejects.toMatchObject({ code: "conflict" });
|
|
261
|
+
expect(f.deletion).not.toHaveBeenCalled();
|
|
262
|
+
expect(f.state.repo("pond")?.forks).toEqual(["pond--Luna"]);
|
|
263
|
+
await appBackend.call(
|
|
264
|
+
"repos.delete",
|
|
265
|
+
{ repo: "pond--Luna" },
|
|
266
|
+
{ bonsai: f.bonsai },
|
|
267
|
+
owner,
|
|
268
|
+
);
|
|
269
|
+
expect(f.state.repo("pond")?.forks).toEqual([]);
|
|
270
|
+
await appBackend.call(
|
|
271
|
+
"repos.delete",
|
|
272
|
+
{ repo: "pond" },
|
|
273
|
+
{ bonsai: f.bonsai },
|
|
274
|
+
owner,
|
|
275
|
+
);
|
|
276
|
+
expect(f.state.repos()).toEqual([]);
|
|
277
|
+
} finally {
|
|
278
|
+
f.database.close();
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it("keeps provider and catalog intact when the local withdrawal transaction fails", async () => {
|
|
283
|
+
const f = fixture();
|
|
284
|
+
try {
|
|
285
|
+
f.database.exec(
|
|
286
|
+
"CREATE TRIGGER refuse_deletion BEFORE DELETE ON repos BEGIN SELECT RAISE(ABORT, 'Local withdrawal failed'); END",
|
|
287
|
+
);
|
|
288
|
+
await expect(
|
|
289
|
+
appBackend.call(
|
|
290
|
+
"repos.delete",
|
|
291
|
+
{ repo: "pond" },
|
|
292
|
+
{ bonsai: f.bonsai },
|
|
293
|
+
owner,
|
|
294
|
+
),
|
|
295
|
+
).rejects.toThrow("Local withdrawal failed");
|
|
296
|
+
await expect(
|
|
297
|
+
appBackend.call("repos.list", {}, { bonsai: f.bonsai }, owner),
|
|
298
|
+
).resolves.toMatchObject({ repos: [{ name: "pond" }] });
|
|
299
|
+
expect(f.deletion).not.toHaveBeenCalled();
|
|
300
|
+
} finally {
|
|
301
|
+
f.database.close();
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it("preserves the readiness generation so an in-flight refresh cannot republish deleted refs", async () => {
|
|
306
|
+
const f = fixture();
|
|
307
|
+
try {
|
|
308
|
+
const revision = f.state.repositoryRefStatus("pond").revision;
|
|
309
|
+
await appBackend.call(
|
|
310
|
+
"repos.delete",
|
|
311
|
+
{ repo: "pond" },
|
|
312
|
+
{ bonsai: f.bonsai },
|
|
313
|
+
owner,
|
|
314
|
+
);
|
|
315
|
+
expect(
|
|
316
|
+
f.state.publishRepositoryRefs("pond", revision, null, {
|
|
317
|
+
defaultBranch: "main",
|
|
318
|
+
refs: [],
|
|
319
|
+
cloneUrl: "https://git.test/pond.git",
|
|
320
|
+
empty: true,
|
|
321
|
+
}),
|
|
322
|
+
).toBe(false);
|
|
323
|
+
await expect(
|
|
324
|
+
appBackend.call(
|
|
325
|
+
"repos.get",
|
|
326
|
+
{ repo: "pond" },
|
|
327
|
+
{ bonsai: f.bonsai },
|
|
328
|
+
owner,
|
|
329
|
+
),
|
|
330
|
+
).rejects.toMatchObject({ code: "not_found" });
|
|
331
|
+
} finally {
|
|
332
|
+
f.database.close();
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
const owner: AppMethodCallContext = {
|
|
337
|
+
computerId: null,
|
|
338
|
+
actor: { kind: "member", memberId: "Levi" },
|
|
339
|
+
role: "owner",
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
it("keeps failed cleanup hidden and blocks name reuse across owner reopen until a retry succeeds", async () => {
|
|
343
|
+
const f = fixture();
|
|
344
|
+
try {
|
|
345
|
+
const failure = new Error("Provider temporarily unavailable");
|
|
346
|
+
f.deletion.mockRejectedValueOnce(failure);
|
|
347
|
+
await expect(
|
|
348
|
+
appBackend.call(
|
|
349
|
+
"repos.delete",
|
|
350
|
+
{ repo: "pond" },
|
|
351
|
+
{ bonsai: f.bonsai },
|
|
352
|
+
owner,
|
|
353
|
+
),
|
|
354
|
+
).rejects.toBe(failure);
|
|
355
|
+
await expect(
|
|
356
|
+
appBackend.call("repos.list", {}, { bonsai: f.bonsai }, owner),
|
|
357
|
+
).resolves.toEqual({ repos: [] });
|
|
358
|
+
const reopened = {
|
|
359
|
+
...f.bonsai,
|
|
360
|
+
state: new BonsaiState(f.sql.sql, f.sql.transactionSync),
|
|
361
|
+
};
|
|
362
|
+
await expect(
|
|
363
|
+
appBackend.call(
|
|
364
|
+
"repos.create",
|
|
365
|
+
{ name: "pond" },
|
|
366
|
+
{ bonsai: reopened },
|
|
367
|
+
owner,
|
|
368
|
+
),
|
|
369
|
+
).rejects.toMatchObject({ code: "conflict" });
|
|
370
|
+
await expect(
|
|
371
|
+
appBackend.call(
|
|
372
|
+
"repos.delete",
|
|
373
|
+
{ repo: "pond" },
|
|
374
|
+
{ bonsai: reopened },
|
|
375
|
+
owner,
|
|
376
|
+
),
|
|
377
|
+
).resolves.toEqual({ deleted: true });
|
|
378
|
+
expect(f.deletion).toHaveBeenCalledTimes(2);
|
|
379
|
+
const create = vi.fn((name: string) =>
|
|
380
|
+
Promise.resolve({
|
|
381
|
+
name,
|
|
382
|
+
defaultBranch: "main",
|
|
383
|
+
lastPushAt: null,
|
|
384
|
+
source: null,
|
|
385
|
+
}),
|
|
386
|
+
);
|
|
387
|
+
reopened.store = gitStore({
|
|
388
|
+
...f.binding,
|
|
389
|
+
create,
|
|
390
|
+
refs: () => Promise.resolve([]),
|
|
391
|
+
cloneUrl: (name) => Promise.resolve(`https://git.test/${name}.git`),
|
|
392
|
+
});
|
|
393
|
+
await expect(
|
|
394
|
+
appBackend.call(
|
|
395
|
+
"repos.create",
|
|
396
|
+
{ name: "pond" },
|
|
397
|
+
{ bonsai: reopened },
|
|
398
|
+
owner,
|
|
399
|
+
),
|
|
400
|
+
).resolves.toMatchObject({ name: "pond" });
|
|
401
|
+
expect(create).toHaveBeenCalledTimes(1);
|
|
402
|
+
} finally {
|
|
403
|
+
f.database.close();
|
|
404
|
+
}
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
it.each(["create", "fork"] as const)(
|
|
408
|
+
"recreated %s metadata is ready without any warm provider reads",
|
|
409
|
+
async (kind) => {
|
|
410
|
+
const f = fixture();
|
|
411
|
+
try {
|
|
412
|
+
const name = kind === "create" ? "pond" : "pond--Levi";
|
|
413
|
+
if (kind === "fork")
|
|
414
|
+
f.state.registerRepo({
|
|
415
|
+
name,
|
|
416
|
+
description: null,
|
|
417
|
+
parent: "pond",
|
|
418
|
+
owner: "Levi",
|
|
419
|
+
});
|
|
420
|
+
await appBackend.call(
|
|
421
|
+
"repos.delete",
|
|
422
|
+
{ repo: name },
|
|
423
|
+
{ bonsai: f.bonsai },
|
|
424
|
+
owner,
|
|
425
|
+
);
|
|
426
|
+
const info = (name: string) =>
|
|
427
|
+
Promise.resolve({
|
|
428
|
+
name,
|
|
429
|
+
defaultBranch: "main",
|
|
430
|
+
lastPushAt: null,
|
|
431
|
+
source: null,
|
|
432
|
+
});
|
|
433
|
+
const readInfo = vi.fn(info);
|
|
434
|
+
const refs = vi.fn(() => Promise.resolve([]));
|
|
435
|
+
const cloneUrl = vi.fn((name: string) =>
|
|
436
|
+
Promise.resolve(`https://git.test/${name}.git`),
|
|
437
|
+
);
|
|
438
|
+
const bonsai = {
|
|
439
|
+
...f.bonsai,
|
|
440
|
+
store: gitStore({
|
|
441
|
+
...f.binding,
|
|
442
|
+
create: info,
|
|
443
|
+
fork: async (source, name) => ({ ...(await info(name)), source }),
|
|
444
|
+
info: readInfo,
|
|
445
|
+
refs,
|
|
446
|
+
cloneUrl,
|
|
447
|
+
}),
|
|
448
|
+
};
|
|
449
|
+
await appBackend.call(
|
|
450
|
+
kind === "create" ? "repos.create" : "repos.fork",
|
|
451
|
+
kind === "create" ? { name } : { repo: "pond" },
|
|
452
|
+
{ bonsai },
|
|
453
|
+
owner,
|
|
454
|
+
);
|
|
455
|
+
readInfo.mockClear();
|
|
456
|
+
refs.mockClear();
|
|
457
|
+
cloneUrl.mockClear();
|
|
458
|
+
await expect(
|
|
459
|
+
appBackend.call("repos.get", { repo: name }, { bonsai }, owner),
|
|
460
|
+
).resolves.toMatchObject({ repo: { name } });
|
|
461
|
+
expect(readInfo).not.toHaveBeenCalled();
|
|
462
|
+
expect(refs).not.toHaveBeenCalled();
|
|
463
|
+
expect(cloneUrl).not.toHaveBeenCalled();
|
|
464
|
+
} finally {
|
|
465
|
+
f.database.close();
|
|
466
|
+
}
|
|
467
|
+
},
|
|
468
|
+
);
|
|
469
|
+
|
|
470
|
+
it("advances the durable repository lifetime only on registration after absence", async () => {
|
|
471
|
+
const f = fixture();
|
|
472
|
+
try {
|
|
473
|
+
expect(f.state.repositoryGeneration("pond")).toBe(1);
|
|
474
|
+
f.state.registerRepo({
|
|
475
|
+
name: "pond",
|
|
476
|
+
description: null,
|
|
477
|
+
parent: null,
|
|
478
|
+
owner: null,
|
|
479
|
+
});
|
|
480
|
+
f.state.invalidateRepositoryRefs("pond");
|
|
481
|
+
expect(f.state.repositoryGeneration("pond")).toBe(1);
|
|
482
|
+
await appBackend.call(
|
|
483
|
+
"repos.delete",
|
|
484
|
+
{ repo: "pond" },
|
|
485
|
+
{ bonsai: f.bonsai },
|
|
486
|
+
owner,
|
|
487
|
+
);
|
|
488
|
+
const reopened = new BonsaiState(f.sql.sql, f.sql.transactionSync);
|
|
489
|
+
expect(reopened.repositoryGeneration("pond")).toBe(1);
|
|
490
|
+
reopened.registerRepo({
|
|
491
|
+
name: "pond",
|
|
492
|
+
description: null,
|
|
493
|
+
parent: null,
|
|
494
|
+
owner: null,
|
|
495
|
+
});
|
|
496
|
+
expect(reopened.repositoryGeneration("pond")).toBe(2);
|
|
497
|
+
} finally {
|
|
498
|
+
f.database.close();
|
|
499
|
+
}
|
|
500
|
+
});
|