@fungi.computer/bonsai 0.0.0-stage → 0.1.0

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.
Files changed (133) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/LICENSE +21 -0
  3. package/README.md +196 -2
  4. package/backend/background.ts +259 -0
  5. package/backend/change-copy.ts +99 -0
  6. package/backend/changes.ts +530 -0
  7. package/backend/diff.ts +228 -0
  8. package/backend/file-edits.ts +850 -0
  9. package/backend/files-copy.ts +361 -0
  10. package/backend/gills.ts +125 -0
  11. package/backend/git.ts +123 -0
  12. package/backend/graph-commit-copies.ts +321 -0
  13. package/backend/history.ts +178 -0
  14. package/backend/index.ts +144 -0
  15. package/backend/insights.ts +256 -0
  16. package/backend/merge.ts +999 -0
  17. package/backend/methods.ts +848 -0
  18. package/backend/policy.ts +160 -0
  19. package/backend/read-copy-page.ts +77 -0
  20. package/backend/repository-copy.ts +70 -0
  21. package/backend/reviews.ts +317 -0
  22. package/backend/service.ts +892 -0
  23. package/backend/state.ts +914 -0
  24. package/demo/koi-pond/README.md +17 -0
  25. package/demo/koi-pond/index.html +11 -0
  26. package/demo/koi-pond/package.json +13 -0
  27. package/demo/koi-pond/pnpm-lock.yaml +450 -0
  28. package/demo/koi-pond/src/fish.ts +32 -0
  29. package/demo/koi-pond/src/main.ts +24 -0
  30. package/demo/koi-pond/src/pond.ts +27 -0
  31. package/docs/media/bonsai-insights.png +0 -0
  32. package/docs/media/overview-phone.png +0 -0
  33. package/docs/media/overview.png +0 -0
  34. package/package.json +152 -3
  35. package/scripts/agent.sh +55 -0
  36. package/scripts/demo.sh +233 -0
  37. package/scripts/rebuild-copies.mjs +34 -0
  38. package/scripts/release.mjs +52 -0
  39. package/scripts/runtime-proof.mjs +299 -0
  40. package/scripts/seed-demo.mjs +724 -0
  41. package/scripts/seed-step.mjs +37 -0
  42. package/scripts/shed-terminal-proof.mjs +774 -0
  43. package/shared/contract.ts +404 -0
  44. package/shared/hooks.ts +208 -0
  45. package/shared/insights.ts +90 -0
  46. package/shared/picker-options.ts +23 -0
  47. package/shared/presentation.ts +21 -0
  48. package/shared/repository-deletion.ts +4 -0
  49. package/test/apps-build-install.test.ts +302 -0
  50. package/test/background-runtime.test.ts +206 -0
  51. package/test/browser/board.browser.mjs +52 -0
  52. package/test/browser/capture.browser.mjs +1264 -0
  53. package/test/browser/edit-in-inkcap.browser.mjs +83 -0
  54. package/test/browser/repository-settings-fixture.tsx +108 -0
  55. package/test/browser/repository-settings.browser.mjs +136 -0
  56. package/test/browser/repository-settings.html +10 -0
  57. package/test/browser/visual-fixture.tsx +667 -0
  58. package/test/browser/visual.html +10 -0
  59. package/test/browser/vite.config.mjs +9 -0
  60. package/test/change-page.test.ts +916 -0
  61. package/test/changes.test.ts +1063 -0
  62. package/test/checks-workflow-template.test.mjs +234 -0
  63. package/test/diff.test.ts +70 -0
  64. package/test/edit-in-inkcap.test.ts +227 -0
  65. package/test/file-edits.test.ts +2714 -0
  66. package/test/files-read-copy-calls.test.ts +374 -0
  67. package/test/fork-adoption.test.ts +111 -0
  68. package/test/gills.test.ts +125 -0
  69. package/test/git-binding.fixture.ts +30 -0
  70. package/test/git-push.config.jsonc +9 -0
  71. package/test/git-push.native.mjs +413 -0
  72. package/test/git-push.worker.ts +280 -0
  73. package/test/graph-commit-read-copy-calls.test.ts +401 -0
  74. package/test/home-page.test.ts +170 -0
  75. package/test/hooks.test.ts +250 -0
  76. package/test/insight-data.test.ts +51 -0
  77. package/test/insight-layout.test.mjs +230 -0
  78. package/test/insights.test.ts +154 -0
  79. package/test/merge-background.test.ts +756 -0
  80. package/test/merge-conflict.test.ts +337 -0
  81. package/test/merge-declaration.test.ts +46 -0
  82. package/test/merge-repro.test.ts +561 -0
  83. package/test/merge-template-build.test.mjs +234 -0
  84. package/test/network-layout.test.ts +200 -0
  85. package/test/outbox.test.ts +424 -0
  86. package/test/parts.test.ts +86 -0
  87. package/test/pkt-line.test.ts +54 -0
  88. package/test/policy.test.ts +172 -0
  89. package/test/present.test.ts +27 -0
  90. package/test/read-copy-calls.test.ts +691 -0
  91. package/test/read-copy-composer.test.ts +388 -0
  92. package/test/read-copy-lifetimes.test.ts +214 -0
  93. package/test/read-copy-rpc.config.jsonc +12 -0
  94. package/test/read-copy-rpc.native.mjs +47 -0
  95. package/test/read-copy-rpc.worker.ts +92 -0
  96. package/test/recent-activity.test.ts +62 -0
  97. package/test/repository-deletion.test.ts +500 -0
  98. package/test/seed-resume.test.mjs +91 -0
  99. package/test/storyboard-save-https-git.fixture.mjs +119 -0
  100. package/test/storyboard-save-template.test.mjs +545 -0
  101. package/test/ui-errors.test.ts +976 -0
  102. package/test/workflow-store.test.ts +115 -0
  103. package/tsconfig.json +36 -0
  104. package/turbo.jsonc +25 -0
  105. package/vite.config.ts +19 -0
  106. package/vitest.config.ts +47 -0
  107. package/web/api.ts +224 -0
  108. package/web/app-build-panel.tsx +171 -0
  109. package/web/board.tsx +259 -0
  110. package/web/bonsai.css +642 -0
  111. package/web/change-page.tsx +719 -0
  112. package/web/change-rows.tsx +135 -0
  113. package/web/code-tab.tsx +267 -0
  114. package/web/commit-tree.tsx +152 -0
  115. package/web/format.ts +54 -0
  116. package/web/home-page.tsx +274 -0
  117. package/web/index.html +17 -0
  118. package/web/insight-data.ts +76 -0
  119. package/web/insight-tiles.tsx +534 -0
  120. package/web/insights.tsx +250 -0
  121. package/web/issues-panel.tsx +305 -0
  122. package/web/main.tsx +72 -0
  123. package/web/network-graph.tsx +647 -0
  124. package/web/overview.tsx +339 -0
  125. package/web/pages.tsx +469 -0
  126. package/web/parts.tsx +381 -0
  127. package/web/public/favicon.svg +1 -0
  128. package/web/repo-navigation.tsx +256 -0
  129. package/web/repo-settings.tsx +145 -0
  130. package/web/router.tsx +151 -0
  131. package/web/transport.ts +91 -0
  132. package/web/views.tsx +151 -0
  133. package/web/working-now.tsx +98 -0
@@ -0,0 +1,374 @@
1
+ import { DatabaseSync } from "node:sqlite";
2
+ import { expect, it } from "vitest";
3
+ import {
4
+ commitFiles,
5
+ type GitFileChange,
6
+ } from "@fungi.computer/agar/git-commit";
7
+ import {
8
+ countingGit,
9
+ ReadCopyBudgetExceeded,
10
+ } from "@fungi.computer/agar/read-copy";
11
+ import { fromNodeSqlite } from "@fungi.computer/watchdog/node-sqlite";
12
+ import { realGit } from "@fungi.computer/agar/test-kit/git";
13
+ import { gitStore, readCopyFrame } from "../backend/git.js";
14
+ import { ensureBonsaiTables, BonsaiState } from "../backend/state.js";
15
+ import { getRepoDetail } from "../backend/repository-copy.js";
16
+ import {
17
+ listFilesCopy,
18
+ listTreeCopy,
19
+ readFileCopy,
20
+ invalidateFilesCopies,
21
+ refreshFilesCopy,
22
+ filesCopyRequests,
23
+ } from "../backend/files-copy.js";
24
+
25
+ const input = { repo: "pond", ref: "main" };
26
+ const author = { name: "Morel", email: "morel@test.invalid" };
27
+ const text = (value: string) => new TextEncoder().encode(value);
28
+
29
+ async function fixture(extra: GitFileChange[] = []) {
30
+ const database = new DatabaseSync(":memory:");
31
+ const native = realGit();
32
+ try {
33
+ await native.git.create("pond", { defaultBranch: "main" });
34
+ const outcome = await commitFiles(native.git, {
35
+ repository: "pond",
36
+ branch: "main",
37
+ expectedHead: null,
38
+ author,
39
+ message: "Plant the Files fixture",
40
+ changes: [
41
+ { path: "README.md", content: text("# Pond\n") },
42
+ { path: "src/fish.ts", content: text("export const fish = 1;\n") },
43
+ ...extra,
44
+ ],
45
+ });
46
+ if (outcome.kind !== "committed") throw new Error("Fixture did not commit");
47
+ const owner = fromNodeSqlite(database);
48
+ ensureBonsaiTables(owner.sql);
49
+ const state = new BonsaiState(owner.sql, owner.transactionSync);
50
+ state.registerRepo({
51
+ name: "pond",
52
+ description: null,
53
+ parent: null,
54
+ owner: null,
55
+ });
56
+ const lifetime = new AbortController();
57
+ const counted = countingGit(native.git, {
58
+ limit: 10_000,
59
+ signal: lifetime.signal,
60
+ });
61
+ const runtime = {
62
+ state,
63
+ store: gitStore(counted.git),
64
+ events: { emit: () => Promise.resolve() },
65
+ };
66
+ await getRepoDetail(runtime, "pond");
67
+ return {
68
+ database,
69
+ native,
70
+ runtime,
71
+ counted,
72
+ initialHead: outcome.commit,
73
+ reopen: () => ({
74
+ ...runtime,
75
+ state: new BonsaiState(owner.sql, owner.transactionSync),
76
+ }),
77
+ close: () => {
78
+ lifetime.abort();
79
+ database.close();
80
+ native.close();
81
+ },
82
+ };
83
+ } catch (error) {
84
+ database.close();
85
+ native.close();
86
+ throw error;
87
+ }
88
+ }
89
+
90
+ it("serves listing, directory and file views after facet reconstruction with zero Git calls", async () => {
91
+ const f = await fixture();
92
+ try {
93
+ const listing = await listFilesCopy(f.runtime, input);
94
+ expect(listing.paths).toEqual(["README.md", "src/", "src/fish.ts"]);
95
+ expect(listing.truncated).toBe(false);
96
+ const directory = await listTreeCopy(f.runtime, { ...input, path: "src" });
97
+ expect(directory.entries.map((entry) => entry.name)).toEqual(["fish.ts"]);
98
+ const content = await readFileCopy(f.runtime, {
99
+ ...input,
100
+ path: "src/fish.ts",
101
+ });
102
+ expect(content.blob).toMatch(/^[0-9a-f]{40}$/u);
103
+ expect(content).toEqual({
104
+ version: null,
105
+ commit: f.initialHead,
106
+ blob: content.blob,
107
+ ref: "main",
108
+ path: "src/fish.ts",
109
+ size: 23,
110
+ binary: false,
111
+ text: "export const fish = 1;\n",
112
+ });
113
+ const reconstructed = f.reopen();
114
+ const before = f.counted.total();
115
+ expect(await listFilesCopy(reconstructed, input)).toEqual(listing);
116
+ expect(
117
+ await listTreeCopy(reconstructed, { ...input, path: "src" }),
118
+ ).toEqual(directory);
119
+ expect(
120
+ await readFileCopy(reconstructed, { ...input, path: "src/fish.ts" }),
121
+ ).toEqual(content);
122
+ expect(f.counted.total() - before).toBe(0);
123
+ } finally {
124
+ f.close();
125
+ }
126
+ });
127
+
128
+ it("refuses ready and invalidated mutable Files views across reconstruction while repository refs are pending", async () => {
129
+ const f = await fixture();
130
+ try {
131
+ await listFilesCopy(f.runtime, input);
132
+ await listTreeCopy(f.runtime, { ...input, path: "src" });
133
+ await readFileCopy(f.runtime, { ...input, path: "README.md" });
134
+ const immutable = { repo: "pond", ref: f.initialHead };
135
+ const listing = await listFilesCopy(f.runtime, immutable);
136
+ const directory = await listTreeCopy(f.runtime, {
137
+ ...immutable,
138
+ path: "src",
139
+ });
140
+ const content = await readFileCopy(f.runtime, {
141
+ ...immutable,
142
+ path: "README.md",
143
+ });
144
+ const revision = f.runtime.state.invalidateRepositoryRefs("pond");
145
+ const reconstructed = f.reopen();
146
+ const before = f.counted.total();
147
+ for (const invalidate of [false, true]) {
148
+ if (invalidate) invalidateFilesCopies(reconstructed, "pond");
149
+ await expect(listFilesCopy(reconstructed, input)).rejects.toMatchObject({
150
+ code: "unavailable",
151
+ });
152
+ await expect(
153
+ listTreeCopy(reconstructed, { ...input, path: "src" }),
154
+ ).rejects.toMatchObject({ code: "unavailable" });
155
+ await expect(
156
+ readFileCopy(reconstructed, { ...input, path: "README.md" }),
157
+ ).rejects.toMatchObject({ code: "unavailable" });
158
+ expect(await listFilesCopy(reconstructed, immutable)).toEqual(listing);
159
+ expect(
160
+ await listTreeCopy(reconstructed, { ...immutable, path: "src" }),
161
+ ).toEqual(directory);
162
+ expect(
163
+ await readFileCopy(reconstructed, { ...immutable, path: "README.md" }),
164
+ ).toEqual(content);
165
+ expect(reconstructed.state.repositoryRefStatus("pond")).toEqual({
166
+ revision,
167
+ pending: true,
168
+ });
169
+ }
170
+ expect(f.counted.total() - before).toBe(0);
171
+ } finally {
172
+ f.close();
173
+ }
174
+ });
175
+
176
+ it("does not publish an in-flight view after synchronous push invalidation", async () => {
177
+ const f = await fixture();
178
+ try {
179
+ await listFilesCopy(f.runtime, input);
180
+ const changed = await commitFiles(f.native.git, {
181
+ repository: "pond",
182
+ branch: "main",
183
+ expectedHead: f.initialHead,
184
+ author,
185
+ message: "Change the pond",
186
+ changes: [{ path: "README.md", content: text("# New pond\n") }],
187
+ });
188
+ if (changed.kind !== "committed")
189
+ throw new Error("Fixture push did not commit");
190
+ f.runtime.state.repositoryCopies.stale("pond");
191
+ await getRepoDetail(f.runtime, "pond");
192
+ const concurrentStore = {
193
+ ...f.runtime.store,
194
+ commit: async (repo: string, hash: string) => {
195
+ const value = await f.runtime.store.commit(repo, hash);
196
+ invalidateFilesCopies(f.runtime, "pond");
197
+ return value;
198
+ },
199
+ };
200
+ await expect(
201
+ refreshFilesCopy(
202
+ { ...f.runtime, store: concurrentStore },
203
+ { kind: "list", ...input },
204
+ ),
205
+ ).rejects.toMatchObject({ code: "unavailable" });
206
+ } finally {
207
+ f.close();
208
+ }
209
+ });
210
+
211
+ it("retains unfinished Files keys across exhausted shared frames and rebuilds one key per foreground page", async () => {
212
+ const f = await fixture();
213
+ try {
214
+ await listFilesCopy(f.runtime, input);
215
+ await listTreeCopy(f.runtime, { ...input, path: "src" });
216
+ await readFileCopy(f.runtime, { ...input, path: "README.md" });
217
+ const changed = await commitFiles(f.native.git, {
218
+ repository: "pond",
219
+ branch: "main",
220
+ expectedHead: f.initialHead,
221
+ author,
222
+ message: "Grow sixty directories",
223
+ changes: [
224
+ { path: "README.md", content: text("# Updated pond\n") },
225
+ ...Array.from({ length: 60 }, (_, n) => ({
226
+ path: `plants/${String(n).padStart(2, "0")}/plant.ts`,
227
+ content: text(`export const plant = ${String(n)};\n`),
228
+ })),
229
+ ],
230
+ });
231
+ if (changed.kind !== "committed")
232
+ throw new Error("Fixture push did not commit");
233
+ invalidateFilesCopies(f.runtime, "pond");
234
+ // This module law starts after metadata publication. The composer owns
235
+ // the actual push transaction and pre-metadata pending-gate regression.
236
+ f.runtime.state.repositoryCopies.stale("pond");
237
+ await getRepoDetail(f.runtime, "pond");
238
+ const before = f.counted.total();
239
+ await expect(
240
+ readCopyFrame(f.runtime.store, async (store) => {
241
+ for (let n = 0; n < 40; n++) await store.info("pond");
242
+ await refreshFilesCopy(
243
+ { ...f.runtime, store },
244
+ { kind: "list", ...input },
245
+ );
246
+ }),
247
+ ).rejects.toBeInstanceOf(ReadCopyBudgetExceeded);
248
+ expect(f.counted.total() - before).toBe(40);
249
+ const reconstructed = f.reopen();
250
+ const requests = filesCopyRequests(reconstructed, "pond");
251
+ expect(requests).toEqual([
252
+ { kind: "list", ...input },
253
+ { kind: "tree", ...input, path: "src" },
254
+ { kind: "file", ...input, path: "README.md" },
255
+ ]);
256
+ let pages = 0;
257
+ for (const request of requests) {
258
+ let ready = false;
259
+ for (let attempt = 0; attempt < 4 && !ready; attempt++) {
260
+ const started = f.counted.total();
261
+ try {
262
+ await readCopyFrame(reconstructed.store, (store) =>
263
+ refreshFilesCopy({ ...reconstructed, store }, request),
264
+ );
265
+ ready = true;
266
+ } catch (error) {
267
+ if (!(error instanceof ReadCopyBudgetExceeded)) throw error;
268
+ }
269
+ expect(f.counted.total() - started).toBeLessThanOrEqual(40);
270
+ pages++;
271
+ }
272
+ expect(ready).toBe(true);
273
+ }
274
+ expect(pages).toBeGreaterThan(requests.length);
275
+ const started = f.counted.total();
276
+ expect((await listFilesCopy(reconstructed, input)).paths).toContain(
277
+ "plants/59/plant.ts",
278
+ );
279
+ expect(
280
+ (await listTreeCopy(reconstructed, { ...input, path: "src" })).entries[0]
281
+ ?.name,
282
+ ).toBe("fish.ts");
283
+ expect(
284
+ (await readFileCopy(reconstructed, { ...input, path: "README.md" })).text,
285
+ ).toBe("# Updated pond\n");
286
+ expect(f.counted.total() - started).toBe(0);
287
+ } finally {
288
+ f.close();
289
+ }
290
+ });
291
+
292
+ it("retains binary, invalid UTF-8, oversized and long-path viewer behavior without warm provider reads", async () => {
293
+ const longPath = `${Array.from({ length: 6 }, (_, n) => `${String(n)}${"p".repeat(199)}`).join("/")}/leaf.txt`;
294
+ const f = await fixture([
295
+ { path: "binary.dat", content: new Uint8Array([0, 1, 2]) },
296
+ { path: "invalid.txt", content: new Uint8Array([0xff]) },
297
+ { path: "large.txt", content: text("x".repeat(512_001)) },
298
+ { path: longPath, content: text("long path\n") },
299
+ ]);
300
+ try {
301
+ for (const [path, expected] of [
302
+ ["binary.dat", { size: 3, binary: true, text: null }],
303
+ ["invalid.txt", { size: 1, binary: true, text: null }],
304
+ ["large.txt", { size: 512_001, binary: false, text: null }],
305
+ [longPath, { size: 10, binary: false, text: "long path\n" }],
306
+ ] as const) {
307
+ expect(await readFileCopy(f.runtime, { ...input, path })).toMatchObject(
308
+ expected,
309
+ );
310
+ const before = f.counted.total();
311
+ expect(await readFileCopy(f.reopen(), { ...input, path })).toMatchObject(
312
+ expected,
313
+ );
314
+ expect(f.counted.total() - before).toBe(0);
315
+ }
316
+ const immutable = { repo: "pond", ref: f.initialHead, path: "large.txt" };
317
+ await readFileCopy(f.runtime, immutable);
318
+ invalidateFilesCopies(f.runtime, "pond");
319
+ f.runtime.state.repositoryCopies.stale("pond");
320
+ const beforeImmutable = f.counted.total();
321
+ expect(await readFileCopy(f.reopen(), immutable)).toMatchObject({
322
+ size: 512_001,
323
+ text: null,
324
+ });
325
+ expect(f.counted.total() - beforeImmutable).toBe(0);
326
+ // A proven missing leaf is a ready committed absence, not a guessed blob.
327
+ await getRepoDetail(f.runtime, "pond");
328
+ expect(
329
+ await readFileCopy(f.runtime, { ...input, path: "missing.txt" }),
330
+ ).toEqual({
331
+ commit: f.initialHead,
332
+ blob: null,
333
+ text: null,
334
+ version: null,
335
+ ref: "main",
336
+ path: "missing.txt",
337
+ size: 0,
338
+ binary: false,
339
+ });
340
+ const absentReads = f.counted.total();
341
+ expect(
342
+ await readFileCopy(f.reopen(), { ...input, path: "missing.txt" }),
343
+ ).toMatchObject({ blob: null, text: null });
344
+ expect(f.counted.total() - absentReads).toBe(0);
345
+ expect(
346
+ filesCopyRequests(f.runtime, "pond").some(
347
+ (request) => request.kind === "file" && request.path === "missing.txt",
348
+ ),
349
+ ).toBe(true);
350
+ } finally {
351
+ f.close();
352
+ }
353
+ });
354
+
355
+ for (const failure of ["tree", "blob", "directory", "ref"] as const) {
356
+ it(`does not invent a missing file for ${failure} failure`, async () => {
357
+ const f = await fixture();
358
+ try {
359
+ const binding = { ...f.native.git };
360
+ if (failure === "tree") binding.readTree = () => Promise.resolve(null);
361
+ if (failure === "blob") binding.readBlob = () => Promise.resolve(null);
362
+ const runtime = { ...f.runtime, store: gitStore(binding) };
363
+ await expect(
364
+ readFileCopy(runtime, {
365
+ repo: "pond",
366
+ ref: failure === "ref" ? "gone" : "main",
367
+ path: failure === "directory" ? "src" : "README.md",
368
+ }),
369
+ ).rejects.toMatchObject({ code: "not_found" });
370
+ } finally {
371
+ f.close();
372
+ }
373
+ });
374
+ }
@@ -0,0 +1,111 @@
1
+ import { DatabaseSync } from "node:sqlite";
2
+ import { fromNodeSqlite } from "@fungi.computer/watchdog/node-sqlite";
3
+ import type { GitBinding } from "@fungi.computer/agar/git";
4
+ import type { AppMethodCallContext } from "@fungi.computer/puffball/app-method-runtime";
5
+ import { expect, it, vi } from "vitest";
6
+ import { appBackend } from "../backend/methods.js";
7
+ import { gitStore } from "../backend/git.js";
8
+ import { BonsaiState, ensureBonsaiTables } from "../backend/state.js";
9
+
10
+ const caller: AppMethodCallContext = {
11
+ computerId: null,
12
+ actor: { kind: "member", memberId: "Levi" },
13
+ role: "owner",
14
+ };
15
+
16
+ /** A host whose fork answers the destination an earlier attempt left. */
17
+ function fixture(fork: GitBinding["fork"]) {
18
+ const database = new DatabaseSync(":memory:");
19
+ const sql = fromNodeSqlite(database);
20
+ ensureBonsaiTables(sql.sql);
21
+ const state = new BonsaiState(sql.sql, sql.transactionSync);
22
+ state.registerRepo({
23
+ name: "pond",
24
+ description: null,
25
+ parent: null,
26
+ owner: null,
27
+ });
28
+ const unavailable = () =>
29
+ Promise.reject(new Error("Unexpected provider operation"));
30
+ const forks = vi.fn(fork);
31
+ const binding: GitBinding = {
32
+ create: unavailable,
33
+ fork: forks,
34
+ cloneUrl: (name) => Promise.resolve(`https://git.test/${name}.git`),
35
+ info: unavailable,
36
+ delete: unavailable,
37
+ log: unavailable,
38
+ readCommit: unavailable,
39
+ readTree: unavailable,
40
+ readBlob: unavailable,
41
+ refs: () =>
42
+ Promise.resolve([{ name: "refs/heads/main", hash: "a".repeat(40) }]),
43
+ exchange: unavailable,
44
+ };
45
+ const bonsai = {
46
+ state,
47
+ store: gitStore(binding),
48
+ events: { emit: () => Promise.resolve() },
49
+ };
50
+ const call = () =>
51
+ appBackend.call("repos.fork", { repo: "pond" }, { bonsai }, caller);
52
+ return { database, state, forks, call };
53
+ }
54
+
55
+ const repository = (name: string, source: string | null) => ({
56
+ name,
57
+ defaultBranch: "main",
58
+ lastPushAt: null,
59
+ source,
60
+ });
61
+
62
+ it("adopts an existing provider fork of the same source and registers it", async () => {
63
+ const f = fixture((source, name) =>
64
+ Promise.resolve(repository(name, source)),
65
+ );
66
+ try {
67
+ await expect(f.call()).resolves.toMatchObject({
68
+ name: "pond--Levi",
69
+ parent: "pond",
70
+ owner: "Levi",
71
+ });
72
+ expect(f.state.repo("pond")?.forks).toEqual(["pond--Levi"]);
73
+ } finally {
74
+ f.database.close();
75
+ }
76
+ });
77
+
78
+ it("refuses an existing provider repository with another source as a conflict", async () => {
79
+ const f = fixture((_source, name) =>
80
+ Promise.resolve(repository(name, "elsewhere")),
81
+ );
82
+ try {
83
+ await expect(f.call()).rejects.toMatchObject({ code: "conflict" });
84
+ expect(f.state.repo("pond--Levi")).toBeNull();
85
+ expect(f.state.repo("pond")?.forks).toEqual([]);
86
+ } finally {
87
+ f.database.close();
88
+ }
89
+ });
90
+
91
+ it("a fork retry after a partial failure registers the fork the first attempt left", async () => {
92
+ let attempts = 0;
93
+ const f = fixture((source, name) => {
94
+ attempts += 1;
95
+ return attempts === 1
96
+ ? Promise.reject(new Error("Artifacts activation failed"))
97
+ : Promise.resolve(repository(name, source));
98
+ });
99
+ try {
100
+ await expect(f.call()).rejects.toThrow("Artifacts activation failed");
101
+ expect(f.state.repo("pond--Levi")).toBeNull();
102
+ await expect(f.call()).resolves.toMatchObject({
103
+ name: "pond--Levi",
104
+ parent: "pond",
105
+ });
106
+ await expect(f.call()).resolves.toMatchObject({ name: "pond--Levi" });
107
+ expect(f.forks).toHaveBeenCalledTimes(2);
108
+ } finally {
109
+ f.database.close();
110
+ }
111
+ });
@@ -0,0 +1,125 @@
1
+ import { afterEach, expect, test, vi } from "vitest";
2
+ import { openChangesGill, recentCommitsGill } from "../backend/gills.js";
3
+
4
+ const context = { params: { repo: "pond" } };
5
+ afterEach(() => {
6
+ vi.restoreAllMocks();
7
+ });
8
+ test("Recent Commits forwards the source page and opens the placement repository", () => {
9
+ vi.spyOn(Date, "now").mockReturnValue(30 * 60_000);
10
+ expect(
11
+ recentCommitsGill.input({
12
+ params: context.params,
13
+ limit: 20,
14
+ after: "opaque-source-cursor",
15
+ }),
16
+ ).toEqual({ repo: "pond", limit: 20, after: "opaque-source-cursor" });
17
+ const record = {
18
+ hash: "a".repeat(40),
19
+ treeHash: "b".repeat(40),
20
+ message: "Keep every returned commit",
21
+ author: { name: "Levi", email: "levi@test.invalid" },
22
+ committer: { name: "Levi", email: "levi@test.invalid" },
23
+ parents: [],
24
+ authoredAt: 4 * 60,
25
+ committedAt: 22 * 60,
26
+ };
27
+ const output = {
28
+ ref: "main",
29
+ commits: [{ commit: record, agent: null }],
30
+ next: "source-next",
31
+ };
32
+ const view = recentCommitsGill.rows(output, context);
33
+ expect(view.state).toBe("full");
34
+ if (view.state !== "full") throw new Error("Expected a commit page");
35
+ expect(view.rows).toHaveLength(1);
36
+ expect(view.next).toBe("source-next");
37
+ expect(view.rows[0]?.detail).toContain("8m ago");
38
+ expect(view.rows[0]?.detail).not.toContain("UTC");
39
+ expect(view.rows[0]?.open?.params).toEqual([
40
+ { key: "page", value: "commit" },
41
+ { key: "repo", value: "pond" },
42
+ { key: "hash", value: record.hash },
43
+ ]);
44
+ const other = recentCommitsGill.rows(output, { params: { repo: "garden" } });
45
+ expect(other.rows[0]?.open?.params).toContainEqual({
46
+ key: "repo",
47
+ value: "garden",
48
+ });
49
+ });
50
+
51
+ test("Open Changes reads only open proposals and distinguishes rebuilding from empty", () => {
52
+ expect(
53
+ openChangesGill.input({
54
+ params: context.params,
55
+ limit: 20,
56
+ after: "unchanged",
57
+ }),
58
+ ).toEqual({ repo: "pond", state: "open", limit: 20, after: "unchanged" });
59
+ expect(openChangesGill.rows({ kind: "rebuilding" }, context).state).toBe(
60
+ "error",
61
+ );
62
+ expect(openChangesGill.rows({ changes: [] }, context).state).toBe("empty");
63
+ });
64
+
65
+ test("Open Changes preserves every source row, cursor and App-owned detail route", () => {
66
+ const records = Array.from({ length: 21 }, (_, index) => ({
67
+ repo: "pond",
68
+ number: index + 1,
69
+ sourceRepo: "pond",
70
+ sourceRef: "feature",
71
+ targetRef: "main",
72
+ title: `Change ${String(index + 1)}`,
73
+ body: "",
74
+ openedBy: "member-a",
75
+ openedKind: "human",
76
+ createdAt: 1,
77
+ updatedAt: 2,
78
+ state: "open",
79
+ head: "a".repeat(40),
80
+ target: "b".repeat(40),
81
+ revision: 1,
82
+ conflict: null,
83
+ lease: null,
84
+ mergedCommit: null,
85
+ mergedBy: null,
86
+ closedBy: null,
87
+ status: "needs-approval",
88
+ mergeable: { allowed: false, reason: "needs-approval" },
89
+ base: "b".repeat(40),
90
+ ahead: 1,
91
+ behind: 0,
92
+ }));
93
+ const result = openChangesGill.rows(
94
+ { changes: records, next: "untouched-next" },
95
+ context,
96
+ );
97
+ if (result.state !== "full")
98
+ throw new Error("Expected the returned source rows");
99
+ expect(result.rows).toHaveLength(21);
100
+ expect(result.next).toBe("untouched-next");
101
+ expect(result.rows[0]?.status).toEqual({
102
+ label: "Needs approval",
103
+ tone: "warning",
104
+ });
105
+ expect(result.rows[0]?.detail).not.toContain("needs-approval");
106
+ expect(result.rows[20]?.open?.params).toEqual([
107
+ { key: "page", value: "change" },
108
+ { key: "repo", value: "pond" },
109
+ { key: "number", value: "21" },
110
+ ]);
111
+ });
112
+
113
+ test("Bonsai widgets declare saved-copy picker methods and recover rebuilding with the host App launch", () => {
114
+ expect(openChangesGill.declaration.params?.[0]?.options).toBe(
115
+ "repos.options",
116
+ );
117
+ expect(
118
+ recentCommitsGill.declaration.params?.map((param) => param.options),
119
+ ).toEqual(["repos.options", "branches.options"]);
120
+ for (const gill of [openChangesGill, recentCommitsGill])
121
+ expect(gill.rows({ kind: "rebuilding" }, context)).toMatchObject({
122
+ state: "error",
123
+ recovery: { kind: "open" },
124
+ });
125
+ });
@@ -0,0 +1,30 @@
1
+ import type { GitStore, GitBinding } from "../backend/git.js";
2
+
3
+ /** Existing store fixtures expose the same maintained binding used in a facet. */
4
+ export function bindingOf(store: Omit<GitStore, "binding">): GitBinding {
5
+ return {
6
+ cloneUrl: (name) => store.cloneUrl(name),
7
+ create: (name, options) => store.create(name, options?.description ?? ""),
8
+ fork: (source, name, options) =>
9
+ store.fork(source, name, options?.description ?? ""),
10
+ info: (name) => store.info(name),
11
+ log: async (name, options) => [
12
+ ...(await store.log(
13
+ name,
14
+ options.ref,
15
+ options.limit ?? 30,
16
+ options.offset,
17
+ )),
18
+ ],
19
+ readCommit: (name, hash) => store.commit(name, hash),
20
+ readTree: async (name, hash) => {
21
+ const entries = await store.tree(name, hash);
22
+ return entries === null ? null : [...entries];
23
+ },
24
+ readBlob: (name, hash) => store.blob(name, hash),
25
+ refs: async (name) => [...(await store.refs(name))],
26
+ exchange: (name, request) => store.exchange(name, request),
27
+ delete: () =>
28
+ Promise.reject(new Error("Fixture does not delete repositories")),
29
+ };
30
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "name": "bonsai-push-proof",
3
+ "compatibility_date": "2026-09-04",
4
+ "compatibility_flags": ["nodejs_compat"],
5
+ "durable_objects": {
6
+ "bindings": [{ "name": "PROBE", "class_name": "BonsaiPushProbe" }],
7
+ },
8
+ "migrations": [{ "tag": "v1", "new_sqlite_classes": ["BonsaiPushProbe"] }],
9
+ }