@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,691 @@
|
|
|
1
|
+
import { decodeGillOptionsResponse } from "@fungi.computer/gills";
|
|
2
|
+
import { changeDetail, changePage } from "../shared/contract.js";
|
|
3
|
+
import { commitPage, copyRebuildPage } from "@fungi.computer/agar/roles/files";
|
|
4
|
+
import { DatabaseSync } from "node:sqlite";
|
|
5
|
+
import { expect, test } from "vitest";
|
|
6
|
+
import { fromNodeSqlite } from "@fungi.computer/watchdog/node-sqlite";
|
|
7
|
+
import { countingGit } from "@fungi.computer/agar/read-copy";
|
|
8
|
+
import type { GitBinding } from "@fungi.computer/agar/git";
|
|
9
|
+
import { gitStore } from "../backend/git.js";
|
|
10
|
+
import { ensureBonsaiTables, BonsaiState } from "../backend/state.js";
|
|
11
|
+
import { observePush } from "../backend/service.js";
|
|
12
|
+
import { appBackend } from "../backend/methods.js";
|
|
13
|
+
|
|
14
|
+
function fixture() {
|
|
15
|
+
const database = new DatabaseSync(":memory:");
|
|
16
|
+
const owner = fromNodeSqlite(database);
|
|
17
|
+
ensureBonsaiTables(owner.sql);
|
|
18
|
+
const unavailable = () =>
|
|
19
|
+
Promise.reject(new Error("Unexpected provider operation"));
|
|
20
|
+
const base = "a".repeat(40);
|
|
21
|
+
const head = "b".repeat(40);
|
|
22
|
+
const commit = (hash: string, parents: string[], message: string) => ({
|
|
23
|
+
hash,
|
|
24
|
+
parents,
|
|
25
|
+
message,
|
|
26
|
+
treeHash: "c".repeat(40),
|
|
27
|
+
author: { name: "Levi", email: "levi@test.invalid" },
|
|
28
|
+
committer: { name: "Levi", email: "levi@test.invalid" },
|
|
29
|
+
authoredAt: 1,
|
|
30
|
+
committedAt: 1,
|
|
31
|
+
});
|
|
32
|
+
const commits = [commit(head, [base], "Add fish"), commit(base, [], "Start")];
|
|
33
|
+
const branches = new Map<string, Map<string, string>>();
|
|
34
|
+
const trees = new Map<
|
|
35
|
+
string,
|
|
36
|
+
import("@fungi.computer/agar/git").GitTreeEntry[]
|
|
37
|
+
>();
|
|
38
|
+
const blobs = new Map<string, Uint8Array>();
|
|
39
|
+
const git: GitBinding = {
|
|
40
|
+
create: (name) =>
|
|
41
|
+
Promise.resolve({
|
|
42
|
+
name,
|
|
43
|
+
defaultBranch: "main",
|
|
44
|
+
lastPushAt: null,
|
|
45
|
+
source: null,
|
|
46
|
+
}),
|
|
47
|
+
fork: (source, name) =>
|
|
48
|
+
Promise.resolve({
|
|
49
|
+
name,
|
|
50
|
+
defaultBranch: "main",
|
|
51
|
+
lastPushAt: null,
|
|
52
|
+
source,
|
|
53
|
+
}),
|
|
54
|
+
cloneUrl: (name) => Promise.resolve(`https://git.test/${name}.git`),
|
|
55
|
+
info: (name) =>
|
|
56
|
+
Promise.resolve({
|
|
57
|
+
name,
|
|
58
|
+
defaultBranch: "main",
|
|
59
|
+
lastPushAt: null,
|
|
60
|
+
source: null,
|
|
61
|
+
}),
|
|
62
|
+
delete: unavailable,
|
|
63
|
+
log: (name, options) => {
|
|
64
|
+
const ref = options.ref;
|
|
65
|
+
const tip = branches.get(name)?.get(ref) ?? ref;
|
|
66
|
+
let current = commits.find((commit) => commit.hash === tip);
|
|
67
|
+
const history: typeof commits = [];
|
|
68
|
+
while (current !== undefined && history.length < (options.limit ?? 30)) {
|
|
69
|
+
history.push(current);
|
|
70
|
+
current = commits.find((commit) => commit.hash === current?.parents[0]);
|
|
71
|
+
}
|
|
72
|
+
return Promise.resolve(history);
|
|
73
|
+
},
|
|
74
|
+
readCommit: (_repo, hash) =>
|
|
75
|
+
Promise.resolve(commits.find((commit) => commit.hash === hash) ?? null),
|
|
76
|
+
readTree: (_repo, hash) => Promise.resolve(trees.get(hash) ?? []),
|
|
77
|
+
readBlob: (_repo, hash) => Promise.resolve(blobs.get(hash) ?? null),
|
|
78
|
+
refs: (name) =>
|
|
79
|
+
Promise.resolve(
|
|
80
|
+
[...(branches.get(name) ?? [])].map(([ref, hash]) => ({
|
|
81
|
+
name: `refs/heads/${ref}`,
|
|
82
|
+
hash,
|
|
83
|
+
})),
|
|
84
|
+
),
|
|
85
|
+
exchange: unavailable,
|
|
86
|
+
};
|
|
87
|
+
const lifetime = new AbortController();
|
|
88
|
+
const counted = countingGit(git, { limit: 1000, signal: lifetime.signal });
|
|
89
|
+
const bonsai = {
|
|
90
|
+
state: new BonsaiState(owner.sql, owner.transactionSync),
|
|
91
|
+
store: gitStore(counted.git),
|
|
92
|
+
events: { emit: () => Promise.resolve() },
|
|
93
|
+
};
|
|
94
|
+
const call = (
|
|
95
|
+
method: string,
|
|
96
|
+
input: import("../shared/contract.js").JsonValue,
|
|
97
|
+
) =>
|
|
98
|
+
appBackend.call(
|
|
99
|
+
method,
|
|
100
|
+
input,
|
|
101
|
+
{ bonsai },
|
|
102
|
+
{ actor: { kind: "member", memberId: "Levi" }, computerId: null },
|
|
103
|
+
);
|
|
104
|
+
return {
|
|
105
|
+
database,
|
|
106
|
+
owner,
|
|
107
|
+
lifetime,
|
|
108
|
+
counted,
|
|
109
|
+
bonsai,
|
|
110
|
+
call,
|
|
111
|
+
branches,
|
|
112
|
+
base,
|
|
113
|
+
head,
|
|
114
|
+
commits,
|
|
115
|
+
trees,
|
|
116
|
+
blobs,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
test("a repository created in this frame reads its saved copy after reopen without provider operations", async () => {
|
|
121
|
+
const f = fixture();
|
|
122
|
+
try {
|
|
123
|
+
await f.call("repos.create", { name: "pond" });
|
|
124
|
+
const before = f.counted.total();
|
|
125
|
+
const reopened = {
|
|
126
|
+
...f.bonsai,
|
|
127
|
+
state: new BonsaiState(f.owner.sql, f.owner.transactionSync),
|
|
128
|
+
};
|
|
129
|
+
const result = await appBackend.call(
|
|
130
|
+
"repos.get",
|
|
131
|
+
{ repo: "pond" },
|
|
132
|
+
{ bonsai: reopened },
|
|
133
|
+
{ actor: { kind: "member", memberId: "Levi" }, computerId: null },
|
|
134
|
+
);
|
|
135
|
+
expect(result).toMatchObject({
|
|
136
|
+
defaultBranch: "main",
|
|
137
|
+
empty: true,
|
|
138
|
+
refs: [],
|
|
139
|
+
cloneUrl: "https://git.test/pond.git",
|
|
140
|
+
});
|
|
141
|
+
expect(f.counted.total() - before).toBe(0);
|
|
142
|
+
} finally {
|
|
143
|
+
f.lifetime.abort();
|
|
144
|
+
f.database.close();
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("accepted pushes prepare Change summaries and details so screens do not call Git", async () => {
|
|
149
|
+
const f = fixture();
|
|
150
|
+
try {
|
|
151
|
+
await f.call("repos.create", { name: "pond" });
|
|
152
|
+
f.branches.set("pond", new Map([["main", f.base]]));
|
|
153
|
+
await f.call("repos.fork", { repo: "pond" });
|
|
154
|
+
f.branches.set("pond--Levi", new Map([["main", f.head]]));
|
|
155
|
+
await observePush(f.bonsai, {
|
|
156
|
+
eventId: "push-work",
|
|
157
|
+
repository: "pond--Levi",
|
|
158
|
+
writer: { kind: "actor", actor: { kind: "member", memberId: "Levi" } },
|
|
159
|
+
updates: [{ ref: "refs/heads/main", before: f.base, after: f.head }],
|
|
160
|
+
});
|
|
161
|
+
const before = f.counted.total();
|
|
162
|
+
expect(await f.call("repos.list", {})).toMatchObject({
|
|
163
|
+
repos: [{ openChanges: 1 }],
|
|
164
|
+
});
|
|
165
|
+
expect(await f.call("changes.list", { repo: "pond" })).toMatchObject({
|
|
166
|
+
changes: [{ ahead: 1, behind: 0, base: f.base }],
|
|
167
|
+
});
|
|
168
|
+
expect(
|
|
169
|
+
await f.call("changes.get", { repo: "pond", number: 1 }),
|
|
170
|
+
).toMatchObject({
|
|
171
|
+
commits: [{ commit: { hash: f.head } }],
|
|
172
|
+
diff: { files: [] },
|
|
173
|
+
});
|
|
174
|
+
const changesGill = await appBackend.gill(
|
|
175
|
+
{ id: "open-changes", params: { repo: "pond" } },
|
|
176
|
+
{ bonsai: f.bonsai },
|
|
177
|
+
{ actor: { kind: "member", memberId: "Levi" }, computerId: null },
|
|
178
|
+
);
|
|
179
|
+
expect(changesGill).toMatchObject({
|
|
180
|
+
state: "full",
|
|
181
|
+
rows: [
|
|
182
|
+
{
|
|
183
|
+
open: {
|
|
184
|
+
params: [
|
|
185
|
+
{ key: "page", value: "change" },
|
|
186
|
+
{ key: "repo", value: "pond" },
|
|
187
|
+
{ key: "number", value: "1" },
|
|
188
|
+
],
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
],
|
|
192
|
+
});
|
|
193
|
+
expect(f.counted.total() - before).toBe(0);
|
|
194
|
+
} finally {
|
|
195
|
+
f.lifetime.abort();
|
|
196
|
+
f.database.close();
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("listing an expired merge lease neither calls Git nor recovers or writes it", async () => {
|
|
201
|
+
const f = fixture();
|
|
202
|
+
try {
|
|
203
|
+
await f.call("repos.create", { name: "pond" });
|
|
204
|
+
f.branches.set("pond", new Map([["main", f.base]]));
|
|
205
|
+
await f.call("repos.fork", { repo: "pond" });
|
|
206
|
+
f.branches.set("pond--Levi", new Map([["main", f.head]]));
|
|
207
|
+
await observePush(f.bonsai, {
|
|
208
|
+
eventId: "leased-work",
|
|
209
|
+
repository: "pond--Levi",
|
|
210
|
+
writer: { kind: "actor", actor: { kind: "member", memberId: "Levi" } },
|
|
211
|
+
updates: [{ ref: "refs/heads/main", before: f.base, after: f.head }],
|
|
212
|
+
});
|
|
213
|
+
const row = f.bonsai.state.requireChange("pond", 1);
|
|
214
|
+
f.bonsai.state.reviseChange(
|
|
215
|
+
{
|
|
216
|
+
...row,
|
|
217
|
+
state: "merging",
|
|
218
|
+
lease: {
|
|
219
|
+
id: "stalled",
|
|
220
|
+
by: "Levi",
|
|
221
|
+
head: f.head,
|
|
222
|
+
at: Date.now() - 120_001,
|
|
223
|
+
},
|
|
224
|
+
},
|
|
225
|
+
row.revision,
|
|
226
|
+
);
|
|
227
|
+
const leased = f.bonsai.state.requireChange("pond", 1);
|
|
228
|
+
const before = f.counted.total();
|
|
229
|
+
expect(await f.call("changes.list", { repo: "pond" })).toMatchObject({
|
|
230
|
+
changes: [{ state: "merging" }],
|
|
231
|
+
});
|
|
232
|
+
expect(f.counted.total() - before).toBe(0);
|
|
233
|
+
expect(f.bonsai.state.requireChange("pond", 1)).toEqual(leased);
|
|
234
|
+
} finally {
|
|
235
|
+
f.lifetime.abort();
|
|
236
|
+
f.database.close();
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test("opening a Change takes tips from the copy and uses at most two bounded logs", async () => {
|
|
241
|
+
const f = fixture();
|
|
242
|
+
try {
|
|
243
|
+
await f.call("repos.create", { name: "pond" });
|
|
244
|
+
f.branches.set(
|
|
245
|
+
"pond",
|
|
246
|
+
new Map([
|
|
247
|
+
["main", f.base],
|
|
248
|
+
["topic", f.head],
|
|
249
|
+
]),
|
|
250
|
+
);
|
|
251
|
+
await observePush(f.bonsai, {
|
|
252
|
+
eventId: "topic-work",
|
|
253
|
+
repository: "pond",
|
|
254
|
+
writer: { kind: "actor", actor: { kind: "member", memberId: "Levi" } },
|
|
255
|
+
updates: [
|
|
256
|
+
{ ref: "refs/heads/topic", before: "0".repeat(40), after: f.head },
|
|
257
|
+
],
|
|
258
|
+
});
|
|
259
|
+
const before = f.counted.counts();
|
|
260
|
+
expect(
|
|
261
|
+
await f.call("changes.open", { repo: "pond", sourceRef: "topic" }),
|
|
262
|
+
).toMatchObject({ head: f.head, target: f.base });
|
|
263
|
+
const after = f.counted.counts();
|
|
264
|
+
expect((after.info ?? 0) - (before.info ?? 0)).toBe(0);
|
|
265
|
+
expect((after.refs ?? 0) - (before.refs ?? 0)).toBe(0);
|
|
266
|
+
expect((after.log ?? 0) - (before.log ?? 0)).toBeLessThanOrEqual(2);
|
|
267
|
+
const reads = f.counted.total();
|
|
268
|
+
expect(
|
|
269
|
+
await f.call("changes.get", { repo: "pond", number: 1 }),
|
|
270
|
+
).toMatchObject({ change: { head: f.head } });
|
|
271
|
+
expect(f.counted.total() - reads).toBe(0);
|
|
272
|
+
} finally {
|
|
273
|
+
f.lifetime.abort();
|
|
274
|
+
f.database.close();
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test("large Change copies advance in foreground pages with at most 40 provider operations per frame", async () => {
|
|
279
|
+
const f = fixture();
|
|
280
|
+
try {
|
|
281
|
+
const root = "e".repeat(40);
|
|
282
|
+
const proposed = f.commits[0];
|
|
283
|
+
if (proposed === undefined)
|
|
284
|
+
throw new Error("Missing proposed fixture commit");
|
|
285
|
+
proposed.treeHash = root;
|
|
286
|
+
const files = Array.from({ length: 60 }, (_, index) => {
|
|
287
|
+
const hash = (index + 16).toString(16).padStart(40, "0");
|
|
288
|
+
f.blobs.set(hash, new TextEncoder().encode(`File ${String(index)}\n`));
|
|
289
|
+
return {
|
|
290
|
+
name: `${String(index)}.txt`,
|
|
291
|
+
type: "blob" as const,
|
|
292
|
+
mode: "100644",
|
|
293
|
+
hash,
|
|
294
|
+
};
|
|
295
|
+
});
|
|
296
|
+
f.trees.set(root, files);
|
|
297
|
+
await f.call("repos.create", { name: "pond" });
|
|
298
|
+
f.branches.set("pond", new Map([["main", f.base]]));
|
|
299
|
+
await f.call("repos.fork", { repo: "pond" });
|
|
300
|
+
f.branches.set("pond--Levi", new Map([["main", f.head]]));
|
|
301
|
+
const beforePush = f.counted.total();
|
|
302
|
+
await observePush(f.bonsai, {
|
|
303
|
+
eventId: "large-work",
|
|
304
|
+
repository: "pond--Levi",
|
|
305
|
+
writer: { kind: "actor", actor: { kind: "member", memberId: "Levi" } },
|
|
306
|
+
updates: [{ ref: "refs/heads/main", before: f.base, after: f.head }],
|
|
307
|
+
});
|
|
308
|
+
expect(f.counted.total() - beforePush).toBeLessThanOrEqual(40);
|
|
309
|
+
let cursor: string | null = null;
|
|
310
|
+
let done = false;
|
|
311
|
+
let pages = 0;
|
|
312
|
+
while (!done && pages < 10) {
|
|
313
|
+
const before = f.counted.total();
|
|
314
|
+
const result = copyRebuildPage.parse(
|
|
315
|
+
await f.call("rebuildCopies", { cursor }),
|
|
316
|
+
);
|
|
317
|
+
expect(f.counted.total() - before).toBeLessThanOrEqual(40);
|
|
318
|
+
({ done, cursor } = result);
|
|
319
|
+
pages++;
|
|
320
|
+
}
|
|
321
|
+
expect(done).toBe(true);
|
|
322
|
+
const before = f.counted.total();
|
|
323
|
+
const detail = changeDetail.parse(
|
|
324
|
+
await f.call("changes.get", { repo: "pond", number: 1 }),
|
|
325
|
+
);
|
|
326
|
+
expect(detail.diff.files).toHaveLength(60);
|
|
327
|
+
expect(f.counted.total() - before).toBe(0);
|
|
328
|
+
} finally {
|
|
329
|
+
f.lifetime.abort();
|
|
330
|
+
f.database.close();
|
|
331
|
+
}
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
test("opening a pushed merge head uses prepared side ancestry without extra logs", async () => {
|
|
335
|
+
const f = fixture();
|
|
336
|
+
try {
|
|
337
|
+
const proposed = f.commits[0];
|
|
338
|
+
const initial = f.commits[1];
|
|
339
|
+
if (!proposed || !initial) throw new Error("Missing fixture history");
|
|
340
|
+
const side = {
|
|
341
|
+
...initial,
|
|
342
|
+
hash: "d".repeat(40),
|
|
343
|
+
parents: [f.base],
|
|
344
|
+
message: "Side work",
|
|
345
|
+
};
|
|
346
|
+
proposed.parents.push(side.hash);
|
|
347
|
+
f.commits.push(side);
|
|
348
|
+
await f.call("repos.create", { name: "pond" });
|
|
349
|
+
f.branches.set(
|
|
350
|
+
"pond",
|
|
351
|
+
new Map([
|
|
352
|
+
["main", f.base],
|
|
353
|
+
["topic", f.head],
|
|
354
|
+
]),
|
|
355
|
+
);
|
|
356
|
+
await observePush(f.bonsai, {
|
|
357
|
+
eventId: "merged-topic",
|
|
358
|
+
repository: "pond",
|
|
359
|
+
writer: { kind: "actor", actor: { kind: "member", memberId: "Levi" } },
|
|
360
|
+
updates: [
|
|
361
|
+
{ ref: "refs/heads/topic", before: "0".repeat(40), after: f.head },
|
|
362
|
+
],
|
|
363
|
+
});
|
|
364
|
+
const before = f.counted.counts().log ?? 0;
|
|
365
|
+
expect(
|
|
366
|
+
await f.call("changes.open", { repo: "pond", sourceRef: "topic" }),
|
|
367
|
+
).toMatchObject({ head: f.head, target: f.base });
|
|
368
|
+
expect((f.counted.counts().log ?? 0) - before).toBeLessThanOrEqual(2);
|
|
369
|
+
} finally {
|
|
370
|
+
f.lifetime.abort();
|
|
371
|
+
f.database.close();
|
|
372
|
+
}
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
test("Change pages forward a producer cursor over saved summaries with zero Git and reject a changed snapshot", async () => {
|
|
376
|
+
const f = fixture();
|
|
377
|
+
try {
|
|
378
|
+
await f.call("repos.create", { name: "pond" });
|
|
379
|
+
f.branches.set("pond", new Map([["main", f.base]]));
|
|
380
|
+
await f.call("repos.fork", { repo: "pond" });
|
|
381
|
+
f.branches.set(
|
|
382
|
+
"pond--Levi",
|
|
383
|
+
new Map([
|
|
384
|
+
["main", f.base],
|
|
385
|
+
...Array.from(
|
|
386
|
+
{ length: 21 },
|
|
387
|
+
(_, i) => [`fish${String(i)}`, f.head] as const,
|
|
388
|
+
),
|
|
389
|
+
]),
|
|
390
|
+
);
|
|
391
|
+
await observePush(f.bonsai, {
|
|
392
|
+
repository: "pond--Levi",
|
|
393
|
+
eventId: "page-changes",
|
|
394
|
+
writer: { kind: "actor", actor: { kind: "member", memberId: "Levi" } },
|
|
395
|
+
updates: Array.from({ length: 21 }, (_, i) => ({
|
|
396
|
+
ref: `refs/heads/fish${String(i)}`,
|
|
397
|
+
before: "0".repeat(40),
|
|
398
|
+
after: f.head,
|
|
399
|
+
})),
|
|
400
|
+
});
|
|
401
|
+
const before = f.counted.total();
|
|
402
|
+
const first = changePage.parse(
|
|
403
|
+
await f.call("changes.list", { repo: "pond", limit: 20 }),
|
|
404
|
+
);
|
|
405
|
+
expect(first.changes).toHaveLength(20);
|
|
406
|
+
expect(first.changes.map((row) => row.number)).toEqual(
|
|
407
|
+
Array.from({ length: 20 }, (_, i) => 21 - i),
|
|
408
|
+
);
|
|
409
|
+
if (first.next === undefined)
|
|
410
|
+
throw new Error("First page lacked its cursor");
|
|
411
|
+
const last = changePage.parse(
|
|
412
|
+
await f.call("changes.list", {
|
|
413
|
+
repo: "pond",
|
|
414
|
+
limit: 20,
|
|
415
|
+
after: first.next,
|
|
416
|
+
}),
|
|
417
|
+
);
|
|
418
|
+
expect(last.changes.map((row) => row.number)).toEqual([1]);
|
|
419
|
+
expect(last.next).toBeUndefined();
|
|
420
|
+
expect(f.counted.total() - before).toBe(0);
|
|
421
|
+
await expect(
|
|
422
|
+
f.call("commits.list", {
|
|
423
|
+
repo: "pond",
|
|
424
|
+
ref: "main",
|
|
425
|
+
limit: 20,
|
|
426
|
+
after: first.next,
|
|
427
|
+
}),
|
|
428
|
+
).rejects.toMatchObject({ code: "invalid" });
|
|
429
|
+
await f.call("changes.close", { repo: "pond", number: 1 });
|
|
430
|
+
const changedAt = f.counted.total();
|
|
431
|
+
await expect(
|
|
432
|
+
f.call("changes.list", { repo: "pond", limit: 20, after: first.next }),
|
|
433
|
+
).rejects.toMatchObject({ code: "conflict" });
|
|
434
|
+
expect(f.counted.total() - changedAt).toBe(0);
|
|
435
|
+
} finally {
|
|
436
|
+
f.lifetime.abort();
|
|
437
|
+
f.database.close();
|
|
438
|
+
}
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
test("Commit pages use the saved window at the requested size and reject stale and cross-ref cursors without Git", async () => {
|
|
442
|
+
const f = fixture();
|
|
443
|
+
try {
|
|
444
|
+
await f.call("repos.create", { name: "pond" });
|
|
445
|
+
const template = f.commits.at(0);
|
|
446
|
+
if (template === undefined) throw new Error("Fixture commit missing");
|
|
447
|
+
let previous = f.head;
|
|
448
|
+
for (let i = 0; i < 70; i++) {
|
|
449
|
+
const hash = String(i + 101).padStart(40, "0");
|
|
450
|
+
f.commits.unshift({
|
|
451
|
+
...template,
|
|
452
|
+
hash,
|
|
453
|
+
parents: [previous],
|
|
454
|
+
message: `Fish ${String(i)}`,
|
|
455
|
+
authoredAt: i + 2,
|
|
456
|
+
committedAt: i + 2,
|
|
457
|
+
});
|
|
458
|
+
previous = hash;
|
|
459
|
+
}
|
|
460
|
+
f.branches.set(
|
|
461
|
+
"pond",
|
|
462
|
+
new Map([
|
|
463
|
+
["main", previous],
|
|
464
|
+
["work", previous],
|
|
465
|
+
]),
|
|
466
|
+
);
|
|
467
|
+
f.bonsai.state.repositoryCopies.stale("pond");
|
|
468
|
+
await f.call("repos.get", { repo: "pond" });
|
|
469
|
+
await f.call("commits.list", { repo: "pond", ref: "main" });
|
|
470
|
+
const before = f.counted.total();
|
|
471
|
+
const first = commitPage.parse(
|
|
472
|
+
await f.call("commits.list", { repo: "pond", ref: "main", limit: 20 }),
|
|
473
|
+
);
|
|
474
|
+
expect(first.commits).toHaveLength(20);
|
|
475
|
+
const commitsGill = await appBackend.gill(
|
|
476
|
+
{ id: "recent-commits", params: { repo: "pond", branch: "main" } },
|
|
477
|
+
{ bonsai: f.bonsai },
|
|
478
|
+
{ actor: { kind: "member", memberId: "Levi" }, computerId: null },
|
|
479
|
+
);
|
|
480
|
+
if (!("rows" in commitsGill) || commitsGill.state !== "full")
|
|
481
|
+
throw new Error("Expected a full commit page");
|
|
482
|
+
expect(commitsGill.rows.map((row) => row.id)).toEqual(
|
|
483
|
+
first.commits.map((row) => row.commit.hash),
|
|
484
|
+
);
|
|
485
|
+
expect(commitsGill.next).toBe(first.next);
|
|
486
|
+
expect(commitsGill.rows[0]?.open?.params).toEqual([
|
|
487
|
+
{ key: "page", value: "commit" },
|
|
488
|
+
{ key: "repo", value: "pond" },
|
|
489
|
+
{ key: "hash", value: first.commits[0]?.commit.hash },
|
|
490
|
+
]);
|
|
491
|
+
if (first.next === undefined)
|
|
492
|
+
throw new Error("First page lacked its cursor");
|
|
493
|
+
let page = first;
|
|
494
|
+
const hashes = first.commits.map((row) => row.commit.hash);
|
|
495
|
+
while (page.next !== undefined) {
|
|
496
|
+
page = commitPage.parse(
|
|
497
|
+
await f.call("commits.list", {
|
|
498
|
+
repo: "pond",
|
|
499
|
+
ref: "main",
|
|
500
|
+
limit: 20,
|
|
501
|
+
after: page.next,
|
|
502
|
+
}),
|
|
503
|
+
);
|
|
504
|
+
expect(page.commits.length).toBeLessThanOrEqual(20);
|
|
505
|
+
hashes.push(...page.commits.map((row) => row.commit.hash));
|
|
506
|
+
}
|
|
507
|
+
expect(hashes).toHaveLength(72);
|
|
508
|
+
expect(new Set(hashes).size).toBe(72);
|
|
509
|
+
expect(f.counted.total() - before).toBe(0);
|
|
510
|
+
await expect(
|
|
511
|
+
f.call("commits.list", {
|
|
512
|
+
repo: "pond",
|
|
513
|
+
ref: "work",
|
|
514
|
+
limit: 20,
|
|
515
|
+
after: first.next,
|
|
516
|
+
}),
|
|
517
|
+
).rejects.toMatchObject({ code: "invalid" });
|
|
518
|
+
expect(f.counted.total() - before).toBe(0);
|
|
519
|
+
f.branches.set("pond", new Map([["main", f.base]]));
|
|
520
|
+
f.bonsai.state.invalidateRepositoryRefs("pond");
|
|
521
|
+
await f.call("repos.get", { repo: "pond" });
|
|
522
|
+
const changedAt = f.counted.total();
|
|
523
|
+
await expect(
|
|
524
|
+
f.call("commits.list", {
|
|
525
|
+
repo: "pond",
|
|
526
|
+
ref: "main",
|
|
527
|
+
limit: 20,
|
|
528
|
+
after: first.next,
|
|
529
|
+
}),
|
|
530
|
+
).rejects.toMatchObject({ code: "conflict" });
|
|
531
|
+
expect(f.counted.total() - changedAt).toBe(0);
|
|
532
|
+
} finally {
|
|
533
|
+
f.lifetime.abort();
|
|
534
|
+
f.database.close();
|
|
535
|
+
}
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
test("repository picker pages the saved canonical catalog after facet reconstruction with zero Git", async () => {
|
|
539
|
+
const f = fixture();
|
|
540
|
+
try {
|
|
541
|
+
await f.call("repos.create", { name: "pond", description: "Koi" });
|
|
542
|
+
await f.call("repos.create", { name: "forest", description: "Trees" });
|
|
543
|
+
await f.call("repos.fork", { repo: "pond" });
|
|
544
|
+
const reopened = {
|
|
545
|
+
...f.bonsai,
|
|
546
|
+
state: new BonsaiState(f.owner.sql, f.owner.transactionSync),
|
|
547
|
+
};
|
|
548
|
+
const before = f.counted.total();
|
|
549
|
+
const result = await appBackend.call(
|
|
550
|
+
"repos.options",
|
|
551
|
+
{ params: {}, limit: 1 },
|
|
552
|
+
{ bonsai: reopened },
|
|
553
|
+
{ actor: { kind: "member", memberId: "Levi" }, computerId: null },
|
|
554
|
+
);
|
|
555
|
+
expect(result).toMatchObject({
|
|
556
|
+
options: [{ value: "forest", label: "forest", detail: "Trees" }],
|
|
557
|
+
});
|
|
558
|
+
expect(f.counted.total() - before).toBe(0);
|
|
559
|
+
expect(appBackend.methods["repos.options"]?.policy).toMatchObject({
|
|
560
|
+
effect: "read",
|
|
561
|
+
requires: [],
|
|
562
|
+
});
|
|
563
|
+
} finally {
|
|
564
|
+
f.lifetime.abort();
|
|
565
|
+
f.database.close();
|
|
566
|
+
}
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
test("repository options preserve the caller limit and continuation and refuse a changed catalog cursor", async () => {
|
|
570
|
+
const f = fixture();
|
|
571
|
+
try {
|
|
572
|
+
await f.call("repos.create", { name: "pond" });
|
|
573
|
+
await f.call("repos.create", { name: "forest" });
|
|
574
|
+
const before = f.counted.total();
|
|
575
|
+
const first = decodeGillOptionsResponse(
|
|
576
|
+
await f.call("repos.options", { params: {}, limit: 1 }),
|
|
577
|
+
);
|
|
578
|
+
expect(first.options).toEqual([{ value: "forest", label: "forest" }]);
|
|
579
|
+
if (first.next === undefined)
|
|
580
|
+
throw new Error("Missing picker continuation");
|
|
581
|
+
expect(
|
|
582
|
+
decodeGillOptionsResponse(
|
|
583
|
+
await f.call("repos.options", {
|
|
584
|
+
params: {},
|
|
585
|
+
limit: 1,
|
|
586
|
+
after: first.next,
|
|
587
|
+
}),
|
|
588
|
+
),
|
|
589
|
+
).toEqual({ options: [{ value: "pond", label: "pond" }] });
|
|
590
|
+
expect(f.counted.total() - before).toBe(0);
|
|
591
|
+
await f.call("repos.create", { name: "lake" });
|
|
592
|
+
const changed = f.counted.total();
|
|
593
|
+
await expect(
|
|
594
|
+
f.call("repos.options", { params: {}, limit: 1, after: first.next }),
|
|
595
|
+
).rejects.toMatchObject({ code: "conflict" });
|
|
596
|
+
expect(f.counted.total() - changed).toBe(0);
|
|
597
|
+
} finally {
|
|
598
|
+
f.lifetime.abort();
|
|
599
|
+
f.database.close();
|
|
600
|
+
}
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
test("branch options use the chosen repo's saved refs, page without tags and refuse cross-view or changed-ref cursors with zero Git", async () => {
|
|
604
|
+
const f = fixture();
|
|
605
|
+
try {
|
|
606
|
+
await f.call("repos.create", { name: "pond" });
|
|
607
|
+
await f.call("repos.create", { name: "forest" });
|
|
608
|
+
f.branches.set(
|
|
609
|
+
"pond",
|
|
610
|
+
new Map([
|
|
611
|
+
["main", f.base],
|
|
612
|
+
["topic", f.head],
|
|
613
|
+
]),
|
|
614
|
+
);
|
|
615
|
+
await observePush(f.bonsai, {
|
|
616
|
+
eventId: "picker-refs",
|
|
617
|
+
repository: "pond",
|
|
618
|
+
writer: { kind: "actor", actor: { kind: "member", memberId: "Levi" } },
|
|
619
|
+
updates: [
|
|
620
|
+
{ ref: "refs/heads/topic", before: "0".repeat(40), after: f.head },
|
|
621
|
+
],
|
|
622
|
+
});
|
|
623
|
+
const reopened = {
|
|
624
|
+
...f.bonsai,
|
|
625
|
+
state: new BonsaiState(f.owner.sql, f.owner.transactionSync),
|
|
626
|
+
};
|
|
627
|
+
const read = (params: { repo: string }, after?: string) =>
|
|
628
|
+
appBackend.call(
|
|
629
|
+
"branches.options",
|
|
630
|
+
after === undefined
|
|
631
|
+
? { params, limit: 1 }
|
|
632
|
+
: { params, limit: 1, after },
|
|
633
|
+
{ bonsai: reopened },
|
|
634
|
+
{ actor: { kind: "member", memberId: "Levi" }, computerId: null },
|
|
635
|
+
);
|
|
636
|
+
const before = f.counted.total();
|
|
637
|
+
const first = decodeGillOptionsResponse(await read({ repo: "pond" }));
|
|
638
|
+
expect(first.options).toEqual([{ value: "main", label: "main" }]);
|
|
639
|
+
if (first.next === undefined)
|
|
640
|
+
throw new Error("Missing branch continuation");
|
|
641
|
+
expect(
|
|
642
|
+
decodeGillOptionsResponse(await read({ repo: "pond" }, first.next)),
|
|
643
|
+
).toEqual({ options: [{ value: "topic", label: "topic" }] });
|
|
644
|
+
await expect(read({ repo: "forest" }, first.next)).rejects.toMatchObject({
|
|
645
|
+
code: "invalid",
|
|
646
|
+
});
|
|
647
|
+
const saved = reopened.state.repositoryCopies.read("pond");
|
|
648
|
+
if (saved.kind !== "ready") throw new Error("Missing saved refs");
|
|
649
|
+
reopened.state.repositoryCopies.write("pond", saved.head, {
|
|
650
|
+
...saved.value,
|
|
651
|
+
refs: [...saved.value.refs, { name: "refs/tags/v1", hash: f.base }],
|
|
652
|
+
});
|
|
653
|
+
expect(
|
|
654
|
+
decodeGillOptionsResponse(await read({ repo: "pond" }, first.next))
|
|
655
|
+
.options,
|
|
656
|
+
).toEqual([{ value: "topic", label: "topic" }]);
|
|
657
|
+
reopened.state.repositoryCopies.write("pond", saved.head, {
|
|
658
|
+
...saved.value,
|
|
659
|
+
refs: [...saved.value.refs, { name: "refs/heads/new", hash: f.base }],
|
|
660
|
+
});
|
|
661
|
+
await expect(read({ repo: "pond" }, first.next)).rejects.toMatchObject({
|
|
662
|
+
code: "conflict",
|
|
663
|
+
});
|
|
664
|
+
expect(f.counted.total() - before).toBe(0);
|
|
665
|
+
} finally {
|
|
666
|
+
f.lifetime.abort();
|
|
667
|
+
f.database.close();
|
|
668
|
+
}
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
test("branch options reject missing or unknown selection and pending saved refs without rebuilding through Git", async () => {
|
|
672
|
+
const f = fixture();
|
|
673
|
+
try {
|
|
674
|
+
await f.call("repos.create", { name: "pond" });
|
|
675
|
+
const before = f.counted.total();
|
|
676
|
+
await expect(
|
|
677
|
+
f.call("branches.options", { params: {}, limit: 20 }),
|
|
678
|
+
).rejects.toMatchObject({ code: "invalid" });
|
|
679
|
+
await expect(
|
|
680
|
+
f.call("branches.options", { params: { repo: "missing" }, limit: 20 }),
|
|
681
|
+
).rejects.toMatchObject({ code: "not_found" });
|
|
682
|
+
f.bonsai.state.invalidateRepositoryRefs("pond");
|
|
683
|
+
await expect(
|
|
684
|
+
f.call("branches.options", { params: { repo: "pond" }, limit: 20 }),
|
|
685
|
+
).rejects.toMatchObject({ code: "unavailable" });
|
|
686
|
+
expect(f.counted.total() - before).toBe(0);
|
|
687
|
+
} finally {
|
|
688
|
+
f.lifetime.abort();
|
|
689
|
+
f.database.close();
|
|
690
|
+
}
|
|
691
|
+
});
|