@webappwiz/arbor 0.0.28 → 0.0.30

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 (119) hide show
  1. package/AGENTS.md +7 -0
  2. package/README.md +34 -12
  3. package/add.test.ts +125 -0
  4. package/add.ts +192 -0
  5. package/age.ts +11 -0
  6. package/arbor.test.ts +145 -0
  7. package/arbor.ts +429 -0
  8. package/assets.d.ts +9 -0
  9. package/attachments.ts +87 -0
  10. package/build.ts +98 -0
  11. package/claim.test.ts +89 -0
  12. package/claim.ts +73 -0
  13. package/components.json +21 -0
  14. package/config.ts +58 -0
  15. package/dev/api.ts +120 -0
  16. package/dev/app.test.tsx +645 -0
  17. package/dev/app.tsx +80 -0
  18. package/dev/assets.ts +16 -0
  19. package/dev/build/main.txt +68 -0
  20. package/dev/build/shell.txt +21 -0
  21. package/dev/build/styles.txt +2263 -0
  22. package/dev/components/ui/badge.tsx +51 -0
  23. package/dev/components/ui/button.tsx +57 -0
  24. package/dev/components/ui/dialog.tsx +154 -0
  25. package/dev/components/ui/empty.tsx +100 -0
  26. package/dev/components/ui/input-group.tsx +155 -0
  27. package/dev/components/ui/input.tsx +19 -0
  28. package/dev/components/ui/progress.tsx +80 -0
  29. package/dev/components/ui/textarea.tsx +17 -0
  30. package/dev/components/ui/toast.tsx +227 -0
  31. package/dev/feed.ts +69 -0
  32. package/dev/files.tsx +191 -0
  33. package/dev/index.html +21 -0
  34. package/dev/lib/utils.ts +7 -0
  35. package/dev/main.tsx +22 -0
  36. package/dev/markdown.test.tsx +133 -0
  37. package/dev/markdown.tsx +124 -0
  38. package/dev/mentions.tsx +319 -0
  39. package/dev/shadcn.css +649 -0
  40. package/dev/styles.css +107 -0
  41. package/dev/tags.tsx +44 -0
  42. package/dev/tasks.tsx +151 -0
  43. package/dev/todos.tsx +759 -0
  44. package/dev.test.ts +439 -0
  45. package/dev.ts +386 -0
  46. package/e2e.test.ts +256 -0
  47. package/escalate.test.ts +76 -0
  48. package/escalate.ts +112 -0
  49. package/exit.test.ts +71 -0
  50. package/exit.ts +74 -0
  51. package/git.ts +294 -0
  52. package/index.ts +9 -0
  53. package/journal.ts +89 -0
  54. package/list.test.ts +82 -0
  55. package/list.ts +137 -0
  56. package/load-config.test.ts +121 -0
  57. package/load-config.ts +62 -0
  58. package/log.test.ts +65 -0
  59. package/log.ts +40 -0
  60. package/merge.test.ts +332 -0
  61. package/merge.ts +283 -0
  62. package/package.json +32 -15
  63. package/path.test.ts +35 -0
  64. package/path.ts +38 -0
  65. package/plan.test.ts +318 -0
  66. package/plan.ts +302 -0
  67. package/progress.test.ts +54 -0
  68. package/progress.ts +31 -0
  69. package/remove.test.ts +68 -0
  70. package/remove.ts +88 -0
  71. package/repository.ts +83 -0
  72. package/retry.test.ts +43 -0
  73. package/retry.ts +48 -0
  74. package/shell.ts +44 -0
  75. package/show.test.ts +91 -0
  76. package/show.ts +151 -0
  77. package/snapshot.test.ts +33 -0
  78. package/snapshot.ts +84 -0
  79. package/table.ts +20 -0
  80. package/testing.ts +229 -0
  81. package/todo.test.ts +490 -0
  82. package/todo.ts +834 -0
  83. package/wait.test.ts +61 -0
  84. package/wait.ts +77 -0
  85. package/worktree-service.test.ts +158 -0
  86. package/worktree-service.ts +193 -0
  87. package/worktree.ts +239 -0
  88. package/add.d.ts +0 -23
  89. package/age.d.ts +0 -2
  90. package/arbor.d.ts +0 -11
  91. package/attachments.d.ts +0 -36
  92. package/claim.d.ts +0 -11
  93. package/config.d.ts +0 -55
  94. package/config.js +0 -7
  95. package/dev/assets.d.ts +0 -11
  96. package/dev.d.ts +0 -47
  97. package/escalate.d.ts +0 -28
  98. package/exit.d.ts +0 -42
  99. package/git.d.ts +0 -77
  100. package/index.d.ts +0 -2
  101. package/index.js +0 -4669
  102. package/journal.d.ts +0 -29
  103. package/list.d.ts +0 -18
  104. package/load-config.d.ts +0 -15
  105. package/log.d.ts +0 -14
  106. package/merge.d.ts +0 -24
  107. package/path.d.ts +0 -13
  108. package/plan.d.ts +0 -83
  109. package/remove.d.ts +0 -19
  110. package/repository.d.ts +0 -25
  111. package/retry.d.ts +0 -17
  112. package/shell.d.ts +0 -17
  113. package/show.d.ts +0 -53
  114. package/snapshot.d.ts +0 -31
  115. package/table.d.ts +0 -5
  116. package/todo.d.ts +0 -230
  117. package/wait.d.ts +0 -22
  118. package/worktree-service.d.ts +0 -51
  119. package/worktree.d.ts +0 -95
@@ -0,0 +1,76 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
2
+ import { join } from "node:path";
3
+ import { add } from "./add";
4
+ import { escalate, REVIEW_SUBJECT } from "./escalate";
5
+ import { PLAN_FILE, questions } from "./plan";
6
+ import { Testing } from "./testing";
7
+
8
+ describe("escalate", () => {
9
+ let deps: Testing;
10
+
11
+ beforeEach(async () => {
12
+ deps = await Testing.open();
13
+ });
14
+
15
+ afterEach(() => deps.disposeAsync());
16
+
17
+ it("records the reason, drops the lease, and leaves the tree alone", async () => {
18
+ await add(deps, "alpha");
19
+ const worktree = (await deps.service.find("alpha")).path;
20
+ await deps.fs.write(join(worktree, "half-done.txt"), "work in progress\n");
21
+
22
+ await escalate(deps, "both sides restructured the router", worktree);
23
+
24
+ const state = (await deps.service.find("alpha")).state;
25
+ expect(state).toMatchObject({ status: "escalated", lease: null });
26
+ expect(state?.escalations).toHaveLength(1);
27
+ expect(await deps.fs.exists(join(worktree, "half-done.txt"))).toBe(true);
28
+
29
+ await escalate(deps, "second thoughts", worktree);
30
+ expect((await deps.service.find("alpha")).state?.escalations).toHaveLength(
31
+ 2,
32
+ );
33
+ });
34
+
35
+ it("requires an explicit task when run outside a worktree", async () => {
36
+ await add(deps, "alpha");
37
+
38
+ await expect(escalate(deps, "needs a human", deps.root)).toBail("usage", {
39
+ message: "--task",
40
+ });
41
+
42
+ await escalate(deps, "needs a human", deps.root, { task: "alpha" });
43
+ expect((await deps.service.find("alpha")).state?.status).toBe("escalated");
44
+ });
45
+
46
+ it("asks for approval with a question of its own, once nothing else is open", async () => {
47
+ await add(deps, "alpha");
48
+ const worktree = (await deps.service.find("alpha")).path;
49
+ const plan = join(worktree, PLAN_FILE);
50
+ await deps.fs.write(plan, "# alpha\n\n## Blocked\n\n- [ ] 1. Keep it?\n");
51
+
52
+ await expect(
53
+ escalate(deps, "check the header", worktree, { review: true }),
54
+ ).toBail("blocked", { data: { task: "alpha", blocked: ["1"] } });
55
+
56
+ await deps.fs.write(
57
+ plan,
58
+ "# alpha\n\n## Blocked\n\n- [x] 1. Keep it? → yes\n",
59
+ );
60
+ await escalate(deps, "check the header at 390px", worktree, {
61
+ review: true,
62
+ });
63
+
64
+ expect(questions(await deps.fs.read(plan)).at(-1)).toMatchObject({
65
+ number: "2",
66
+ text: REVIEW_SUBJECT,
67
+ body: "check the header at 390px",
68
+ });
69
+ expect(
70
+ (await deps.service.find("alpha")).state?.escalations?.at(-1),
71
+ ).toMatchObject({ reason: "check the header at 390px", review: "2" });
72
+ expect(deps.out()).toContain(
73
+ "review: question 2 asks to approve merging",
74
+ );
75
+ });
76
+ });
package/escalate.ts ADDED
@@ -0,0 +1,112 @@
1
+ import { color, type Logger } from "webappwiz/log";
2
+ import type { Fs, Lock } from "webappwiz/system";
3
+ import { fail } from "./exit";
4
+ import type { Git } from "./git";
5
+ import { PLAN_FILE, questions, withQuestion } from "./plan";
6
+ import type { WorktreeService } from "./worktree-service";
7
+
8
+ export interface EscalateOptions {
9
+ /** Task to escalate. Defaults to the one `cwd` is a worktree for. */
10
+ task?: string;
11
+ /**
12
+ * Ask a person to approve merging, rather than to answer questions: adds
13
+ * the question that asks it to the plan, with the reason as its detail.
14
+ * Refused while another question is unchecked.
15
+ */
16
+ review?: boolean;
17
+ }
18
+
19
+ /** The question a review asks. */
20
+ export const REVIEW_SUBJECT = "Ready to merge?";
21
+
22
+ /**
23
+ * The way out that is not "resolve the conflict badly to finish the task".
24
+ * When both sides restructured the same logic there is no correct merge, only
25
+ * a decision, and that belongs to a human.
26
+ */
27
+ export async function escalate(
28
+ {
29
+ service,
30
+ git,
31
+ lock,
32
+ log,
33
+ fs,
34
+ }: {
35
+ service: WorktreeService;
36
+ git: Git;
37
+ lock: Lock;
38
+ log: Logger;
39
+ fs: Fs;
40
+ },
41
+ reason: string,
42
+ cwd: string,
43
+ { task, review = false }: EscalateOptions = {},
44
+ ): Promise<void> {
45
+ const branch = await git.currentBranch(cwd).catch(() => "");
46
+ const name = task || service.taskFor(branch);
47
+ if (!name) {
48
+ fail(
49
+ "usage",
50
+ "not in a task worktree: pass --task <name> to escalate from elsewhere",
51
+ );
52
+ }
53
+ const found = await service.find(name);
54
+ if (!found.state) {
55
+ fail("not_found", `no state file for '${name}'`, { task: name });
56
+ }
57
+
58
+ let asked: string | undefined;
59
+ if (review) {
60
+ const path = `${found.path}/${PLAN_FILE}`;
61
+ const plan = await fs.read(path).catch(() => "");
62
+ const unchecked = questions(plan)
63
+ .filter((question) => !question.done)
64
+ .map((question) => question.number);
65
+ // A review is the one thing left: approve it and the task lands.
66
+ if (unchecked.length > 0) {
67
+ fail(
68
+ "blocked",
69
+ `'${name}' has questions ${unchecked.join(", ")} unchecked under ## Blocked: settle those first, or escalate without --review to ask them`,
70
+ { task: name, blocked: unchecked },
71
+ );
72
+ }
73
+ const added = withQuestion(plan, REVIEW_SUBJECT, reason);
74
+ await fs.write(path, added.plan);
75
+ asked = added.number;
76
+ }
77
+ const escalations = [
78
+ ...(found.state.escalations ?? []),
79
+ {
80
+ reason,
81
+ at: new Date().toISOString(),
82
+ ...(asked === undefined ? {} : { review: asked }),
83
+ },
84
+ ];
85
+ // The worktree is left exactly as it is: a human needs to see what the
86
+ // agent saw, conflict markers and all.
87
+ const worktree = await found.save({
88
+ status: "escalated",
89
+ lease: null,
90
+ escalations,
91
+ });
92
+ await lock.releaseIfOurs();
93
+
94
+ log.info(
95
+ [
96
+ `${color.yellow("escalated")} ${worktree.task}`,
97
+ ` worktree: ${worktree.path}`,
98
+ ` branch: ${worktree.branch}`,
99
+ ` reason: ${reason}`,
100
+ asked === undefined
101
+ ? ""
102
+ : ` review: question ${asked} asks to approve merging`,
103
+ escalations.length > 1
104
+ ? ` (${escalations.length} escalations recorded)`
105
+ : "",
106
+ "",
107
+ "Left untouched for a human. Stop working on this task.",
108
+ ]
109
+ .filter(Boolean)
110
+ .join("\n"),
111
+ );
112
+ }
package/exit.test.ts ADDED
@@ -0,0 +1,71 @@
1
+ import { beforeEach, describe, expect, it } from "bun:test";
2
+ import { MemoryLogger } from "webappwiz/log";
3
+ import { NodePs } from "webappwiz/system";
4
+ import { FakeProcess } from "webappwiz/system/testing";
5
+ import { EXIT, exits, fail } from "./exit";
6
+
7
+ it("keeps the exit codes stable", () => {
8
+ // pinned: these codes are arbor's API and an agent branches on them, so a
9
+ // renumbering has to be deliberate, and README.md's table has one source
10
+ expect(EXIT).toEqual({
11
+ usage: 1,
12
+ conflict: 2,
13
+ tests_failed: 3,
14
+ lease_lost: 4,
15
+ budget_exhausted: 5,
16
+ lease_held: 6,
17
+ dirty: 7,
18
+ not_found: 8,
19
+ hook_failed: 9,
20
+ exists: 10,
21
+ orphaned: 11,
22
+ merge_failed: 12,
23
+ already_removed: 13,
24
+ timeout: 14,
25
+ blocked: 16,
26
+ });
27
+ });
28
+
29
+ describe("exit", () => {
30
+ let process: FakeProcess;
31
+ let log: MemoryLogger;
32
+ let ps: NodePs;
33
+ let middleware: ReturnType<typeof exits>;
34
+
35
+ beforeEach(() => {
36
+ process = new FakeProcess();
37
+ log = new MemoryLogger();
38
+ ps = new NodePs({ proc: process });
39
+ middleware = exits();
40
+ });
41
+
42
+ it("turns a refusal into JSON, an explanation and a status code", async () => {
43
+ await middleware({ log, ps }, async () => {
44
+ fail("conflict", "rebase conflicted", { task: "alpha" });
45
+ });
46
+
47
+ expect(process.lastExit()).toBe(EXIT.conflict);
48
+ const [json, human] = log.entries;
49
+ expect(JSON.parse(String(json?.message))).toEqual({
50
+ reason: "conflict",
51
+ task: "alpha",
52
+ });
53
+ expect(String(human?.message)).toContain("rebase conflicted");
54
+ });
55
+
56
+ it("lets anything that is not a refusal through", async () => {
57
+ const boom = middleware({ log, ps }, async () => {
58
+ throw new Error("boom");
59
+ });
60
+
61
+ expect(boom).rejects.toThrow("boom");
62
+ expect(process.lastExit()).toBeUndefined();
63
+ });
64
+
65
+ it("leaves a command that succeeded alone", async () => {
66
+ await middleware({ log, ps }, async () => {});
67
+
68
+ expect(process.lastExit()).toBeUndefined();
69
+ expect(log.entries).toEqual([]);
70
+ });
71
+ });
package/exit.ts ADDED
@@ -0,0 +1,74 @@
1
+ import type { Deps, Middleware } from "webappwiz/cmd";
2
+
3
+ /** Exit codes are the API: an agent branches on these, not on prose. */
4
+ export const EXIT = {
5
+ // Stable by contract: keep them as they are, and keep README.md's table in
6
+ // sync.
7
+ usage: 1,
8
+ conflict: 2,
9
+ tests_failed: 3,
10
+ lease_lost: 4,
11
+ budget_exhausted: 5,
12
+ lease_held: 6,
13
+ dirty: 7,
14
+ not_found: 8,
15
+ hook_failed: 9,
16
+ exists: 10,
17
+ orphaned: 11,
18
+ merge_failed: 12,
19
+ already_removed: 13,
20
+ timeout: 14,
21
+ // 15 was `unread`, gone with the replies that waited to be read: never reuse it.
22
+ blocked: 16,
23
+ // 17 was `not_escalated`, gone with answering from the page: never reuse it.
24
+ } as const;
25
+
26
+ export type Reason = keyof typeof EXIT;
27
+
28
+ /**
29
+ * A refusal, carrying the reason to branch on, a message to show, and any
30
+ * data worth reporting with it. `exits` turns one into output and a status
31
+ * code.
32
+ */
33
+ export class Exit extends Error {
34
+ constructor(
35
+ readonly reason: Reason,
36
+ message: string,
37
+ readonly data: Record<string, unknown> = {},
38
+ ) {
39
+ super(message);
40
+ }
41
+ }
42
+
43
+ /**
44
+ * How a command refuses. Every call site means "and go no further", which is
45
+ * what the `never` return buys: the code below a `fail` is unreachable, and
46
+ * TypeScript narrows accordingly.
47
+ */
48
+ export function fail(
49
+ reason: Reason,
50
+ message: string,
51
+ data: Record<string, unknown> = {},
52
+ ): never {
53
+ throw new Exit(reason, message, data);
54
+ }
55
+
56
+ /**
57
+ * Decides what a refusal looks like (a machine-readable reason on stdout, a
58
+ * human explanation on stderr) and is the only place that ends the process.
59
+ * An agent branches on the first and reads the second.
60
+ */
61
+ export function exits<C extends Deps>(): Middleware<C> {
62
+ return async (ctx, next) => {
63
+ try {
64
+ await next(ctx);
65
+ } catch (e) {
66
+ if (!(e instanceof Exit)) {
67
+ throw e;
68
+ }
69
+ ctx.log.info(JSON.stringify({ reason: e.reason, ...e.data }));
70
+ ctx.log.error(e.message);
71
+ ctx.ps.exit(EXIT[e.reason]);
72
+ }
73
+ };
74
+ }
package/git.ts ADDED
@@ -0,0 +1,294 @@
1
+ import { type Fs, NodeFs, NodePs, type Ps } from "webappwiz/system";
2
+
3
+ export interface GitResult {
4
+ code: number;
5
+ stdout: string;
6
+ stderr: string;
7
+ }
8
+
9
+ /**
10
+ * Every git call arbor makes, scoped to one repository. Agents are not allowed
11
+ * to run these themselves, since going through arbor is what makes concurrent
12
+ * work safe, so this is the only place git is spoken.
13
+ */
14
+ /** What a `Git` works through; the real ones by default. */
15
+ export interface GitOptions {
16
+ ps?: Ps;
17
+ fs?: Fs;
18
+ }
19
+
20
+ export class Git {
21
+ private readonly ps: Ps;
22
+ private readonly fs: Fs;
23
+
24
+ constructor(
25
+ /** The main worktree, where trunk lives. */
26
+ readonly root: string,
27
+ opts: GitOptions = {},
28
+ ) {
29
+ this.ps = opts.ps ?? new NodePs();
30
+ this.fs = opts.fs ?? new NodeFs();
31
+ }
32
+
33
+ async run(cwd: string, ...args: string[]): Promise<GitResult> {
34
+ const { exitCode, stdout, stderr } = await this.ps.spawnCapture([
35
+ "git",
36
+ "-C",
37
+ cwd,
38
+ ...args,
39
+ ]);
40
+ return { code: exitCode, stdout: stdout.trim(), stderr: stderr.trim() };
41
+ }
42
+
43
+ async out(cwd: string, ...args: string[]): Promise<string> {
44
+ const result = await this.run(cwd, ...args);
45
+ if (result.code !== 0) {
46
+ throw new Error(
47
+ `git ${args.join(" ")}: ${result.stderr || result.stdout}`,
48
+ );
49
+ }
50
+ return result.stdout;
51
+ }
52
+
53
+ async branchExists(branch: string): Promise<boolean> {
54
+ const { code } = await this.run(
55
+ this.root,
56
+ "rev-parse",
57
+ "--verify",
58
+ "--quiet",
59
+ `refs/heads/${branch}`,
60
+ );
61
+ return code === 0;
62
+ }
63
+
64
+ /**
65
+ * The branch `origin/HEAD` points at, or null when the repo has no such ref:
66
+ * no remote, or a clone that never fetched it.
67
+ */
68
+ async defaultBranch(): Promise<string | null> {
69
+ const { code, stdout } = await this.run(
70
+ this.root,
71
+ "symbolic-ref",
72
+ "--short",
73
+ "refs/remotes/origin/HEAD",
74
+ );
75
+ return code === 0 ? stdout.replace(/^origin\//, "") || null : null;
76
+ }
77
+
78
+ currentBranch(cwd: string): Promise<string> {
79
+ return this.out(cwd, "rev-parse", "--abbrev-ref", "HEAD");
80
+ }
81
+
82
+ head(cwd: string): Promise<string> {
83
+ return this.out(cwd, "rev-parse", "HEAD");
84
+ }
85
+
86
+ shortHead(cwd: string): Promise<string> {
87
+ return this.out(cwd, "rev-parse", "--short", "HEAD");
88
+ }
89
+
90
+ async porcelain(cwd: string): Promise<string[]> {
91
+ return lines(await this.out(cwd, "status", "--porcelain"));
92
+ }
93
+
94
+ async conflictedPaths(cwd: string): Promise<string[]> {
95
+ const out = await this.out(
96
+ cwd,
97
+ "diff",
98
+ "--name-only",
99
+ "--diff-filter=U",
100
+ ).catch(() => "");
101
+ return lines(out);
102
+ }
103
+
104
+ async commitsAhead(trunk: string, branch: string): Promise<number | null> {
105
+ const result = await this.run(
106
+ this.root,
107
+ "rev-list",
108
+ "--count",
109
+ `${trunk}..${branch}`,
110
+ );
111
+ return result.code === 0 ? Number(result.stdout) : null;
112
+ }
113
+
114
+ /** Lines added and removed on `branch` since it left `trunk`. */
115
+ async diffStat(
116
+ trunk: string,
117
+ branch: string,
118
+ ): Promise<{ added: number; removed: number } | null> {
119
+ const result = await this.run(
120
+ this.root,
121
+ "diff",
122
+ "--shortstat",
123
+ `${trunk}...${branch}`,
124
+ );
125
+ if (result.code !== 0) {
126
+ return null;
127
+ }
128
+ const count = (pattern: RegExp): number =>
129
+ Number(pattern.exec(result.stdout)?.[1] ?? 0);
130
+ return {
131
+ added: count(/(\d+) insertion/),
132
+ removed: count(/(\d+) deletion/),
133
+ };
134
+ }
135
+
136
+ /**
137
+ * Every path `branch` touches since it left `trunk`, plus what is still
138
+ * uncommitted in `worktree` when there is one: an agent mid-task has usually
139
+ * committed nothing yet, and its edits are the overlap that matters most.
140
+ */
141
+ async changedFiles(
142
+ trunk: string,
143
+ branch: string,
144
+ worktree: string | null,
145
+ ): Promise<string[] | null> {
146
+ const committed = await this.run(
147
+ this.root,
148
+ "diff",
149
+ "--name-only",
150
+ `${trunk}...${branch}`,
151
+ );
152
+ if (committed.code !== 0) {
153
+ return null;
154
+ }
155
+ const paths = lines(committed.stdout);
156
+ if (worktree !== null) {
157
+ // Two calls rather than `status --porcelain`, whose leading status
158
+ // columns do not survive the trim every call here gets.
159
+ const edited = await this.run(worktree, "diff", "--name-only", "HEAD");
160
+ const added = await this.run(
161
+ worktree,
162
+ "ls-files",
163
+ "--others",
164
+ "--exclude-standard",
165
+ );
166
+ paths.push(...lines(edited.stdout), ...lines(added.stdout));
167
+ }
168
+ return [...new Set(paths)].sort();
169
+ }
170
+
171
+ /**
172
+ * Every path a person might point an agent at in the tree at `cwd`: the
173
+ * files git tracks, the new ones it does not ignore, and each directory
174
+ * above them, which ends in `/`. Sorted, relative to the tree's root.
175
+ */
176
+ async paths(cwd: string): Promise<string[]> {
177
+ const files = lines(
178
+ await this.out(
179
+ cwd,
180
+ "ls-files",
181
+ "--cached",
182
+ "--others",
183
+ "--exclude-standard",
184
+ ),
185
+ );
186
+ const found = new Set(files);
187
+ for (const file of files) {
188
+ const parts = file.split("/");
189
+ for (let depth = 1; depth < parts.length; depth++) {
190
+ found.add(`${parts.slice(0, depth).join("/")}/`);
191
+ }
192
+ }
193
+ return [...found].sort();
194
+ }
195
+
196
+ rebase(cwd: string, onto: string): Promise<GitResult> {
197
+ return this.run(cwd, "rebase", onto);
198
+ }
199
+
200
+ resetHard(cwd: string, commit: string): Promise<GitResult> {
201
+ return this.run(cwd, "reset", "--hard", commit);
202
+ }
203
+
204
+ checkout(branch: string): Promise<GitResult> {
205
+ return this.run(this.root, "checkout", branch);
206
+ }
207
+
208
+ mergeFfOnly(cwd: string, branch: string): Promise<GitResult> {
209
+ return this.run(cwd, "merge", "--ff-only", branch);
210
+ }
211
+
212
+ /**
213
+ * The worktree with `branch` checked out, or null when no worktree has it.
214
+ * Git allows one worktree per branch and that is the only place the branch
215
+ * can be advanced, so this is where a fast-forward has to happen.
216
+ */
217
+ async worktreeFor(branch: string): Promise<string | null> {
218
+ const listing = await this.out(
219
+ this.root,
220
+ "worktree",
221
+ "list",
222
+ "--porcelain",
223
+ );
224
+ let path: string | null = null;
225
+ for (const line of listing.split("\n")) {
226
+ if (line.startsWith("worktree ")) {
227
+ path = line.slice("worktree ".length);
228
+ } else if (line === `branch refs/heads/${branch}`) {
229
+ return path;
230
+ }
231
+ }
232
+ return null;
233
+ }
234
+
235
+ addWorktree(branch: string, path: string, from: string): Promise<GitResult> {
236
+ return this.run(this.root, "worktree", "add", "-b", branch, path, from);
237
+ }
238
+
239
+ removeWorktree(path: string): Promise<GitResult> {
240
+ return this.run(this.root, "worktree", "remove", path, "--force");
241
+ }
242
+
243
+ /** Drops metadata for worktrees whose directories are gone. */
244
+ pruneWorktrees(): Promise<GitResult> {
245
+ return this.run(this.root, "worktree", "prune");
246
+ }
247
+
248
+ deleteBranch(branch: string): Promise<GitResult> {
249
+ return this.run(this.root, "branch", "-D", branch);
250
+ }
251
+
252
+ /** The one `.git` every worktree of this repo shares. */
253
+ commonDir(): Promise<string> {
254
+ return this.out(
255
+ this.root,
256
+ "rev-parse",
257
+ "--path-format=absolute",
258
+ "--git-common-dir",
259
+ );
260
+ }
261
+
262
+ /** The git directory belonging to a worktree, or null if it has none. */
263
+ async worktreeGitDir(worktree: string): Promise<string | null> {
264
+ // Guessing this from the worktree's basename would be wrong: git dedupes
265
+ // those names, so the directory is not always named after the worktree.
266
+ const raw = await this.fs.read(`${worktree}/.git`).catch(() => null);
267
+ return raw?.trim().replace(/^gitdir:\s*/, "") ?? null;
268
+ }
269
+
270
+ /** Half-finished merges and rebases a resuming agent is standing in. */
271
+ async interruptedOps(worktree: string): Promise<string[]> {
272
+ const dir = await this.worktreeGitDir(worktree);
273
+ if (dir === null) {
274
+ return [];
275
+ }
276
+ const checks: Array<[string, string]> = [
277
+ ["rebase-merge", "rebase in progress"],
278
+ ["rebase-apply", "rebase in progress"],
279
+ ["MERGE_HEAD", "merge in progress"],
280
+ ["CHERRY_PICK_HEAD", "cherry-pick in progress"],
281
+ ];
282
+ const found: string[] = [];
283
+ for (const [entry, description] of checks) {
284
+ if (await this.fs.exists(`${dir}/${entry}`)) {
285
+ found.push(description);
286
+ }
287
+ }
288
+ return [...new Set(found)];
289
+ }
290
+ }
291
+
292
+ function lines(out: string): string[] {
293
+ return out === "" ? [] : out.split("\n");
294
+ }
package/index.ts ADDED
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env bun
2
+ import { NodeFs } from "webappwiz/system";
3
+ import { arbor } from "./arbor";
4
+ import { assets } from "./dev/assets";
5
+
6
+ await arbor.run({
7
+ fs: new NodeFs(),
8
+ assets,
9
+ });