@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 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. The worktree still exists; fix and re-run the hook. |
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
- fail("usage", `git worktree add failed: ${added.stderr}`, {
87
- task
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
- return { ...defaults(root), ...await file(opts.fs ?? new NodeFs5, root) };
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: "main",
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: "\\uD83C\\uDF32 arbor"
19036
- }, undefined, false, undefined, this)
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 and nothing to keep in step with the heading on the page. -->
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.1",
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.1"
20
+ "webappwiz": "^0.0.3"
21
21
  },
22
22
  "main": "./index.js",
23
23
  "types": "./index.d.ts",