@webappwiz/arbor 0.0.1 → 0.0.3
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 +11 -2
- package/config.d.ts +6 -0
- package/git.d.ts +5 -0
- package/index.js +41 -11
- package/load-config.d.ts +8 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -216,7 +216,7 @@ The agent's control flow runs on these.
|
|
|
216
216
|
| 6 | `lease_held` | Another agent is driving this tree. |
|
|
217
217
|
| 7 | `dirty` | Uncommitted changes. Commit before merging. |
|
|
218
218
|
| 8 | `not_found` | No such task, or not run from a task worktree. |
|
|
219
|
-
| 9 | `hook_failed` | `postCheckout` failed
|
|
219
|
+
| 9 | `hook_failed` | `postCheckout` failed (worktree still exists; fix and re-run the hook), or `postMerge` failed (the branch already landed; nothing rolled back). |
|
|
220
220
|
| 10 | `exists` | Task already exists. `arbor claim` it, or `arbor rm` first. |
|
|
221
221
|
| 11 | `orphaned` | Record with no worktree. `arbor rm` it. |
|
|
222
222
|
| 12 | `merge_failed` | Trunk could not be fast-forwarded (usually a dirty main worktree). |
|
|
@@ -239,6 +239,7 @@ export default defineConfig({
|
|
|
239
239
|
postCheckout: "bun install && cp ../../myrepo/.env .env",
|
|
240
240
|
postRewrite: "bun install", // after each rebase, before preMerge
|
|
241
241
|
preMerge: "bun test", // the last gate before the branch lands
|
|
242
|
+
postMerge: "bun install", // in the main tree, after the branch lands
|
|
242
243
|
leaseStalenessMs: 90_000,
|
|
243
244
|
mergeRetryCount: 2,
|
|
244
245
|
removedCapacity: 50, // removed names kept, so rm can say "already removed"
|
|
@@ -246,11 +247,19 @@ export default defineConfig({
|
|
|
246
247
|
});
|
|
247
248
|
```
|
|
248
249
|
|
|
250
|
+
`trunk` is the one key worth leaving out: unset, arbor takes the branch
|
|
251
|
+
`refs/remotes/origin/HEAD` points at, so a repo on `master` needs no config at
|
|
252
|
+
all, and falls back to `main` when there is no such ref to read.
|
|
253
|
+
|
|
249
254
|
The hooks are named for the git events they sit around: `postCheckout` runs
|
|
250
255
|
once, when `add` checks the worktree out; `postRewrite` runs after every rebase
|
|
251
256
|
`merge` does; `preMerge` runs after that, and is the last thing between the
|
|
252
257
|
branch and the base. They all run through `sh -c` in the worktree with
|
|
253
|
-
`ARBOR_TASK` and `ARBOR_WORKTREE` in the environment.
|
|
258
|
+
`ARBOR_TASK` and `ARBOR_WORKTREE` in the environment. `postMerge` is the
|
|
259
|
+
exception: it runs in the main tree after the branch lands and the worktree is
|
|
260
|
+
gone (so only `ARBOR_TASK` is set), for keeping the main tree current the way
|
|
261
|
+
`postRewrite` keeps the worktree current. It reports failure but rolls nothing
|
|
262
|
+
back; the landing already happened.
|
|
254
263
|
|
|
255
264
|
Every hook is unset by default: arbor has no opinion about what a repo runs, or
|
|
256
265
|
whether it has tests at all. Configure `preMerge` and a nonzero exit rolls the
|
package/config.d.ts
CHANGED
|
@@ -22,6 +22,12 @@ export interface Config {
|
|
|
22
22
|
* rebase alone.
|
|
23
23
|
*/
|
|
24
24
|
preMerge: string | null;
|
|
25
|
+
/**
|
|
26
|
+
* Command run by `merge` in the main tree after the base fast-forwards, via
|
|
27
|
+
* `sh -c`. The branch has already landed and the worktree is gone: a nonzero
|
|
28
|
+
* exit reports the failure but rolls nothing back.
|
|
29
|
+
*/
|
|
30
|
+
postMerge: string | null;
|
|
25
31
|
/** How long since its last heartbeat before a task's lease is up for grabs. */
|
|
26
32
|
leaseStalenessMs: number;
|
|
27
33
|
/** Failed `merge` attempts a task gets before it must escalate or be removed. */
|
package/git.d.ts
CHANGED
|
@@ -25,6 +25,11 @@ export declare class Git {
|
|
|
25
25
|
run(cwd: string, ...args: string[]): Promise<GitResult>;
|
|
26
26
|
out(cwd: string, ...args: string[]): Promise<string>;
|
|
27
27
|
branchExists(branch: string): Promise<boolean>;
|
|
28
|
+
/**
|
|
29
|
+
* The branch `origin/HEAD` points at, or null when the repo has no such ref:
|
|
30
|
+
* no remote, or a clone that never fetched it.
|
|
31
|
+
*/
|
|
32
|
+
defaultBranch(): Promise<string | null>;
|
|
28
33
|
currentBranch(cwd: string): Promise<string>;
|
|
29
34
|
head(cwd: string): Promise<string>;
|
|
30
35
|
shortHead(cwd: string): Promise<string>;
|
package/index.js
CHANGED
|
@@ -83,9 +83,8 @@ async function add({
|
|
|
83
83
|
}
|
|
84
84
|
const added = await service.add(task, { base });
|
|
85
85
|
if (added.code !== 0) {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
});
|
|
86
|
+
const missingTrunk = base === config.trunk && !await service.git.branchExists(base);
|
|
87
|
+
fail("usage", missingTrunk ? `trunk '${base}' is not a branch in this repo: set \`trunk\` in arbor.config.ts` : `git worktree add failed: ${added.stderr}`, { task });
|
|
89
88
|
}
|
|
90
89
|
const worktree = await (await service.find(task)).take({ base });
|
|
91
90
|
const info = `${await service.git.commonDir()}/info`;
|
|
@@ -628,6 +627,21 @@ ${gated.stderr}`)
|
|
|
628
627
|
fail("usage", `landed '${task}' on ${base} (${head}) but could not discard its worktree: ${discarded.stderr || discarded.stdout}
|
|
629
628
|
Run \`arbor rm ${task}\` to clean up.`, { task });
|
|
630
629
|
}
|
|
630
|
+
if (config.postMerge) {
|
|
631
|
+
const ran = await shell.run(config.postMerge, {
|
|
632
|
+
cwd: git.root,
|
|
633
|
+
env: { ARBOR_TASK: task }
|
|
634
|
+
});
|
|
635
|
+
if (ran.exitCode !== 0) {
|
|
636
|
+
fail("hook_failed", [
|
|
637
|
+
`landed '${task}' on ${base} (${head}) but the postMerge hook failed (exit ${ran.exitCode}).`,
|
|
638
|
+
"",
|
|
639
|
+
tail(`${ran.stdout}
|
|
640
|
+
${ran.stderr}`)
|
|
641
|
+
].join(`
|
|
642
|
+
`), { task, exitCode: ran.exitCode });
|
|
643
|
+
}
|
|
644
|
+
}
|
|
631
645
|
log2.info(`${color8.green("merged")} ${task} onto ${base} (${head})
|
|
632
646
|
worktree removed, cd ${git.root}`);
|
|
633
647
|
}
|
|
@@ -698,6 +712,10 @@ class Git {
|
|
|
698
712
|
const { code } = await this.run(this.root, "rev-parse", "--verify", "--quiet", `refs/heads/${branch}`);
|
|
699
713
|
return code === 0;
|
|
700
714
|
}
|
|
715
|
+
async defaultBranch() {
|
|
716
|
+
const { code, stdout } = await this.run(this.root, "symbolic-ref", "--short", "refs/remotes/origin/HEAD");
|
|
717
|
+
return code === 0 ? stdout.replace(/^origin\//, "") || null : null;
|
|
718
|
+
}
|
|
701
719
|
currentBranch(cwd) {
|
|
702
720
|
return this.out(cwd, "rev-parse", "--abbrev-ref", "HEAD");
|
|
703
721
|
}
|
|
@@ -832,15 +850,18 @@ class Journal {
|
|
|
832
850
|
import { basename, resolve } from "node:path";
|
|
833
851
|
import { NodeFs as NodeFs5 } from "webappwiz/system";
|
|
834
852
|
async function loadConfig(root, opts = {}) {
|
|
835
|
-
|
|
853
|
+
const found = await file(opts.fs ?? new NodeFs5, root);
|
|
854
|
+
const trunk = found.trunk ?? await (opts.git ?? new Git(root)).defaultBranch() ?? "main";
|
|
855
|
+
return { ...defaults(root, trunk), ...found };
|
|
836
856
|
}
|
|
837
|
-
function defaults(root) {
|
|
857
|
+
function defaults(root, trunk) {
|
|
838
858
|
return {
|
|
839
|
-
trunk
|
|
859
|
+
trunk,
|
|
840
860
|
worktreeRoot: resolve(root, "..", `${basename(root)}-arbor`),
|
|
841
861
|
postCheckout: null,
|
|
842
862
|
postRewrite: null,
|
|
843
863
|
preMerge: null,
|
|
864
|
+
postMerge: null,
|
|
844
865
|
leaseStalenessMs: 90000,
|
|
845
866
|
mergeRetryCount: 2,
|
|
846
867
|
removedCapacity: 50,
|
|
@@ -852,7 +873,9 @@ async function file(fs, root) {
|
|
|
852
873
|
if (!await fs.exists(path2)) {
|
|
853
874
|
return {};
|
|
854
875
|
}
|
|
855
|
-
const mod = await import(path2)
|
|
876
|
+
const mod = await import(path2).catch((cause) => {
|
|
877
|
+
throw new Error(`could not load ${path2}: ${cause}. Its imports may be stale: try \`bun install\` in ${root}.`, { cause });
|
|
878
|
+
});
|
|
856
879
|
return mod.default ?? {};
|
|
857
880
|
}
|
|
858
881
|
|
|
@@ -1150,8 +1173,8 @@ function repository(at) {
|
|
|
1150
1173
|
}
|
|
1151
1174
|
const gitDir = stdout.trim();
|
|
1152
1175
|
const arborDir = `${gitDir}/arbor`;
|
|
1153
|
-
const config = await loadConfig(dirname(gitDir), { fs });
|
|
1154
1176
|
const git = new Git(dirname(gitDir), { ps, fs });
|
|
1177
|
+
const config = await loadConfig(dirname(gitDir), { fs, git });
|
|
1155
1178
|
const service = new WorktreeService(git, config, arborDir, { fs, ps });
|
|
1156
1179
|
await service.init();
|
|
1157
1180
|
await next({
|
|
@@ -19026,14 +19049,21 @@ function App() {
|
|
|
19026
19049
|
}, [feed]);
|
|
19027
19050
|
const { snapshot, offline } = useReactive(feed, (feed2) => ({ snapshot: feed2.snapshot, offline: feed2.offline }), ["changed"]);
|
|
19028
19051
|
const [tab, setTab] = import_react6.useState("tasks");
|
|
19052
|
+
const emoji = offline ? "⚠️" : "\\uD83C\\uDF32";
|
|
19053
|
+
import_react6.useEffect(() => {
|
|
19054
|
+
document.querySelector("link[rel=icon]")?.setAttribute("href", \`data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><text y='26' font-size='28'>\${emoji}</text></svg>\`);
|
|
19055
|
+
}, [emoji]);
|
|
19029
19056
|
return /* @__PURE__ */ jsx_dev_runtime2.jsxDEV("div", {
|
|
19030
19057
|
className: "mx-auto max-w-6xl px-4 py-8 font-mono text-[13px]",
|
|
19031
19058
|
children: [
|
|
19032
19059
|
/* @__PURE__ */ jsx_dev_runtime2.jsxDEV("h1", {
|
|
19033
19060
|
className: "mb-4 text-base",
|
|
19034
19061
|
children: /* @__PURE__ */ jsx_dev_runtime2.jsxDEV("b", {
|
|
19035
|
-
children:
|
|
19036
|
-
|
|
19062
|
+
children: [
|
|
19063
|
+
emoji,
|
|
19064
|
+
" arbor"
|
|
19065
|
+
]
|
|
19066
|
+
}, undefined, true, undefined, this)
|
|
19037
19067
|
}, undefined, false, undefined, this),
|
|
19038
19068
|
/* @__PURE__ */ jsx_dev_runtime2.jsxDEV("nav", {
|
|
19039
19069
|
className: "mb-4 flex gap-6 text-xs uppercase tracking-widest",
|
|
@@ -19362,7 +19392,7 @@ var shell_default = `<!doctype html>
|
|
|
19362
19392
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
19363
19393
|
<title>arbor</title>
|
|
19364
19394
|
<!-- The emoji drawn as SVG inline, so there is no file for the server to
|
|
19365
|
-
serve
|
|
19395
|
+
serve. The page rewrites it to a warning when the server goes away. -->
|
|
19366
19396
|
<link
|
|
19367
19397
|
rel="icon"
|
|
19368
19398
|
href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><text y='26' font-size='28'>🌲</text></svg>"
|
package/load-config.d.ts
CHANGED
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
import { type Fs } from "webappwiz/system";
|
|
2
2
|
import type { Config } from "./config.js";
|
|
3
|
+
import { Git } from "./git.js";
|
|
3
4
|
export interface LoadConfigOptions {
|
|
4
5
|
/** What `arbor.config.ts` is looked for through; the real one by default. */
|
|
5
6
|
fs?: Fs;
|
|
7
|
+
/** What the trunk is detected through; the repo's own git by default. */
|
|
8
|
+
git?: Git;
|
|
6
9
|
}
|
|
10
|
+
/**
|
|
11
|
+
* The settings arbor runs a repo with: what `arbor.config.ts` names, then the
|
|
12
|
+
* branch `origin/HEAD` points at for the trunk, then arbor's own defaults.
|
|
13
|
+
* Throws when the config file is there but cannot be imported.
|
|
14
|
+
*/
|
|
7
15
|
export declare function loadConfig(root: string, opts?: LoadConfigOptions): Promise<Config>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webappwiz/arbor",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.3",
|
|
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.3"
|
|
21
21
|
},
|
|
22
22
|
"main": "./index.js",
|
|
23
23
|
"types": "./index.d.ts",
|