@webappwiz/arbor 0.0.28 → 0.0.30
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/AGENTS.md +7 -0
- package/README.md +34 -12
- package/add.test.ts +125 -0
- package/add.ts +192 -0
- package/age.ts +11 -0
- package/arbor.test.ts +145 -0
- package/arbor.ts +429 -0
- package/assets.d.ts +9 -0
- package/attachments.ts +87 -0
- package/build.ts +98 -0
- package/claim.test.ts +89 -0
- package/claim.ts +73 -0
- package/components.json +21 -0
- package/config.ts +58 -0
- package/dev/api.ts +120 -0
- package/dev/app.test.tsx +645 -0
- package/dev/app.tsx +80 -0
- package/dev/assets.ts +16 -0
- package/dev/build/main.txt +68 -0
- package/dev/build/shell.txt +21 -0
- package/dev/build/styles.txt +2263 -0
- package/dev/components/ui/badge.tsx +51 -0
- package/dev/components/ui/button.tsx +57 -0
- package/dev/components/ui/dialog.tsx +154 -0
- package/dev/components/ui/empty.tsx +100 -0
- package/dev/components/ui/input-group.tsx +155 -0
- package/dev/components/ui/input.tsx +19 -0
- package/dev/components/ui/progress.tsx +80 -0
- package/dev/components/ui/textarea.tsx +17 -0
- package/dev/components/ui/toast.tsx +227 -0
- package/dev/feed.ts +69 -0
- package/dev/files.tsx +191 -0
- package/dev/index.html +21 -0
- package/dev/lib/utils.ts +7 -0
- package/dev/main.tsx +22 -0
- package/dev/markdown.test.tsx +133 -0
- package/dev/markdown.tsx +124 -0
- package/dev/mentions.tsx +319 -0
- package/dev/shadcn.css +649 -0
- package/dev/styles.css +107 -0
- package/dev/tags.tsx +44 -0
- package/dev/tasks.tsx +151 -0
- package/dev/todos.tsx +759 -0
- package/dev.test.ts +439 -0
- package/dev.ts +386 -0
- package/e2e.test.ts +256 -0
- package/escalate.test.ts +76 -0
- package/escalate.ts +112 -0
- package/exit.test.ts +71 -0
- package/exit.ts +74 -0
- package/git.ts +294 -0
- package/index.ts +9 -0
- package/journal.ts +89 -0
- package/list.test.ts +82 -0
- package/list.ts +137 -0
- package/load-config.test.ts +121 -0
- package/load-config.ts +62 -0
- package/log.test.ts +65 -0
- package/log.ts +40 -0
- package/merge.test.ts +332 -0
- package/merge.ts +283 -0
- package/package.json +32 -15
- package/path.test.ts +35 -0
- package/path.ts +38 -0
- package/plan.test.ts +318 -0
- package/plan.ts +302 -0
- package/progress.test.ts +54 -0
- package/progress.ts +31 -0
- package/remove.test.ts +68 -0
- package/remove.ts +88 -0
- package/repository.ts +83 -0
- package/retry.test.ts +43 -0
- package/retry.ts +48 -0
- package/shell.ts +44 -0
- package/show.test.ts +91 -0
- package/show.ts +151 -0
- package/snapshot.test.ts +33 -0
- package/snapshot.ts +84 -0
- package/table.ts +20 -0
- package/testing.ts +229 -0
- package/todo.test.ts +490 -0
- package/todo.ts +834 -0
- package/wait.test.ts +61 -0
- package/wait.ts +77 -0
- package/worktree-service.test.ts +158 -0
- package/worktree-service.ts +193 -0
- package/worktree.ts +239 -0
- package/add.d.ts +0 -23
- package/age.d.ts +0 -2
- package/arbor.d.ts +0 -11
- package/attachments.d.ts +0 -36
- package/claim.d.ts +0 -11
- package/config.d.ts +0 -55
- package/config.js +0 -7
- package/dev/assets.d.ts +0 -11
- package/dev.d.ts +0 -47
- package/escalate.d.ts +0 -28
- package/exit.d.ts +0 -42
- package/git.d.ts +0 -77
- package/index.d.ts +0 -2
- package/index.js +0 -4669
- package/journal.d.ts +0 -29
- package/list.d.ts +0 -18
- package/load-config.d.ts +0 -15
- package/log.d.ts +0 -14
- package/merge.d.ts +0 -24
- package/path.d.ts +0 -13
- package/plan.d.ts +0 -83
- package/remove.d.ts +0 -19
- package/repository.d.ts +0 -25
- package/retry.d.ts +0 -17
- package/shell.d.ts +0 -17
- package/show.d.ts +0 -53
- package/snapshot.d.ts +0 -31
- package/table.d.ts +0 -5
- package/todo.d.ts +0 -230
- package/wait.d.ts +0 -22
- package/worktree-service.d.ts +0 -51
- package/worktree.d.ts +0 -95
package/merge.test.ts
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import { describe, expect, it, setDefaultTimeout } from "bun:test";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { FakePs } from "webappwiz/system/testing";
|
|
4
|
+
import { add } from "./add";
|
|
5
|
+
import { merge } from "./merge";
|
|
6
|
+
import { PLAN_FILE } from "./plan";
|
|
7
|
+
import { Shell } from "./shell";
|
|
8
|
+
import { LIVE_PID, Testing } from "./testing";
|
|
9
|
+
|
|
10
|
+
// Every test here drives real git in a repo of its own, dozens of processes
|
|
11
|
+
// each: fast alone, but starved past bun's 5s default when the whole suite
|
|
12
|
+
// runs in parallel on a busy machine.
|
|
13
|
+
setDefaultTimeout(30_000);
|
|
14
|
+
|
|
15
|
+
describe.concurrent("merge", () => {
|
|
16
|
+
it("rebases, tests, then fast-forwards trunk", async () => {
|
|
17
|
+
await using deps = await Testing.open();
|
|
18
|
+
|
|
19
|
+
await add(deps, "alpha");
|
|
20
|
+
const worktree = (await deps.service.find("alpha")).path;
|
|
21
|
+
await deps.commit(worktree, "alpha.txt", "alpha\n", "add alpha");
|
|
22
|
+
// Trunk moved on underneath, so the rebase has real work to do.
|
|
23
|
+
await deps.commit(deps.root, "trunk.txt", "trunk\n", "add trunk");
|
|
24
|
+
|
|
25
|
+
await merge(deps, worktree);
|
|
26
|
+
|
|
27
|
+
expect(await deps.gitCli(deps.root, "log", "--oneline", "main")).toContain(
|
|
28
|
+
"add alpha",
|
|
29
|
+
);
|
|
30
|
+
expect(
|
|
31
|
+
await deps.gitCli(deps.root, "rev-list", "--count", "--merges", "main"),
|
|
32
|
+
).toBe("0");
|
|
33
|
+
expect(await deps.fs.exists(deps.lockPath)).toBe(false);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("lands on a clean rebase alone when no hook is configured", async () => {
|
|
37
|
+
await using deps = await Testing.open();
|
|
38
|
+
|
|
39
|
+
deps.config.postRewrite = null;
|
|
40
|
+
deps.config.preMerge = null;
|
|
41
|
+
await add(deps, "alpha");
|
|
42
|
+
const worktree = (await deps.service.find("alpha")).path;
|
|
43
|
+
await deps.commit(worktree, "alpha.txt", "alpha\n", "add alpha");
|
|
44
|
+
|
|
45
|
+
await merge(deps, worktree);
|
|
46
|
+
|
|
47
|
+
expect(await deps.gitCli(deps.root, "log", "--oneline", "main")).toContain(
|
|
48
|
+
"add alpha",
|
|
49
|
+
);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("lands a --base task on its base branch and leaves trunk alone", async () => {
|
|
53
|
+
await using deps = await Testing.open();
|
|
54
|
+
|
|
55
|
+
await deps.gitCli(deps.root, "branch", "feature", "main");
|
|
56
|
+
const trunkBefore = await deps.gitCli(deps.root, "rev-parse", "main");
|
|
57
|
+
await add(deps, "alpha", { base: "feature" });
|
|
58
|
+
const worktree = (await deps.service.find("alpha")).path;
|
|
59
|
+
await deps.commit(worktree, "alpha.txt", "alpha\n", "add alpha");
|
|
60
|
+
|
|
61
|
+
await merge(deps, worktree);
|
|
62
|
+
|
|
63
|
+
expect(
|
|
64
|
+
await deps.gitCli(deps.root, "log", "--oneline", "feature"),
|
|
65
|
+
).toContain("add alpha");
|
|
66
|
+
expect(await deps.gitCli(deps.root, "rev-parse", "main")).toBe(trunkBefore);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("lands into the worktree that holds the base, leaving trunk alone", async () => {
|
|
70
|
+
await using deps = await Testing.open();
|
|
71
|
+
|
|
72
|
+
await add(deps, "parent");
|
|
73
|
+
const parent = (await deps.service.find("parent")).path;
|
|
74
|
+
await deps.commit(parent, "parent.txt", "parent\n", "add parent");
|
|
75
|
+
const trunkBefore = await deps.gitCli(deps.root, "rev-parse", "main");
|
|
76
|
+
|
|
77
|
+
await add(deps, "child", { base: "task/parent" });
|
|
78
|
+
const child = (await deps.service.find("child")).path;
|
|
79
|
+
await deps.commit(child, "child.txt", "child\n", "add child");
|
|
80
|
+
|
|
81
|
+
await merge(deps, child);
|
|
82
|
+
|
|
83
|
+
// The parent's files move with its branch, not just the ref: a ref moved
|
|
84
|
+
// behind a worktree's back leaves it reading the landed files as deleted.
|
|
85
|
+
expect(await deps.fs.exists(join(parent, "child.txt"))).toBe(true);
|
|
86
|
+
expect(await deps.gitCli(parent, "log", "--oneline", "HEAD")).toContain(
|
|
87
|
+
"add child",
|
|
88
|
+
);
|
|
89
|
+
expect(await deps.gitCli(deps.root, "rev-parse", "main")).toBe(trunkBefore);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("refuses to land into a base worktree whose changes collide", async () => {
|
|
93
|
+
await using deps = await Testing.open();
|
|
94
|
+
|
|
95
|
+
await add(deps, "parent");
|
|
96
|
+
const parent = (await deps.service.find("parent")).path;
|
|
97
|
+
await add(deps, "child", { base: "task/parent" });
|
|
98
|
+
const child = (await deps.service.find("child")).path;
|
|
99
|
+
await deps.commit(child, "shared.txt", "child\n", "add shared");
|
|
100
|
+
// The parent is mid-edit on the very file the child is about to land.
|
|
101
|
+
await deps.fs.write(join(parent, "shared.txt"), "parent still editing\n");
|
|
102
|
+
|
|
103
|
+
await expect(merge(deps, child)).toBail("merge_failed", {
|
|
104
|
+
message: parent,
|
|
105
|
+
});
|
|
106
|
+
expect((await deps.service.find("child")).state?.mergeAttempts).toBe(1);
|
|
107
|
+
expect(await deps.fs.exists(deps.lockPath)).toBe(false);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("discards the landed task, so it drops out of the listing", async () => {
|
|
111
|
+
await using deps = await Testing.open();
|
|
112
|
+
|
|
113
|
+
await add(deps, "alpha");
|
|
114
|
+
const worktree = (await deps.service.find("alpha")).path;
|
|
115
|
+
await deps.commit(worktree, "alpha.txt", "alpha\n", "add alpha");
|
|
116
|
+
|
|
117
|
+
await merge(deps, worktree);
|
|
118
|
+
|
|
119
|
+
expect((await deps.service.find("alpha")).status).toBe("removed");
|
|
120
|
+
expect(await deps.fs.exists(worktree)).toBe(false);
|
|
121
|
+
expect(await deps.service.list()).toEqual([]);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("refuses to run with uncommitted changes, before taking the lock", async () => {
|
|
125
|
+
await using deps = await Testing.open();
|
|
126
|
+
|
|
127
|
+
await add(deps, "alpha");
|
|
128
|
+
const worktree = (await deps.service.find("alpha")).path;
|
|
129
|
+
await deps.fs.write(join(worktree, "alpha.txt"), "not committed\n");
|
|
130
|
+
|
|
131
|
+
await expect(merge(deps, worktree)).toBail("dirty");
|
|
132
|
+
expect(await deps.fs.exists(deps.lockPath)).toBe(false);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("leaves a conflicting rebase in progress for the agent to resolve", async () => {
|
|
136
|
+
await using deps = await Testing.open();
|
|
137
|
+
|
|
138
|
+
await add(deps, "alpha");
|
|
139
|
+
const worktree = (await deps.service.find("alpha")).path;
|
|
140
|
+
await deps.commit(worktree, "README.md", "task side\n", "task edit");
|
|
141
|
+
await deps.commit(deps.root, "README.md", "trunk side\n", "trunk edit");
|
|
142
|
+
|
|
143
|
+
await expect(merge(deps, worktree)).toBail("conflict", {
|
|
144
|
+
message: "README.md",
|
|
145
|
+
});
|
|
146
|
+
expect(await deps.gitCli(worktree, "status", "--porcelain")).toContain(
|
|
147
|
+
"UU README.md",
|
|
148
|
+
);
|
|
149
|
+
expect((await deps.service.find("alpha")).state?.mergeAttempts).toBe(1);
|
|
150
|
+
expect(await deps.fs.exists(deps.lockPath)).toBe(false);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("rolls the branch back and leaves trunk alone when the gate fails", async () => {
|
|
154
|
+
await using deps = await Testing.open();
|
|
155
|
+
|
|
156
|
+
deps.config.preMerge = "echo boom-from-tests; exit 1";
|
|
157
|
+
await add(deps, "alpha");
|
|
158
|
+
const worktree = (await deps.service.find("alpha")).path;
|
|
159
|
+
await deps.commit(worktree, "alpha.txt", "alpha\n", "add alpha");
|
|
160
|
+
await deps.commit(deps.root, "trunk.txt", "trunk\n", "add trunk");
|
|
161
|
+
const before = await deps.gitCli(worktree, "rev-parse", "HEAD");
|
|
162
|
+
const trunkBefore = await deps.gitCli(deps.root, "rev-parse", "main");
|
|
163
|
+
|
|
164
|
+
await expect(merge(deps, worktree)).toBail("tests_failed", {
|
|
165
|
+
message: "boom-from-tests",
|
|
166
|
+
});
|
|
167
|
+
expect(await deps.gitCli(worktree, "rev-parse", "HEAD")).toBe(before);
|
|
168
|
+
expect(await deps.gitCli(deps.root, "rev-parse", "main")).toBe(trunkBefore);
|
|
169
|
+
expect(await deps.fs.exists(deps.lockPath)).toBe(false);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("runs postRewrite before preMerge, and fails the gate when it does", async () => {
|
|
173
|
+
await using deps = await Testing.open();
|
|
174
|
+
|
|
175
|
+
// Only passes if the hook ran first, in the same tree, before preMerge.
|
|
176
|
+
deps.config.postRewrite = "echo hooked > hook.txt";
|
|
177
|
+
deps.config.preMerge = "grep -q hooked hook.txt";
|
|
178
|
+
await add(deps, "alpha");
|
|
179
|
+
const worktree = (await deps.service.find("alpha")).path;
|
|
180
|
+
await deps.commit(worktree, "alpha.txt", "alpha\n", "add alpha");
|
|
181
|
+
|
|
182
|
+
await merge(deps, worktree);
|
|
183
|
+
expect(await deps.gitCli(deps.root, "log", "-1", "--format=%s")).toBe(
|
|
184
|
+
"add alpha",
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
deps.config.postRewrite = "echo boom-from-hook; exit 1";
|
|
188
|
+
await add(deps, "beta");
|
|
189
|
+
const beta = (await deps.service.find("beta")).path;
|
|
190
|
+
await deps.commit(beta, "beta.txt", "beta\n", "add beta");
|
|
191
|
+
|
|
192
|
+
await expect(merge(deps, beta)).toBail("tests_failed", {
|
|
193
|
+
message: "boom-from-hook",
|
|
194
|
+
});
|
|
195
|
+
expect((await deps.service.find("beta")).state?.mergeAttempts).toBe(1);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("runs postMerge in the main tree after landing", async () => {
|
|
199
|
+
await using deps = await Testing.open();
|
|
200
|
+
|
|
201
|
+
deps.config.postMerge = 'echo "$ARBOR_TASK" > post-merge.txt';
|
|
202
|
+
await add(deps, "alpha");
|
|
203
|
+
const worktree = (await deps.service.find("alpha")).path;
|
|
204
|
+
await deps.commit(worktree, "alpha.txt", "alpha\n", "add alpha");
|
|
205
|
+
|
|
206
|
+
await merge(deps, worktree);
|
|
207
|
+
|
|
208
|
+
expect(await deps.fs.read(join(deps.root, "post-merge.txt"))).toBe(
|
|
209
|
+
"alpha\n",
|
|
210
|
+
);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("reports a failed postMerge without disturbing the landed branch", async () => {
|
|
214
|
+
await using deps = await Testing.open();
|
|
215
|
+
|
|
216
|
+
deps.config.postMerge = "echo boom-after-land; exit 1";
|
|
217
|
+
await add(deps, "alpha");
|
|
218
|
+
const worktree = (await deps.service.find("alpha")).path;
|
|
219
|
+
await deps.commit(worktree, "alpha.txt", "alpha\n", "add alpha");
|
|
220
|
+
|
|
221
|
+
await expect(merge(deps, worktree)).toBail("hook_failed", {
|
|
222
|
+
message: "boom-after-land",
|
|
223
|
+
});
|
|
224
|
+
expect(await deps.gitCli(deps.root, "log", "--oneline", "main")).toContain(
|
|
225
|
+
"add alpha",
|
|
226
|
+
);
|
|
227
|
+
expect(await deps.fs.exists(deps.lockPath)).toBe(false);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("refuses to land while a question is open or an answer unchecked", async () => {
|
|
231
|
+
await using deps = await Testing.open();
|
|
232
|
+
|
|
233
|
+
await add(deps, "alpha");
|
|
234
|
+
const found = await deps.service.find("alpha");
|
|
235
|
+
const worktree = found.path;
|
|
236
|
+
await deps.commit(worktree, "alpha.txt", "alpha\n", "add alpha");
|
|
237
|
+
const plan = `${worktree}/${PLAN_FILE}`;
|
|
238
|
+
await deps.fs.write(plan, "# alpha\n\n## Blocked\n\n- [ ] 1. Keep it?\n");
|
|
239
|
+
|
|
240
|
+
await expect(merge(deps, worktree)).toBail("blocked", {
|
|
241
|
+
message: "questions 1 unchecked",
|
|
242
|
+
data: { task: "alpha", blocked: ["1"] },
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
// Answered but not acted on: the agent checks it off once it has.
|
|
246
|
+
await deps.fs.write(
|
|
247
|
+
plan,
|
|
248
|
+
"# alpha\n\n## Blocked\n\n- [ ] 1. Keep it? → rename it first\n",
|
|
249
|
+
);
|
|
250
|
+
await expect(merge(deps, worktree)).toBail("blocked");
|
|
251
|
+
expect(
|
|
252
|
+
await deps.gitCli(deps.root, "log", "--oneline", "main"),
|
|
253
|
+
).not.toContain("add alpha");
|
|
254
|
+
|
|
255
|
+
const read = await deps.fs.read(plan);
|
|
256
|
+
await deps.fs.write(plan, read.replace("- [ ]", "- [x]"));
|
|
257
|
+
await merge(deps, worktree);
|
|
258
|
+
expect(await deps.gitCli(deps.root, "log", "--oneline", "main")).toContain(
|
|
259
|
+
"add alpha",
|
|
260
|
+
);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("stops once the retry budget is spent", async () => {
|
|
264
|
+
await using deps = await Testing.open();
|
|
265
|
+
|
|
266
|
+
deps.config.mergeRetryCount = 2;
|
|
267
|
+
await add(deps, "alpha");
|
|
268
|
+
const worktree = await deps.service.find("alpha");
|
|
269
|
+
await worktree.save({ mergeAttempts: 2 });
|
|
270
|
+
|
|
271
|
+
await expect(merge(deps, worktree.path)).toBail("budget_exhausted", {
|
|
272
|
+
message: "arbor escalate",
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("refuses to land when the lease changed hands during the test run", async () => {
|
|
277
|
+
await using deps = await Testing.open();
|
|
278
|
+
|
|
279
|
+
await add(deps, "alpha");
|
|
280
|
+
const worktree = await deps.service.find("alpha");
|
|
281
|
+
await deps.commit(worktree.path, "alpha.txt", "alpha\n", "add alpha");
|
|
282
|
+
const trunkBefore = await deps.gitCli(deps.root, "rev-parse", "main");
|
|
283
|
+
// Another agent takes the tree while the tests are running: the one
|
|
284
|
+
// window merge cannot hold the record across, and the reason it re-reads
|
|
285
|
+
// it before landing.
|
|
286
|
+
const ps = new FakePs();
|
|
287
|
+
ps.simulate(async () => {
|
|
288
|
+
await worktree.save({
|
|
289
|
+
lease: {
|
|
290
|
+
pid: LIVE_PID,
|
|
291
|
+
hostname: deps.ps.hostname,
|
|
292
|
+
heartbeatAt: new Date().toISOString(),
|
|
293
|
+
},
|
|
294
|
+
});
|
|
295
|
+
return 0;
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
await expect(
|
|
299
|
+
merge({ ...deps, shell: new Shell({ ps }) }, worktree.path),
|
|
300
|
+
).toBail("lease_lost");
|
|
301
|
+
expect(await deps.gitCli(deps.root, "rev-parse", "main")).toBe(trunkBefore);
|
|
302
|
+
expect(await deps.fs.exists(deps.lockPath)).toBe(false);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it("serializes: one merge runs its tests and lands before the next starts", async () => {
|
|
306
|
+
await using deps = await Testing.open();
|
|
307
|
+
|
|
308
|
+
const trace = join(deps.root, "trace.log");
|
|
309
|
+
deps.config.preMerge = `printf 'start-%s\\n' "$ARBOR_TASK" >> ${trace}; sleep 0.2; printf 'end-%s\\n' "$ARBOR_TASK" >> ${trace}`;
|
|
310
|
+
|
|
311
|
+
await add(deps, "alpha");
|
|
312
|
+
await add(deps, "beta");
|
|
313
|
+
const alpha = (await deps.service.find("alpha")).path;
|
|
314
|
+
const beta = (await deps.service.find("beta")).path;
|
|
315
|
+
await deps.commit(alpha, "alpha.txt", "alpha\n", "add alpha");
|
|
316
|
+
await deps.commit(beta, "beta.txt", "beta\n", "add beta");
|
|
317
|
+
|
|
318
|
+
await Promise.all([merge(deps, alpha), merge(deps, beta)]);
|
|
319
|
+
|
|
320
|
+
const lines = (await deps.fs.read(trace)).trim().split("\n");
|
|
321
|
+
expect(lines).toHaveLength(4);
|
|
322
|
+
// Merges hold the lock exclusively, so the second can't start until the first ends.
|
|
323
|
+
expect(lines[1]).toBe(`end-${lines[0]?.slice("start-".length)}`);
|
|
324
|
+
expect(lines[3]).toBe(`end-${lines[2]?.slice("start-".length)}`);
|
|
325
|
+
const log = await deps.gitCli(deps.root, "log", "--oneline", "main");
|
|
326
|
+
expect(log).toContain("add alpha");
|
|
327
|
+
expect(log).toContain("add beta");
|
|
328
|
+
expect(
|
|
329
|
+
await deps.gitCli(deps.root, "rev-list", "--count", "--merges", "main"),
|
|
330
|
+
).toBe("0");
|
|
331
|
+
}, 20_000);
|
|
332
|
+
});
|
package/merge.ts
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import { color, type Logger } from "webappwiz/log";
|
|
2
|
+
import type { Fs, Lock } from "webappwiz/system";
|
|
3
|
+
import type { Config } from "./config";
|
|
4
|
+
import { fail } from "./exit";
|
|
5
|
+
import type { Git } from "./git";
|
|
6
|
+
import { PLAN_FILE, questions } from "./plan";
|
|
7
|
+
import type { Shell } from "./shell";
|
|
8
|
+
import { recommend, recommendation, type Todos } from "./todo";
|
|
9
|
+
import type { Worktree } from "./worktree";
|
|
10
|
+
import type { WorktreeService } from "./worktree-service";
|
|
11
|
+
|
|
12
|
+
const TAIL_LINES = 40;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Lands the current worktree's branch on its base branch, trunk unless the
|
|
16
|
+
* task was created with `--base`. Never a merge commit: it rebases onto the
|
|
17
|
+
* base, runs the gate there, and fast-forwards the base with
|
|
18
|
+
* `git merge --ff-only`, in whichever worktree has the base checked out.
|
|
19
|
+
* History stays linear.
|
|
20
|
+
*/
|
|
21
|
+
export async function merge(
|
|
22
|
+
{
|
|
23
|
+
service,
|
|
24
|
+
git,
|
|
25
|
+
lock,
|
|
26
|
+
shell,
|
|
27
|
+
config,
|
|
28
|
+
log,
|
|
29
|
+
todos,
|
|
30
|
+
fs,
|
|
31
|
+
}: {
|
|
32
|
+
service: WorktreeService;
|
|
33
|
+
git: Git;
|
|
34
|
+
lock: Lock;
|
|
35
|
+
shell: Shell;
|
|
36
|
+
config: Config;
|
|
37
|
+
log: Logger;
|
|
38
|
+
todos: Todos;
|
|
39
|
+
fs: Fs;
|
|
40
|
+
},
|
|
41
|
+
cwd: string,
|
|
42
|
+
): Promise<void> {
|
|
43
|
+
const branch = await git.currentBranch(cwd).catch(() => "");
|
|
44
|
+
const task = service.taskFor(branch);
|
|
45
|
+
if (!task) {
|
|
46
|
+
fail(
|
|
47
|
+
"not_found",
|
|
48
|
+
`not in a task worktree (branch '${branch}'): run merge from a worktree made by \`arbor add\``,
|
|
49
|
+
{ branch },
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
let worktree = await service.find(task);
|
|
53
|
+
if (!worktree.state) {
|
|
54
|
+
fail(
|
|
55
|
+
"not_found",
|
|
56
|
+
`no state file for '${task}': run \`arbor claim ${task}\` first`,
|
|
57
|
+
{ task },
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const base = worktree.base;
|
|
62
|
+
const dirty = await worktree.uncommitted();
|
|
63
|
+
if (dirty.length > 0) {
|
|
64
|
+
fail(
|
|
65
|
+
"dirty",
|
|
66
|
+
`'${task}' has uncommitted changes: commit them before merging`,
|
|
67
|
+
{ task, paths: dirty },
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
if (worktree.mergeAttempts >= config.mergeRetryCount) {
|
|
71
|
+
fail(
|
|
72
|
+
"budget_exhausted",
|
|
73
|
+
`'${task}' has used its ${config.mergeRetryCount} merge attempts: run \`arbor escalate <reason>\`, and a human can grant another ${config.mergeRetryCount} with \`arbor retry ${task}\`; or \`arbor remove ${task}\` and start over against current ${base}`,
|
|
74
|
+
{ task, mergeAttempts: worktree.mergeAttempts },
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
if (worktree.leaseHeldByOther) {
|
|
78
|
+
fail(
|
|
79
|
+
"lease_held",
|
|
80
|
+
`'${task}' is held by pid ${worktree.lease?.pid} on ${worktree.lease?.hostname}: another agent is driving this tree`,
|
|
81
|
+
{ task, lease: worktree.lease },
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
// An unchecked question is one the user has yet to answer, or an answer
|
|
85
|
+
// the agent has yet to act on.
|
|
86
|
+
const plan = await fs.read(`${worktree.path}/${PLAN_FILE}`).catch(() => "");
|
|
87
|
+
const blocked = questions(plan)
|
|
88
|
+
.filter((asked) => !asked.done)
|
|
89
|
+
.map((asked) => asked.number);
|
|
90
|
+
if (blocked.length > 0) {
|
|
91
|
+
fail(
|
|
92
|
+
"blocked",
|
|
93
|
+
`'${task}' has questions ${blocked.join(", ")} unchecked under ## Blocked: act on each answer and check it off, or escalate and ask the user in chat, then merge again`,
|
|
94
|
+
{ task, blocked },
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
worktree = await worktree.take();
|
|
98
|
+
|
|
99
|
+
// Blocks until free: an agent told "busy, try later" goes and edits code in
|
|
100
|
+
// a branch that is supposed to be frozen.
|
|
101
|
+
await lock.acquire();
|
|
102
|
+
worktree = await worktree.take({ status: "merging" });
|
|
103
|
+
|
|
104
|
+
const before = await git.head(worktree.path);
|
|
105
|
+
const rebase = await git.rebase(worktree.path, base);
|
|
106
|
+
if (rebase.code !== 0) {
|
|
107
|
+
// Left in progress on purpose: the agent needs the conflict markers.
|
|
108
|
+
const paths = await git.conflictedPaths(worktree.path);
|
|
109
|
+
await lock.release();
|
|
110
|
+
await bump(worktree);
|
|
111
|
+
fail(
|
|
112
|
+
"conflict",
|
|
113
|
+
[
|
|
114
|
+
`rebase onto ${base} conflicted in ${paths.length || "?"} file(s):`,
|
|
115
|
+
...paths.map((file) => ` ${file}`),
|
|
116
|
+
"",
|
|
117
|
+
"The rebase is still in progress. Resolve the conflicts, `git add` them,",
|
|
118
|
+
"`git rebase --continue`, then run `arbor merge` again.",
|
|
119
|
+
"If both sides restructured the same logic, prefer `arbor escalate <reason>`",
|
|
120
|
+
`or \`arbor remove ${task}\` and redo the task against current ${base}.`,
|
|
121
|
+
].join("\n"),
|
|
122
|
+
{ task, paths },
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// After the rebase, never before: a branch that passed against an older
|
|
127
|
+
// trunk tells you nothing about the combination. This is the only thing
|
|
128
|
+
// standing between semantic conflicts and a broken trunk, and a repo that
|
|
129
|
+
// configures neither hook has chosen to go without it.
|
|
130
|
+
//
|
|
131
|
+
// The two hooks share one shell rather than getting a run each: the recovery
|
|
132
|
+
// is the same either way, and one gate reports one failure however far down
|
|
133
|
+
// it got.
|
|
134
|
+
const gate = [config.postRewrite, config.preMerge]
|
|
135
|
+
.filter(Boolean)
|
|
136
|
+
.join(" && ");
|
|
137
|
+
const gated = gate
|
|
138
|
+
? await shell.run(gate, worktree.path, {
|
|
139
|
+
env: {
|
|
140
|
+
ARBOR_TASK: task,
|
|
141
|
+
ARBOR_WORKTREE: worktree.path,
|
|
142
|
+
},
|
|
143
|
+
})
|
|
144
|
+
: { exitCode: 0, stdout: "", stderr: "" };
|
|
145
|
+
if (gated.exitCode !== 0) {
|
|
146
|
+
// The rebase already finished, so there is nothing for `rebase --abort`
|
|
147
|
+
// to undo: resetting to the pre-rebase commit is what returns the branch
|
|
148
|
+
// to its previous state.
|
|
149
|
+
await git.resetHard(worktree.path, before);
|
|
150
|
+
await lock.release();
|
|
151
|
+
await bump(worktree);
|
|
152
|
+
fail(
|
|
153
|
+
"tests_failed",
|
|
154
|
+
[
|
|
155
|
+
`\`${gate}\` failed after rebasing onto ${base} (exit ${gated.exitCode}).`,
|
|
156
|
+
`${base} is untouched and ${branch} is back at ${before.slice(0, 8)}.`,
|
|
157
|
+
"",
|
|
158
|
+
tail(`${gated.stdout}\n${gated.stderr}`),
|
|
159
|
+
].join("\n"),
|
|
160
|
+
{ task, exitCode: gated.exitCode },
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Deliberately re-read from disk rather than trusting the copy in hand: a
|
|
165
|
+
// long test run can outlive the lease, and landing after someone else
|
|
166
|
+
// claimed the tree would land work that is no longer ours.
|
|
167
|
+
const current = await worktree.reload();
|
|
168
|
+
if (!current.leaseOurs) {
|
|
169
|
+
await lock.release();
|
|
170
|
+
fail(
|
|
171
|
+
"lease_lost",
|
|
172
|
+
`the lease on '${task}' was taken by pid ${current.lease?.pid} during the merge, stopping without landing. Do not retry; another agent owns this tree.`,
|
|
173
|
+
{ task, lease: current.lease },
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// A branch can only be advanced where it is checked out: git allows one
|
|
178
|
+
// worktree per branch, and moving the ref behind that worktree's back would
|
|
179
|
+
// leave its index and files on the old commit. For trunk that tree is the
|
|
180
|
+
// main one; for a task created with `--base task/<other>` it is that task's
|
|
181
|
+
// worktree, and the work lands there rather than on trunk.
|
|
182
|
+
const holder = await git.worktreeFor(base);
|
|
183
|
+
const landing = holder ?? git.root;
|
|
184
|
+
const checkout = holder ? null : await git.checkout(base);
|
|
185
|
+
const merged =
|
|
186
|
+
checkout && checkout.code !== 0
|
|
187
|
+
? checkout
|
|
188
|
+
: await git.mergeFfOnly(landing, branch);
|
|
189
|
+
if (merged.code !== 0) {
|
|
190
|
+
await lock.release();
|
|
191
|
+
await bump(worktree);
|
|
192
|
+
fail(
|
|
193
|
+
"merge_failed",
|
|
194
|
+
[
|
|
195
|
+
`could not fast-forward ${base} in ${landing}: ${merged.stderr || merged.stdout}`,
|
|
196
|
+
...(landing === git.root
|
|
197
|
+
? []
|
|
198
|
+
: [
|
|
199
|
+
"",
|
|
200
|
+
`${base} is checked out there, which is the only place it can move.`,
|
|
201
|
+
"Whoever is working in that tree has to commit or stash what collides,",
|
|
202
|
+
"then merge again.",
|
|
203
|
+
]),
|
|
204
|
+
].join("\n"),
|
|
205
|
+
{ task, base, landing },
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const head = await git.shortHead(landing);
|
|
210
|
+
// The work is on the base, so the tree has nothing left to hold. Discarding it
|
|
211
|
+
// here is what keeps `arbor list` a list of live work rather than a graveyard
|
|
212
|
+
// of landed tasks.
|
|
213
|
+
//
|
|
214
|
+
// Step out of it first: merge usually runs from inside the tree it is about
|
|
215
|
+
// to delete, and spawning git from a directory that no longer exists fails
|
|
216
|
+
// with ENOENT before git is even reached.
|
|
217
|
+
service.ps.cd(git.root);
|
|
218
|
+
const discarded = await current.discard();
|
|
219
|
+
await lock.release();
|
|
220
|
+
if (discarded.code !== 0) {
|
|
221
|
+
fail(
|
|
222
|
+
"usage",
|
|
223
|
+
`landed '${task}' on ${base} (${head}) but could not discard its worktree: ${discarded.stderr || discarded.stdout}\nRun \`arbor remove ${task}\` to clean up.`,
|
|
224
|
+
{ task },
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
// After the land, in the main tree: the merge can bring the main tree a
|
|
228
|
+
// dependency change the way a rebase brings the worktree one. The branch is
|
|
229
|
+
// already on the base, so a failure here reports and rolls nothing back.
|
|
230
|
+
if (config.postMerge) {
|
|
231
|
+
const ran = await shell.run(config.postMerge, landing, {
|
|
232
|
+
env: { ARBOR_TASK: task },
|
|
233
|
+
});
|
|
234
|
+
if (ran.exitCode !== 0) {
|
|
235
|
+
fail(
|
|
236
|
+
"hook_failed",
|
|
237
|
+
[
|
|
238
|
+
`landed '${task}' on ${base} (${head}) but the postMerge hook failed (exit ${ran.exitCode}).`,
|
|
239
|
+
"",
|
|
240
|
+
tail(`${ran.stdout}\n${ran.stderr}`),
|
|
241
|
+
].join("\n"),
|
|
242
|
+
{ task, exitCode: ran.exitCode },
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
// Done is done: the todos this task took up leave the list with it.
|
|
247
|
+
const done = await todos.takenBy(task);
|
|
248
|
+
for (const todo of done) {
|
|
249
|
+
await todo.remove();
|
|
250
|
+
}
|
|
251
|
+
const lines = [
|
|
252
|
+
`${color.green("merged")} ${task} onto ${base} (${head})${
|
|
253
|
+
landing === git.root ? "" : `\n landed in: ${landing}`
|
|
254
|
+
}\n worktree removed, cd ${git.root}`,
|
|
255
|
+
...done.map((todo) => ` done todo ${todo.id}: ${todo.subject}`),
|
|
256
|
+
];
|
|
257
|
+
// Only a landing on trunk ends a piece of work. A part landing on its
|
|
258
|
+
// parent's branch hands back to that parent, which is not done yet.
|
|
259
|
+
if (landing === git.root) {
|
|
260
|
+
lines.push(
|
|
261
|
+
...recommendation(
|
|
262
|
+
await recommend(
|
|
263
|
+
todos,
|
|
264
|
+
task,
|
|
265
|
+
config.todoStalenessMs,
|
|
266
|
+
done.flatMap((todo) => todo.tags),
|
|
267
|
+
),
|
|
268
|
+
),
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
log.info(lines.join("\n"));
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async function bump(worktree: Worktree): Promise<void> {
|
|
275
|
+
await worktree.take({
|
|
276
|
+
status: "working",
|
|
277
|
+
mergeAttempts: worktree.mergeAttempts + 1,
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function tail(output: string): string {
|
|
282
|
+
return output.trim().split("\n").slice(-TAIL_LINES).join("\n");
|
|
283
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webappwiz/arbor",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.30",
|
|
4
4
|
"description": "Runs several AI coding agents on one repository at once, each in its own git worktree",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Jared Johnson",
|
|
@@ -10,6 +10,14 @@
|
|
|
10
10
|
"directory": "packages/arbor"
|
|
11
11
|
},
|
|
12
12
|
"type": "module",
|
|
13
|
+
"module": "index.ts",
|
|
14
|
+
"bin": {
|
|
15
|
+
"arbor": "./index.ts"
|
|
16
|
+
},
|
|
17
|
+
"exports": {
|
|
18
|
+
".": "./index.ts",
|
|
19
|
+
"./config": "./config.ts"
|
|
20
|
+
},
|
|
13
21
|
"imports": {
|
|
14
22
|
"#dev/*": "./dev/*"
|
|
15
23
|
},
|
|
@@ -20,22 +28,31 @@
|
|
|
20
28
|
"access": "public"
|
|
21
29
|
},
|
|
22
30
|
"dependencies": {
|
|
23
|
-
"webappwiz": "
|
|
31
|
+
"webappwiz": "0.0.29",
|
|
24
32
|
"zod": "^4.4.3"
|
|
25
33
|
},
|
|
26
|
-
"
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
"
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
"
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@base-ui/react": "^1.8.0",
|
|
36
|
+
"@dnd-kit/core": "^6.3.1",
|
|
37
|
+
"@dnd-kit/modifiers": "^9.0.0",
|
|
38
|
+
"@dnd-kit/sortable": "^10.0.0",
|
|
39
|
+
"@dnd-kit/utilities": "^3.2.2",
|
|
40
|
+
"@tailwindcss/oxide": "^4.3.3",
|
|
41
|
+
"@testing-library/react": "^16.3.1",
|
|
42
|
+
"@types/react": "^19.2.18",
|
|
43
|
+
"@types/react-dom": "^19.2.4",
|
|
44
|
+
"@webappwiz/react": "0.0.29",
|
|
45
|
+
"class-variance-authority": "^0.7.1",
|
|
46
|
+
"clsx": "^2.1.1",
|
|
47
|
+
"lucide-react": "^1.49.0",
|
|
48
|
+
"markdown-to-jsx": "^9.10.3",
|
|
49
|
+
"react": "^19.2.8",
|
|
50
|
+
"react-dom": "^19.2.8",
|
|
51
|
+
"tailwind-merge": "^3.7.0",
|
|
52
|
+
"tailwindcss": "^4.3.3",
|
|
53
|
+
"tw-animate-css": "^1.4.0"
|
|
37
54
|
},
|
|
38
|
-
"
|
|
39
|
-
"
|
|
55
|
+
"scripts": {
|
|
56
|
+
"build": "bun run build.ts"
|
|
40
57
|
}
|
|
41
58
|
}
|