@webappwiz/arbor 0.0.30 → 0.0.32

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 (120) hide show
  1. package/README.md +2 -2
  2. package/add.d.ts +23 -0
  3. package/age.d.ts +2 -0
  4. package/arbor.d.ts +11 -0
  5. package/attachments.d.ts +36 -0
  6. package/claim.d.ts +11 -0
  7. package/config.d.ts +55 -0
  8. package/config.js +7 -0
  9. package/dev/assets.d.ts +11 -0
  10. package/dev.d.ts +48 -0
  11. package/escalate.d.ts +28 -0
  12. package/exit.d.ts +42 -0
  13. package/git.d.ts +82 -0
  14. package/index.d.ts +2 -0
  15. package/index.js +4763 -0
  16. package/journal.d.ts +29 -0
  17. package/list.d.ts +18 -0
  18. package/load-config.d.ts +15 -0
  19. package/log.d.ts +14 -0
  20. package/merge.d.ts +24 -0
  21. package/package.json +15 -32
  22. package/path.d.ts +13 -0
  23. package/plan.d.ts +83 -0
  24. package/remove.d.ts +19 -0
  25. package/repository.d.ts +25 -0
  26. package/retry.d.ts +17 -0
  27. package/shell.d.ts +17 -0
  28. package/show.d.ts +53 -0
  29. package/snapshot.d.ts +36 -0
  30. package/table.d.ts +5 -0
  31. package/todo.d.ts +202 -0
  32. package/todos.d.ts +75 -0
  33. package/wait.d.ts +22 -0
  34. package/worktree-service.d.ts +51 -0
  35. package/worktree.d.ts +95 -0
  36. package/AGENTS.md +0 -7
  37. package/add.test.ts +0 -125
  38. package/add.ts +0 -192
  39. package/age.ts +0 -11
  40. package/arbor.test.ts +0 -145
  41. package/arbor.ts +0 -429
  42. package/assets.d.ts +0 -9
  43. package/attachments.ts +0 -87
  44. package/build.ts +0 -98
  45. package/claim.test.ts +0 -89
  46. package/claim.ts +0 -73
  47. package/components.json +0 -21
  48. package/config.ts +0 -58
  49. package/dev/api.ts +0 -120
  50. package/dev/app.test.tsx +0 -645
  51. package/dev/app.tsx +0 -80
  52. package/dev/assets.ts +0 -16
  53. package/dev/build/main.txt +0 -68
  54. package/dev/build/shell.txt +0 -21
  55. package/dev/build/styles.txt +0 -2263
  56. package/dev/components/ui/badge.tsx +0 -51
  57. package/dev/components/ui/button.tsx +0 -57
  58. package/dev/components/ui/dialog.tsx +0 -154
  59. package/dev/components/ui/empty.tsx +0 -100
  60. package/dev/components/ui/input-group.tsx +0 -155
  61. package/dev/components/ui/input.tsx +0 -19
  62. package/dev/components/ui/progress.tsx +0 -80
  63. package/dev/components/ui/textarea.tsx +0 -17
  64. package/dev/components/ui/toast.tsx +0 -227
  65. package/dev/feed.ts +0 -69
  66. package/dev/files.tsx +0 -191
  67. package/dev/index.html +0 -21
  68. package/dev/lib/utils.ts +0 -7
  69. package/dev/main.tsx +0 -22
  70. package/dev/markdown.test.tsx +0 -133
  71. package/dev/markdown.tsx +0 -124
  72. package/dev/mentions.tsx +0 -319
  73. package/dev/shadcn.css +0 -649
  74. package/dev/styles.css +0 -107
  75. package/dev/tags.tsx +0 -44
  76. package/dev/tasks.tsx +0 -151
  77. package/dev/todos.tsx +0 -759
  78. package/dev.test.ts +0 -439
  79. package/dev.ts +0 -386
  80. package/e2e.test.ts +0 -256
  81. package/escalate.test.ts +0 -76
  82. package/escalate.ts +0 -112
  83. package/exit.test.ts +0 -71
  84. package/exit.ts +0 -74
  85. package/git.ts +0 -294
  86. package/index.ts +0 -9
  87. package/journal.ts +0 -89
  88. package/list.test.ts +0 -82
  89. package/list.ts +0 -137
  90. package/load-config.test.ts +0 -121
  91. package/load-config.ts +0 -62
  92. package/log.test.ts +0 -65
  93. package/log.ts +0 -40
  94. package/merge.test.ts +0 -332
  95. package/merge.ts +0 -283
  96. package/path.test.ts +0 -35
  97. package/path.ts +0 -38
  98. package/plan.test.ts +0 -318
  99. package/plan.ts +0 -302
  100. package/progress.test.ts +0 -54
  101. package/progress.ts +0 -31
  102. package/remove.test.ts +0 -68
  103. package/remove.ts +0 -88
  104. package/repository.ts +0 -83
  105. package/retry.test.ts +0 -43
  106. package/retry.ts +0 -48
  107. package/shell.ts +0 -44
  108. package/show.test.ts +0 -91
  109. package/show.ts +0 -151
  110. package/snapshot.test.ts +0 -33
  111. package/snapshot.ts +0 -84
  112. package/table.ts +0 -20
  113. package/testing.ts +0 -229
  114. package/todo.test.ts +0 -490
  115. package/todo.ts +0 -834
  116. package/wait.test.ts +0 -61
  117. package/wait.ts +0 -77
  118. package/worktree-service.test.ts +0 -158
  119. package/worktree-service.ts +0 -193
  120. package/worktree.ts +0 -239
package/merge.test.ts DELETED
@@ -1,332 +0,0 @@
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 DELETED
@@ -1,283 +0,0 @@
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/path.test.ts DELETED
@@ -1,35 +0,0 @@
1
- import { afterEach, beforeEach, describe, expect, it } from "bun:test";
2
- import { add } from "./add";
3
- import { path } from "./path";
4
- import { Testing } from "./testing";
5
-
6
- describe("path", () => {
7
- let deps: Testing;
8
-
9
- beforeEach(async () => {
10
- deps = await Testing.open();
11
- });
12
-
13
- afterEach(() => deps.disposeAsync());
14
-
15
- it("prints the main tree bare, so `cd $(arbor path)` works", async () => {
16
- await path(deps);
17
-
18
- // Bare: no label, no decoration, nothing a shell would have to strip.
19
- expect(deps.out()).toBe(deps.root);
20
- });
21
-
22
- it("prints a task's worktree, and refuses one that is not there", async () => {
23
- await add(deps, "alpha");
24
- const worktree = (await deps.service.find("alpha")).path;
25
- deps.log.clear();
26
-
27
- await path(deps, "alpha");
28
- expect(deps.out()).toBe(worktree);
29
-
30
- await expect(path(deps, "nope")).toBail("not_found");
31
-
32
- await deps.fs.rm(worktree, { recursive: true, force: true });
33
- await expect(path(deps, "alpha")).toBail("orphaned");
34
- });
35
- });
package/path.ts DELETED
@@ -1,38 +0,0 @@
1
- import type { Logger } from "webappwiz/log";
2
- import { fail } from "./exit";
3
- import type { WorktreeService } from "./worktree-service";
4
-
5
- /**
6
- * Where a task lives, or (with no task) the main tree. Nothing but the path
7
- * on stdout, so it composes: `cd "$(arbor path)"`, `zed -a "$(arbor path x)"`.
8
- * Moving between trees is `cd` and nothing else, and the main tree is the one
9
- * path a process standing in a worktree cannot otherwise name: git's
10
- * `--show-toplevel` hands back the worktree it is already in.
11
- */
12
- export async function path(
13
- { service, log }: { service: WorktreeService; log: Logger },
14
- task?: string,
15
- ): Promise<void> {
16
- if (!task) {
17
- log.info(service.git.root);
18
- return;
19
- }
20
-
21
- const worktree = await service.find(task);
22
- if (worktree.gone) {
23
- fail(
24
- "not_found",
25
- `no task '${task}': run \`arbor list\` to see what there is`,
26
- { task },
27
- );
28
- }
29
- // A path you cannot cd into is worse than no answer: say so on stderr and
30
- // leave stdout empty rather than handing back a directory that is not there.
31
- if (!worktree.exists) {
32
- fail("orphaned", `'${task}' has no worktree at ${worktree.path}`, {
33
- task,
34
- worktree: worktree.path,
35
- });
36
- }
37
- log.info(worktree.path);
38
- }