@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.
- package/CHANGELOG.md +43 -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 +848 -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/present.test.ts +27 -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 +47 -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 +719 -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,561 @@
|
|
|
1
|
+
import * as z from "zod";
|
|
2
|
+
import { DatabaseSync } from "node:sqlite";
|
|
3
|
+
import { expect, it } from "vitest";
|
|
4
|
+
import { commitFiles } from "@fungi.computer/agar/git-commit";
|
|
5
|
+
import { countingGit } from "@fungi.computer/agar/read-copy";
|
|
6
|
+
import {
|
|
7
|
+
backgroundContext,
|
|
8
|
+
backgroundOriginKey,
|
|
9
|
+
sealBackgroundOrigin,
|
|
10
|
+
admitClaimedOrigin,
|
|
11
|
+
type BackgroundBinding,
|
|
12
|
+
} from "@fungi.computer/puffball/background-replay";
|
|
13
|
+
import { fromNodeSqlite } from "@fungi.computer/watchdog/node-sqlite";
|
|
14
|
+
import { realGit } from "@fungi.computer/agar/test-kit/git";
|
|
15
|
+
import { appBackend } from "../backend/methods.js";
|
|
16
|
+
import { settleMerge, recoverExpiredLease } from "../backend/merge.js";
|
|
17
|
+
import { gitStore } from "../backend/git.js";
|
|
18
|
+
import { getRepoDetail } from "../backend/repository-copy.js";
|
|
19
|
+
import { ensureBonsaiTables, BonsaiState } from "../backend/state.js";
|
|
20
|
+
import { changeRecord, changeDetail } from "../shared/contract.js";
|
|
21
|
+
|
|
22
|
+
const testKey = backgroundOriginKey("bonsai-background-merge-test-key");
|
|
23
|
+
const instanceId = "bonsai-test-instance";
|
|
24
|
+
async function enqueueCaller(
|
|
25
|
+
context: ReturnType<typeof caller> & { idempotencyKey: string },
|
|
26
|
+
method = "changes.merge",
|
|
27
|
+
) {
|
|
28
|
+
const origin = await sealBackgroundOrigin(await testKey, {
|
|
29
|
+
instanceId,
|
|
30
|
+
lane: `member:${context.actor.memberId}`,
|
|
31
|
+
method,
|
|
32
|
+
});
|
|
33
|
+
return backgroundContext(
|
|
34
|
+
context,
|
|
35
|
+
[],
|
|
36
|
+
{ name: method, effect: "write" },
|
|
37
|
+
origin,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
function replayBinding(
|
|
41
|
+
admitted: readonly string[] = ["changes.merge"],
|
|
42
|
+
): BackgroundBinding {
|
|
43
|
+
return {
|
|
44
|
+
admit: async (origin) =>
|
|
45
|
+
admitClaimedOrigin(
|
|
46
|
+
await testKey,
|
|
47
|
+
{
|
|
48
|
+
instanceId,
|
|
49
|
+
lane: "member:Levi",
|
|
50
|
+
admitted,
|
|
51
|
+
},
|
|
52
|
+
origin,
|
|
53
|
+
),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const author = { name: "Morel", email: "morel@test.invalid" };
|
|
58
|
+
const caller = (name: string) => ({
|
|
59
|
+
actor: { kind: "member" as const, memberId: name },
|
|
60
|
+
computerId: null,
|
|
61
|
+
role: "owner" as const,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
async function fixture() {
|
|
65
|
+
const provider = realGit();
|
|
66
|
+
const database = new DatabaseSync(":memory:");
|
|
67
|
+
const owner = fromNodeSqlite(database);
|
|
68
|
+
ensureBonsaiTables(owner.sql);
|
|
69
|
+
const lifetime = new AbortController();
|
|
70
|
+
const exchanges: { service: string; phase: string }[] = [];
|
|
71
|
+
const counted = countingGit(
|
|
72
|
+
{
|
|
73
|
+
...provider.git,
|
|
74
|
+
exchange: async (repo, request) => {
|
|
75
|
+
exchanges.push({ service: request.service, phase: request.phase });
|
|
76
|
+
return provider.git.exchange(repo, request);
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
limit: 10_000,
|
|
81
|
+
signal: lifetime.signal,
|
|
82
|
+
},
|
|
83
|
+
);
|
|
84
|
+
const bonsai = {
|
|
85
|
+
state: new BonsaiState(owner.sql, owner.transactionSync),
|
|
86
|
+
store: gitStore(counted.git),
|
|
87
|
+
events: { emit: () => Promise.resolve() },
|
|
88
|
+
};
|
|
89
|
+
await provider.git.create("pond");
|
|
90
|
+
bonsai.state.registerRepo({
|
|
91
|
+
name: "pond",
|
|
92
|
+
description: null,
|
|
93
|
+
parent: null,
|
|
94
|
+
owner: null,
|
|
95
|
+
});
|
|
96
|
+
const base = await commitFiles(provider.git, {
|
|
97
|
+
repository: "pond",
|
|
98
|
+
branch: "main",
|
|
99
|
+
expectedHead: null,
|
|
100
|
+
author,
|
|
101
|
+
message: "Start",
|
|
102
|
+
changes: [
|
|
103
|
+
{ path: "notes.txt", content: new TextEncoder().encode("Fish\n") },
|
|
104
|
+
],
|
|
105
|
+
});
|
|
106
|
+
if (base.kind !== "committed") throw new Error("Fixture base did not commit");
|
|
107
|
+
const source = await commitFiles(provider.git, {
|
|
108
|
+
repository: "pond",
|
|
109
|
+
branch: "feature",
|
|
110
|
+
expectedHead: null,
|
|
111
|
+
startFrom: "main",
|
|
112
|
+
author,
|
|
113
|
+
message: "Add waves",
|
|
114
|
+
changes: [
|
|
115
|
+
{ path: "waves.txt", content: new TextEncoder().encode("Waves\n") },
|
|
116
|
+
],
|
|
117
|
+
});
|
|
118
|
+
if (source.kind !== "committed")
|
|
119
|
+
throw new Error("Fixture source did not commit");
|
|
120
|
+
const source2 = await commitFiles(provider.git, {
|
|
121
|
+
repository: "pond",
|
|
122
|
+
branch: "feature2",
|
|
123
|
+
expectedHead: null,
|
|
124
|
+
startFrom: "main",
|
|
125
|
+
author,
|
|
126
|
+
message: "Add fins",
|
|
127
|
+
changes: [
|
|
128
|
+
{ path: "fins.txt", content: new TextEncoder().encode("Fins\n") },
|
|
129
|
+
],
|
|
130
|
+
});
|
|
131
|
+
if (source2.kind !== "committed") throw new Error("source2");
|
|
132
|
+
await getRepoDetail(bonsai, "pond");
|
|
133
|
+
const open = async () =>
|
|
134
|
+
changeRecord.parse(
|
|
135
|
+
await appBackend.call(
|
|
136
|
+
"changes.open",
|
|
137
|
+
{ repo: "pond", sourceRef: "feature" },
|
|
138
|
+
{ bonsai },
|
|
139
|
+
caller("Morel"),
|
|
140
|
+
),
|
|
141
|
+
);
|
|
142
|
+
const detail = async (number: number) =>
|
|
143
|
+
changeDetail.parse(
|
|
144
|
+
await appBackend.call(
|
|
145
|
+
"changes.get",
|
|
146
|
+
{ repo: "pond", number },
|
|
147
|
+
{ bonsai },
|
|
148
|
+
caller("Levi"),
|
|
149
|
+
),
|
|
150
|
+
);
|
|
151
|
+
return {
|
|
152
|
+
bonsai,
|
|
153
|
+
provider,
|
|
154
|
+
counted,
|
|
155
|
+
exchanges,
|
|
156
|
+
base: base.commit,
|
|
157
|
+
source: source.commit,
|
|
158
|
+
source2: source2.commit,
|
|
159
|
+
open,
|
|
160
|
+
detail,
|
|
161
|
+
reopen() {
|
|
162
|
+
return {
|
|
163
|
+
...bonsai,
|
|
164
|
+
state: new BonsaiState(owner.sql, owner.transactionSync),
|
|
165
|
+
};
|
|
166
|
+
},
|
|
167
|
+
close() {
|
|
168
|
+
lifetime.abort();
|
|
169
|
+
database.close();
|
|
170
|
+
provider.close();
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function drain(f: Awaited<ReturnType<typeof fixture>>, tag: string) {
|
|
176
|
+
let more = true;
|
|
177
|
+
let frames = 0;
|
|
178
|
+
while (more && frames < 24) {
|
|
179
|
+
const result = await appBackend.call(
|
|
180
|
+
"sync",
|
|
181
|
+
{},
|
|
182
|
+
{ bonsai: f.reopen(), env: { BACKGROUND: replayBinding() } },
|
|
183
|
+
{ ...caller("Levi"), idempotencyKey: `${tag}-${String(frames)}` },
|
|
184
|
+
);
|
|
185
|
+
more = z.strictObject({ more: z.boolean() }).parse(result).more;
|
|
186
|
+
frames++;
|
|
187
|
+
}
|
|
188
|
+
return { more, frames };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Drains a queued merge while a competing writer keeps moving the target
|
|
193
|
+
* branch out from under it, forcing every attempt's push to see a stale
|
|
194
|
+
* target CAS and exhaust the bounded retry.
|
|
195
|
+
*/
|
|
196
|
+
async function drainRacing(
|
|
197
|
+
f: Awaited<ReturnType<typeof fixture>>,
|
|
198
|
+
tag: string,
|
|
199
|
+
startHead: string,
|
|
200
|
+
) {
|
|
201
|
+
let more = true;
|
|
202
|
+
let frames = 0;
|
|
203
|
+
let mainHead = startHead;
|
|
204
|
+
while (more && frames < 40) {
|
|
205
|
+
const race = await commitFiles(f.provider.git, {
|
|
206
|
+
repository: "pond",
|
|
207
|
+
branch: "main",
|
|
208
|
+
expectedHead: mainHead,
|
|
209
|
+
author,
|
|
210
|
+
message: `Race ${String(frames)}`,
|
|
211
|
+
changes: [
|
|
212
|
+
{
|
|
213
|
+
path: "race.txt",
|
|
214
|
+
content: new TextEncoder().encode(`race ${String(frames)}\n`),
|
|
215
|
+
},
|
|
216
|
+
],
|
|
217
|
+
});
|
|
218
|
+
if (race.kind !== "committed") throw new Error("Race commit did not land");
|
|
219
|
+
mainHead = race.commit;
|
|
220
|
+
const result = await appBackend.call(
|
|
221
|
+
"sync",
|
|
222
|
+
{},
|
|
223
|
+
{ bonsai: f.reopen(), env: { BACKGROUND: replayBinding() } },
|
|
224
|
+
{ ...caller("Levi"), idempotencyKey: `${tag}-${String(frames)}` },
|
|
225
|
+
);
|
|
226
|
+
more = z.strictObject({ more: z.boolean() }).parse(result).more;
|
|
227
|
+
frames++;
|
|
228
|
+
}
|
|
229
|
+
return { more, frames };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function openSecond(f: Awaited<ReturnType<typeof fixture>>) {
|
|
233
|
+
const row = changeRecord.parse(
|
|
234
|
+
await appBackend.call(
|
|
235
|
+
"changes.open",
|
|
236
|
+
{ repo: "pond", sourceRef: "feature2" },
|
|
237
|
+
{ bonsai: f.bonsai },
|
|
238
|
+
caller("Morel"),
|
|
239
|
+
),
|
|
240
|
+
);
|
|
241
|
+
return { row, head: f.source2 };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const merge = async (
|
|
245
|
+
f: Awaited<ReturnType<typeof fixture>>,
|
|
246
|
+
number: number,
|
|
247
|
+
head: string,
|
|
248
|
+
key: string,
|
|
249
|
+
) =>
|
|
250
|
+
appBackend.call(
|
|
251
|
+
"changes.merge",
|
|
252
|
+
{ repo: "pond", number, head },
|
|
253
|
+
{ bonsai: f.bonsai },
|
|
254
|
+
await enqueueCaller({ ...caller("Levi"), idempotencyKey: key }),
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
it("settles a second same-member merge after the first one already settled", async () => {
|
|
258
|
+
const f = await fixture();
|
|
259
|
+
try {
|
|
260
|
+
const first = await f.open();
|
|
261
|
+
const second = await openSecond(f);
|
|
262
|
+
await f.detail(first.number);
|
|
263
|
+
await f.detail(second.row.number);
|
|
264
|
+
expect(await merge(f, first.number, f.source, "m1")).toMatchObject({
|
|
265
|
+
kind: "merging",
|
|
266
|
+
});
|
|
267
|
+
await drain(f, "a");
|
|
268
|
+
await f.detail(second.row.number);
|
|
269
|
+
await merge(f, second.row.number, second.head, "m2");
|
|
270
|
+
await drain(f, "b");
|
|
271
|
+
const after = f.bonsai.state.requireChange("pond", second.row.number);
|
|
272
|
+
expect(after.state).toBe("merged");
|
|
273
|
+
} finally {
|
|
274
|
+
f.close();
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("records mergeFailure sync_failed when a background merge exhausts its stale-CAS retries", async () => {
|
|
279
|
+
const f = await fixture();
|
|
280
|
+
try {
|
|
281
|
+
const first = await f.open();
|
|
282
|
+
await f.detail(first.number);
|
|
283
|
+
expect(await merge(f, first.number, f.source, "race1")).toMatchObject({
|
|
284
|
+
kind: "merging",
|
|
285
|
+
});
|
|
286
|
+
await drainRacing(f, "race", f.base);
|
|
287
|
+
const after = f.bonsai.state.requireChange("pond", first.number);
|
|
288
|
+
expect(after.state).toBe("open");
|
|
289
|
+
expect(after.lease).toBeNull();
|
|
290
|
+
expect(after.mergeFailure).toBe("sync_failed");
|
|
291
|
+
} finally {
|
|
292
|
+
f.close();
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* `settleMerge`'s own ownership/head/target races are only reachable in a
|
|
298
|
+
* real end-to-end run through a window far narrower than this single-
|
|
299
|
+
* threaded test runner can land a competing write into (the Watchdog's own
|
|
300
|
+
* per-frame policy gate re-checks the row's head on every continuation, so
|
|
301
|
+
* any black-box write lands either before it -- refusing the job outright,
|
|
302
|
+
* honestly, as "access_changed" -- or not at all). Driving `settleMerge`
|
|
303
|
+
* directly tests the exact race it resolves without fighting that gate.
|
|
304
|
+
*/
|
|
305
|
+
it("settleMerge names the commit that actually landed when the row's head moved before it settled", async () => {
|
|
306
|
+
const f = await fixture();
|
|
307
|
+
try {
|
|
308
|
+
const row = await f.open();
|
|
309
|
+
const leaseId = "lease-case-a";
|
|
310
|
+
const leased = f.bonsai.state.reviseChange(
|
|
311
|
+
{
|
|
312
|
+
...row,
|
|
313
|
+
state: "merging",
|
|
314
|
+
lease: { id: leaseId, by: "Levi", head: row.head, at: Date.now() },
|
|
315
|
+
},
|
|
316
|
+
row.revision,
|
|
317
|
+
);
|
|
318
|
+
// The author pushed a new commit to the source while this lease's
|
|
319
|
+
// merge was in flight: the stored row's head no longer matches what
|
|
320
|
+
// the lease (and the Git work it drove) was actually merging. The
|
|
321
|
+
// merge still landed, though, so this isn't a sync failure -- it's
|
|
322
|
+
// an honest "merged, but for a head this Change no longer shows".
|
|
323
|
+
f.bonsai.state.reviseChange(
|
|
324
|
+
{ ...leased, head: f.source2 },
|
|
325
|
+
leased.revision,
|
|
326
|
+
);
|
|
327
|
+
settleMerge(
|
|
328
|
+
f.bonsai,
|
|
329
|
+
{ name: "Levi", kind: "human" },
|
|
330
|
+
leased,
|
|
331
|
+
{ repo: "pond", number: row.number, head: row.head },
|
|
332
|
+
"unit-case-a",
|
|
333
|
+
JSON.stringify({ case: "a" }),
|
|
334
|
+
"https://source.invalid",
|
|
335
|
+
"https://target.invalid",
|
|
336
|
+
{ kind: "merged", before: f.base, after: f.source },
|
|
337
|
+
true,
|
|
338
|
+
);
|
|
339
|
+
const after = f.bonsai.state.requireChange("pond", row.number);
|
|
340
|
+
expect(after.state).toBe("open");
|
|
341
|
+
expect(after.mergeFailure).toBe("head_moved");
|
|
342
|
+
expect(after.mergedCommit).toBe(f.source);
|
|
343
|
+
expect(after.mergedBy).toBe("Levi");
|
|
344
|
+
} finally {
|
|
345
|
+
f.close();
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it("settleMerge records a real conflict even when the row's cached target has raced to a third value", async () => {
|
|
350
|
+
const f = await fixture();
|
|
351
|
+
try {
|
|
352
|
+
const row = await f.open();
|
|
353
|
+
const leaseId = "lease-case-b";
|
|
354
|
+
const leased = f.bonsai.state.reviseChange(
|
|
355
|
+
{
|
|
356
|
+
...row,
|
|
357
|
+
state: "merging",
|
|
358
|
+
lease: { id: leaseId, by: "Levi", head: row.head, at: Date.now() },
|
|
359
|
+
},
|
|
360
|
+
row.revision,
|
|
361
|
+
);
|
|
362
|
+
// Some other concurrent write raced the row's cached target to a
|
|
363
|
+
// third value, independent of Git itself, while this lease's merge
|
|
364
|
+
// ran -- exactly what previously made settleMerge drop the conflict
|
|
365
|
+
// it had just found, since neither side of its target guard matched.
|
|
366
|
+
f.bonsai.state.reviseChange(
|
|
367
|
+
{ ...leased, target: "0".repeat(40) },
|
|
368
|
+
leased.revision,
|
|
369
|
+
);
|
|
370
|
+
settleMerge(
|
|
371
|
+
f.bonsai,
|
|
372
|
+
{ name: "Levi", kind: "human" },
|
|
373
|
+
leased,
|
|
374
|
+
{ repo: "pond", number: row.number, head: row.head },
|
|
375
|
+
"unit-case-b",
|
|
376
|
+
JSON.stringify({ case: "b" }),
|
|
377
|
+
"https://source.invalid",
|
|
378
|
+
"https://target.invalid",
|
|
379
|
+
{ kind: "conflict", head: f.base, paths: ["notes.txt"] },
|
|
380
|
+
true,
|
|
381
|
+
);
|
|
382
|
+
const after = f.bonsai.state.requireChange("pond", row.number);
|
|
383
|
+
expect(after.state).toBe("open");
|
|
384
|
+
expect(after.conflict).toMatchObject({
|
|
385
|
+
head: row.head,
|
|
386
|
+
paths: ["notes.txt"],
|
|
387
|
+
});
|
|
388
|
+
// The raced bookkeeping field is left alone: it matched neither what
|
|
389
|
+
// this attempt proposed nor what it found, so overwriting it would
|
|
390
|
+
// have been a guess, not a fact.
|
|
391
|
+
expect(after.target).toBe("0".repeat(40));
|
|
392
|
+
} finally {
|
|
393
|
+
f.close();
|
|
394
|
+
}
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it("settleMerge records head_moved, not a conflict, when the conflicting head is already superseded", async () => {
|
|
398
|
+
const f = await fixture();
|
|
399
|
+
try {
|
|
400
|
+
const row = await f.open();
|
|
401
|
+
const leaseId = "lease-case-a-conflict";
|
|
402
|
+
const leased = f.bonsai.state.reviseChange(
|
|
403
|
+
{
|
|
404
|
+
...row,
|
|
405
|
+
state: "merging",
|
|
406
|
+
lease: { id: leaseId, by: "Levi", head: row.head, at: Date.now() },
|
|
407
|
+
},
|
|
408
|
+
row.revision,
|
|
409
|
+
);
|
|
410
|
+
// The author pushed a new commit to the source while this lease's
|
|
411
|
+
// conflicting merge attempt was in flight. The conflict it found was
|
|
412
|
+
// against a head this Change no longer shows, so recording it as
|
|
413
|
+
// this Change's current conflict would be stale and wrong.
|
|
414
|
+
f.bonsai.state.reviseChange(
|
|
415
|
+
{ ...leased, head: f.source2 },
|
|
416
|
+
leased.revision,
|
|
417
|
+
);
|
|
418
|
+
settleMerge(
|
|
419
|
+
f.bonsai,
|
|
420
|
+
{ name: "Levi", kind: "human" },
|
|
421
|
+
leased,
|
|
422
|
+
{ repo: "pond", number: row.number, head: row.head },
|
|
423
|
+
"unit-case-a-conflict",
|
|
424
|
+
JSON.stringify({ case: "a-conflict" }),
|
|
425
|
+
"https://source.invalid",
|
|
426
|
+
"https://target.invalid",
|
|
427
|
+
{ kind: "conflict", head: f.base, paths: ["notes.txt"] },
|
|
428
|
+
true,
|
|
429
|
+
);
|
|
430
|
+
const after = f.bonsai.state.requireChange("pond", row.number);
|
|
431
|
+
expect(after.state).toBe("open");
|
|
432
|
+
expect(after.conflict).toBeNull();
|
|
433
|
+
expect(after.mergeFailure).toBe("head_moved");
|
|
434
|
+
} finally {
|
|
435
|
+
f.close();
|
|
436
|
+
}
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
it("settleMerge records head_moved, never sync_failed, for a source Git itself found moved", async () => {
|
|
440
|
+
const f = await fixture();
|
|
441
|
+
try {
|
|
442
|
+
const row = await f.open();
|
|
443
|
+
const leaseId = "lease-source-moved";
|
|
444
|
+
const leased = f.bonsai.state.reviseChange(
|
|
445
|
+
{
|
|
446
|
+
...row,
|
|
447
|
+
state: "merging",
|
|
448
|
+
lease: { id: leaseId, by: "Levi", head: row.head, at: Date.now() },
|
|
449
|
+
},
|
|
450
|
+
row.revision,
|
|
451
|
+
);
|
|
452
|
+
// Git's own fetch found the source branch had moved; the row's own
|
|
453
|
+
// head field hasn't caught up yet, but this is still the author's
|
|
454
|
+
// work overtaking the attempt, not a sync problem with the target.
|
|
455
|
+
settleMerge(
|
|
456
|
+
f.bonsai,
|
|
457
|
+
{ name: "Levi", kind: "human" },
|
|
458
|
+
leased,
|
|
459
|
+
{ repo: "pond", number: row.number, head: row.head },
|
|
460
|
+
"unit-source-moved",
|
|
461
|
+
JSON.stringify({ case: "source-moved" }),
|
|
462
|
+
"https://source.invalid",
|
|
463
|
+
"https://target.invalid",
|
|
464
|
+
{ kind: "source-moved", head: f.source2 },
|
|
465
|
+
true,
|
|
466
|
+
);
|
|
467
|
+
const after = f.bonsai.state.requireChange("pond", row.number);
|
|
468
|
+
expect(after.state).toBe("open");
|
|
469
|
+
expect(after.mergeFailure).toBe("head_moved");
|
|
470
|
+
} finally {
|
|
471
|
+
f.close();
|
|
472
|
+
}
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
it("settles both merges when two same-member merges overlap on one lane", async () => {
|
|
476
|
+
const f = await fixture();
|
|
477
|
+
try {
|
|
478
|
+
const first = await f.open();
|
|
479
|
+
const second = await openSecond(f);
|
|
480
|
+
await f.detail(first.number);
|
|
481
|
+
await f.detail(second.row.number);
|
|
482
|
+
await merge(f, first.number, f.source, "o1");
|
|
483
|
+
await appBackend.call(
|
|
484
|
+
"sync",
|
|
485
|
+
{},
|
|
486
|
+
{ bonsai: f.reopen(), env: { BACKGROUND: replayBinding() } },
|
|
487
|
+
{ ...caller("Levi"), idempotencyKey: "o-pre" },
|
|
488
|
+
);
|
|
489
|
+
await merge(f, second.row.number, second.head, "o2");
|
|
490
|
+
await drain(f, "o");
|
|
491
|
+
const a = f.bonsai.state.requireChange("pond", first.number);
|
|
492
|
+
const b = f.bonsai.state.requireChange("pond", second.row.number);
|
|
493
|
+
expect(a.state).toBe("merged");
|
|
494
|
+
expect(b.state).toBe("merged");
|
|
495
|
+
} finally {
|
|
496
|
+
f.close();
|
|
497
|
+
}
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
it("recoverExpiredLease records a typed failure instead of reopening a simply-expired lease silently", async () => {
|
|
501
|
+
const f = await fixture();
|
|
502
|
+
try {
|
|
503
|
+
const row = await f.open();
|
|
504
|
+
const leased = f.bonsai.state.reviseChange(
|
|
505
|
+
{
|
|
506
|
+
...row,
|
|
507
|
+
state: "merging",
|
|
508
|
+
lease: {
|
|
509
|
+
id: "lease-expired",
|
|
510
|
+
by: "Levi",
|
|
511
|
+
head: row.head,
|
|
512
|
+
at: Date.now() - 121_000,
|
|
513
|
+
},
|
|
514
|
+
},
|
|
515
|
+
row.revision,
|
|
516
|
+
);
|
|
517
|
+
// Nothing ever landed for this lease (no merge ran), and the head
|
|
518
|
+
// it was merging is still current: this is an ordinary expiry, not
|
|
519
|
+
// a head move, so it settles as an honest sync failure.
|
|
520
|
+
const recovered = await recoverExpiredLease(f.bonsai, leased);
|
|
521
|
+
expect(recovered.state).toBe("open");
|
|
522
|
+
expect(recovered.lease).toBeNull();
|
|
523
|
+
expect(recovered.mergedCommit).toBeNull();
|
|
524
|
+
expect(recovered.mergeFailure).toBe("sync_failed");
|
|
525
|
+
} finally {
|
|
526
|
+
f.close();
|
|
527
|
+
}
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
it("recoverExpiredLease records head_moved instead of reopening silently when the source also moved before it expired", async () => {
|
|
531
|
+
const f = await fixture();
|
|
532
|
+
try {
|
|
533
|
+
const row = await f.open();
|
|
534
|
+
const leased = f.bonsai.state.reviseChange(
|
|
535
|
+
{
|
|
536
|
+
...row,
|
|
537
|
+
state: "merging",
|
|
538
|
+
lease: {
|
|
539
|
+
id: "lease-expired-moved",
|
|
540
|
+
by: "Levi",
|
|
541
|
+
head: row.head,
|
|
542
|
+
at: Date.now() - 121_000,
|
|
543
|
+
},
|
|
544
|
+
},
|
|
545
|
+
row.revision,
|
|
546
|
+
);
|
|
547
|
+
// The author pushed a new commit to the source while this lease sat
|
|
548
|
+
// expired and unresolved: nothing it was merging is current anymore.
|
|
549
|
+
const moved = f.bonsai.state.reviseChange(
|
|
550
|
+
{ ...leased, head: f.source2 },
|
|
551
|
+
leased.revision,
|
|
552
|
+
);
|
|
553
|
+
const recovered = await recoverExpiredLease(f.bonsai, moved);
|
|
554
|
+
expect(recovered.state).toBe("open");
|
|
555
|
+
expect(recovered.lease).toBeNull();
|
|
556
|
+
expect(recovered.mergedCommit).toBeNull();
|
|
557
|
+
expect(recovered.mergeFailure).toBe("head_moved");
|
|
558
|
+
} finally {
|
|
559
|
+
f.close();
|
|
560
|
+
}
|
|
561
|
+
});
|