@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 +15 -5
- package/git.d.ts +7 -1
- package/index.js +35 -10
- package/merge.d.ts +2 -1
- package/package.json +2 -2
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
|
|
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
|
|
59
|
-
there, and fast-forwards
|
|
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
|
|
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` |
|
|
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
|
|
634
|
-
const
|
|
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",
|
|
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(
|
|
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:
|
|
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(
|
|
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
|
|
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.
|
|
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.
|
|
20
|
+
"webappwiz": "^0.0.12"
|
|
21
21
|
},
|
|
22
22
|
"main": "./index.js",
|
|
23
23
|
"types": "./index.d.ts",
|