@webappwiz/arbor 0.0.10 → 0.0.12

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/README.md CHANGED
@@ -32,6 +32,11 @@ rebase, and that is what `rm` is for.
32
32
  Creates the task: branch `task/<task>`, a worktree at
33
33
  `../<repo>-arbor/<task>`, and a state record.
34
34
 
35
+ `--base <branch>` starts the task from that branch and lands it back there
36
+ instead of trunk. It takes another task's branch too: `--base task/<other>`
37
+ stacks this task on that one, and the work lands in that task's worktree
38
+ rather than on trunk.
39
+
35
40
  A fresh worktree shares no untracked files with the repo (no `node_modules`,
36
41
  no `.env`) which is what `postCheckout` is for.
37
42
 
@@ -53,10 +58,11 @@ holds the lease. A worktree with no record is rebuilt rather than rejected.
53
58
 
54
59
  ### `arbor merge`
55
60
 
56
- Lands the current worktree's branch on trunk. The core command.
61
+ Lands the current worktree's branch on its base, trunk unless the task was
62
+ created with `--base`. The core command.
57
63
 
58
- **Never a merge commit.** It rebases onto trunk, runs the `preMerge` gate
59
- there, and fast-forwards trunk with `git merge --ff-only`. History stays
64
+ **Never a merge commit.** It rebases onto the base, runs the `preMerge` gate
65
+ there, and fast-forwards the base with `git merge --ff-only`. History stays
60
66
  linear.
61
67
 
62
68
  1. Refuses if the worktree is dirty, out of retry budget, or leased elsewhere.
@@ -67,7 +73,11 @@ linear.
67
73
  passed before rebasing says nothing about whether it works against current
68
74
  trunk; this is the only defense against semantic conflicts, where both sides
69
75
  merge cleanly and the combination is broken.
70
- 4. Re-checks the lease, then `git checkout <trunk> && git merge --ff-only`.
76
+ 4. Re-checks the lease, then fast-forwards the base **in the worktree that has
77
+ it checked out**: the main tree for trunk, or the other task's worktree for
78
+ a `--base task/<other>` task. Git allows one worktree per branch, and that
79
+ tree is the only place the branch can move: advancing the ref behind its
80
+ back would leave its index and files on the old commit.
71
81
  5. Discards the task (worktree, branch and record) exactly as `rm`
72
82
  would. The work is on trunk, so the tree has nothing left to hold, and
73
83
  `arbor ls` stays a list of live work rather than a graveyard of landed
@@ -236,7 +246,7 @@ The agent's control flow runs on these.
236
246
  | 9 | `hook_failed` | `postCheckout` failed (worktree still exists; fix and re-run the hook), or `postMerge` failed (the branch already landed; nothing rolled back). |
237
247
  | 10 | `exists` | Task already exists. `arbor claim` it, or `arbor rm` first. |
238
248
  | 11 | `orphaned` | Record with no worktree. `arbor rm` it. |
239
- | 12 | `merge_failed` | Trunk could not be fast-forwarded (usually a dirty main worktree). |
249
+ | 12 | `merge_failed` | The base could not be fast-forwarded (usually uncommitted changes in the worktree holding it). |
240
250
  | 13 | `already_removed` | This task was removed earlier; nothing left to remove. |
241
251
  | 14 | `timeout` | `arbor wait` gave up: the task is still working or merging. |
242
252
 
package/git.d.ts CHANGED
@@ -44,7 +44,13 @@ export declare class Git {
44
44
  rebase(cwd: string, onto: string): Promise<GitResult>;
45
45
  resetHard(cwd: string, commit: string): Promise<GitResult>;
46
46
  checkout(branch: string): Promise<GitResult>;
47
- mergeFfOnly(branch: string): Promise<GitResult>;
47
+ mergeFfOnly(cwd: string, branch: string): Promise<GitResult>;
48
+ /**
49
+ * The worktree with `branch` checked out, or null when no worktree has it.
50
+ * Git allows one worktree per branch and that is the only place the branch
51
+ * can be advanced, so this is where a fast-forward has to happen.
52
+ */
53
+ worktreeFor(branch: string): Promise<string | null>;
48
54
  addWorktree(branch: string, path: string, from: string): Promise<GitResult>;
49
55
  removeWorktree(path: string): Promise<GitResult>;
50
56
  /** Drops metadata for worktrees whose directories are gone. */
package/index.js CHANGED
@@ -630,14 +630,25 @@ ${gated.stderr}`)
630
630
  await lock.release();
631
631
  fail("lease_lost", `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.`, { task, lease: current.lease });
632
632
  }
633
- const checkout = await git.checkout(base);
634
- const merged = checkout.code === 0 ? await git.mergeFfOnly(branch) : checkout;
633
+ const holder = await git.worktreeFor(base);
634
+ const landing = holder ?? git.root;
635
+ const checkout = holder ? null : await git.checkout(base);
636
+ const merged = checkout && checkout.code !== 0 ? checkout : await git.mergeFfOnly(landing, branch);
635
637
  if (merged.code !== 0) {
636
638
  await lock.release();
637
639
  await bump(worktree);
638
- fail("merge_failed", `could not fast-forward ${base} in ${git.root}: ${merged.stderr || merged.stdout}`, { task });
640
+ fail("merge_failed", [
641
+ `could not fast-forward ${base} in ${landing}: ${merged.stderr || merged.stdout}`,
642
+ ...landing === git.root ? [] : [
643
+ "",
644
+ `${base} is checked out there, which is the only place it can move.`,
645
+ "Whoever is working in that tree has to commit or stash what collides,",
646
+ "then merge again."
647
+ ]
648
+ ].join(`
649
+ `), { task, base, landing });
639
650
  }
640
- const head = await git.shortHead(git.root);
651
+ const head = await git.shortHead(landing);
641
652
  service.ps.cd(git.root);
642
653
  const discarded = await current.discard();
643
654
  await lock.release();
@@ -647,7 +658,7 @@ Run \`arbor rm ${task}\` to clean up.`, { task });
647
658
  }
648
659
  if (config.postMerge) {
649
660
  const ran = await shell.run(config.postMerge, {
650
- cwd: git.root,
661
+ cwd: landing,
651
662
  env: { ARBOR_TASK: task }
652
663
  });
653
664
  if (ran.exitCode !== 0) {
@@ -660,7 +671,8 @@ ${ran.stderr}`)
660
671
  `), { task, exitCode: ran.exitCode });
661
672
  }
662
673
  }
663
- log2.info(`${color8.green("merged")} ${task} onto ${base} (${head})
674
+ log2.info(`${color8.green("merged")} ${task} onto ${base} (${head})${landing === git.root ? "" : `
675
+ landed in: ${landing}`}
664
676
  worktree removed, cd ${git.root}`);
665
677
  }
666
678
  async function bump(worktree) {
@@ -774,8 +786,21 @@ class Git {
774
786
  checkout(branch) {
775
787
  return this.run(this.root, "checkout", branch);
776
788
  }
777
- mergeFfOnly(branch) {
778
- return this.run(this.root, "merge", "--ff-only", branch);
789
+ mergeFfOnly(cwd, branch) {
790
+ return this.run(cwd, "merge", "--ff-only", branch);
791
+ }
792
+ async worktreeFor(branch) {
793
+ const listing2 = await this.out(this.root, "worktree", "list", "--porcelain");
794
+ let path2 = null;
795
+ for (const line of listing2.split(`
796
+ `)) {
797
+ if (line.startsWith("worktree ")) {
798
+ path2 = line.slice("worktree ".length);
799
+ } else if (line === `branch refs/heads/${branch}`) {
800
+ return path2;
801
+ }
802
+ }
803
+ return null;
779
804
  }
780
805
  addWorktree(branch, path2, from) {
781
806
  return this.run(this.root, "worktree", "add", "-b", branch, path2, from);
@@ -1319,10 +1344,10 @@ var here = async ({
1319
1344
  var arbor = cli("arbor").use(exits()).use(repository());
1320
1345
  arbor.command("add").description("start a new task: create branch task/<task>, a worktree at ../<repo>-arbor/<task> and a state record").arg("task", t.string(), { description: "task name (lowercase-with-dashes)" }).option("base", t.string(), {
1321
1346
  default: "",
1322
- description: "branch this task starts from and merges onto (default: trunk)"
1347
+ description: "branch this task starts from and merges onto (default: trunk); `task/<other>` stacks this task on that one and lands the work in its worktree"
1323
1348
  }).action((opts, ctx) => ctx.journal.record("add", opts.task, () => add(ctx, opts.task, { base: opts.base || undefined })));
1324
1349
  arbor.command("claim").description("resume an existing task: take ownership of its worktree and print its path, status and any half-finished rebase; refuses while another agent holds the lease, but takes a stale one silently, so `arbor show` first if the tree may not be abandoned").arg("task", t.string(), { description: "task name" }).action((opts, ctx) => ctx.journal.record("claim", opts.task, () => claim(ctx, opts.task)));
1325
- arbor.command("merge").description("land this worktree's branch on its base (trunk unless created with --base): rebase onto it, run tests on the rebased code, fast-forward it, then discard the worktree, branch and record (linear history, never a merge commit, no flag to skip tests); requires committed work, refusing a dirty worktree").action(async (_opts, ctx) => ctx.journal.record("merge", await here(ctx), () => merge(ctx, ctx.ps.cwd())));
1350
+ arbor.command("merge").description("land this worktree's branch on its base (trunk unless created with --base): rebase onto it, run tests on the rebased code, fast-forward it in whichever worktree has it checked out, then discard the worktree, branch and record (linear history, never a merge commit, no flag to skip tests); requires committed work, refusing a dirty worktree").action(async (_opts, ctx) => ctx.journal.record("merge", await here(ctx), () => merge(ctx, ctx.ps.cwd())));
1326
1351
  arbor.command("rm").description("discard a task: worktree, branch and state file; cheap and encouraged, since redoing a task against current trunk often beats a hard rebase").arg("task", t.string(), { description: "task name" }).option("force", t.boolean(), {
1327
1352
  default: false,
1328
1353
  description: "discard even when another agent holds the lease"
package/merge.d.ts CHANGED
@@ -8,7 +8,8 @@ import type { WorktreeService } from "./worktree-service.js";
8
8
  * Lands the current worktree's branch on its base branch, trunk unless the
9
9
  * task was created with `--base`. Never a merge commit: it rebases onto the
10
10
  * base, runs the gate there, and fast-forwards the base with
11
- * `git merge --ff-only`. History stays linear.
11
+ * `git merge --ff-only`, in whichever worktree has the base checked out.
12
+ * History stays linear.
12
13
  */
13
14
  export declare function merge({ service, git, lock, shell, config, log, }: {
14
15
  service: WorktreeService;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webappwiz/arbor",
3
- "version": "0.0.10",
3
+ "version": "0.0.12",
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",
@@ -17,7 +17,7 @@
17
17
  "access": "public"
18
18
  },
19
19
  "dependencies": {
20
- "webappwiz": "^0.0.10"
20
+ "webappwiz": "^0.0.12"
21
21
  },
22
22
  "main": "./index.js",
23
23
  "types": "./index.d.ts",