@os-eco/overstory-cli 0.6.1

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 (170) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +381 -0
  3. package/agents/builder.md +137 -0
  4. package/agents/coordinator.md +263 -0
  5. package/agents/lead.md +301 -0
  6. package/agents/merger.md +160 -0
  7. package/agents/monitor.md +214 -0
  8. package/agents/reviewer.md +140 -0
  9. package/agents/scout.md +119 -0
  10. package/agents/supervisor.md +423 -0
  11. package/package.json +47 -0
  12. package/src/agents/checkpoint.test.ts +88 -0
  13. package/src/agents/checkpoint.ts +101 -0
  14. package/src/agents/hooks-deployer.test.ts +2040 -0
  15. package/src/agents/hooks-deployer.ts +607 -0
  16. package/src/agents/identity.test.ts +603 -0
  17. package/src/agents/identity.ts +384 -0
  18. package/src/agents/lifecycle.test.ts +196 -0
  19. package/src/agents/lifecycle.ts +183 -0
  20. package/src/agents/manifest.test.ts +746 -0
  21. package/src/agents/manifest.ts +354 -0
  22. package/src/agents/overlay.test.ts +676 -0
  23. package/src/agents/overlay.ts +308 -0
  24. package/src/beads/client.test.ts +217 -0
  25. package/src/beads/client.ts +202 -0
  26. package/src/beads/molecules.test.ts +338 -0
  27. package/src/beads/molecules.ts +198 -0
  28. package/src/commands/agents.test.ts +322 -0
  29. package/src/commands/agents.ts +287 -0
  30. package/src/commands/clean.test.ts +670 -0
  31. package/src/commands/clean.ts +618 -0
  32. package/src/commands/completions.test.ts +342 -0
  33. package/src/commands/completions.ts +887 -0
  34. package/src/commands/coordinator.test.ts +1530 -0
  35. package/src/commands/coordinator.ts +733 -0
  36. package/src/commands/costs.test.ts +1119 -0
  37. package/src/commands/costs.ts +564 -0
  38. package/src/commands/dashboard.test.ts +308 -0
  39. package/src/commands/dashboard.ts +838 -0
  40. package/src/commands/doctor.test.ts +294 -0
  41. package/src/commands/doctor.ts +213 -0
  42. package/src/commands/errors.test.ts +647 -0
  43. package/src/commands/errors.ts +248 -0
  44. package/src/commands/feed.test.ts +578 -0
  45. package/src/commands/feed.ts +361 -0
  46. package/src/commands/group.test.ts +262 -0
  47. package/src/commands/group.ts +511 -0
  48. package/src/commands/hooks.test.ts +458 -0
  49. package/src/commands/hooks.ts +253 -0
  50. package/src/commands/init.test.ts +347 -0
  51. package/src/commands/init.ts +650 -0
  52. package/src/commands/inspect.test.ts +670 -0
  53. package/src/commands/inspect.ts +431 -0
  54. package/src/commands/log.test.ts +1454 -0
  55. package/src/commands/log.ts +724 -0
  56. package/src/commands/logs.test.ts +379 -0
  57. package/src/commands/logs.ts +546 -0
  58. package/src/commands/mail.test.ts +1270 -0
  59. package/src/commands/mail.ts +771 -0
  60. package/src/commands/merge.test.ts +670 -0
  61. package/src/commands/merge.ts +355 -0
  62. package/src/commands/metrics.test.ts +444 -0
  63. package/src/commands/metrics.ts +143 -0
  64. package/src/commands/monitor.test.ts +191 -0
  65. package/src/commands/monitor.ts +390 -0
  66. package/src/commands/nudge.test.ts +230 -0
  67. package/src/commands/nudge.ts +372 -0
  68. package/src/commands/prime.test.ts +470 -0
  69. package/src/commands/prime.ts +381 -0
  70. package/src/commands/replay.test.ts +741 -0
  71. package/src/commands/replay.ts +360 -0
  72. package/src/commands/run.test.ts +431 -0
  73. package/src/commands/run.ts +351 -0
  74. package/src/commands/sling.test.ts +657 -0
  75. package/src/commands/sling.ts +661 -0
  76. package/src/commands/spec.test.ts +203 -0
  77. package/src/commands/spec.ts +168 -0
  78. package/src/commands/status.test.ts +430 -0
  79. package/src/commands/status.ts +398 -0
  80. package/src/commands/stop.test.ts +420 -0
  81. package/src/commands/stop.ts +151 -0
  82. package/src/commands/supervisor.test.ts +187 -0
  83. package/src/commands/supervisor.ts +535 -0
  84. package/src/commands/trace.test.ts +745 -0
  85. package/src/commands/trace.ts +325 -0
  86. package/src/commands/watch.test.ts +145 -0
  87. package/src/commands/watch.ts +247 -0
  88. package/src/commands/worktree.test.ts +786 -0
  89. package/src/commands/worktree.ts +311 -0
  90. package/src/config.test.ts +822 -0
  91. package/src/config.ts +829 -0
  92. package/src/doctor/agents.test.ts +454 -0
  93. package/src/doctor/agents.ts +396 -0
  94. package/src/doctor/config-check.test.ts +190 -0
  95. package/src/doctor/config-check.ts +183 -0
  96. package/src/doctor/consistency.test.ts +651 -0
  97. package/src/doctor/consistency.ts +294 -0
  98. package/src/doctor/databases.test.ts +290 -0
  99. package/src/doctor/databases.ts +218 -0
  100. package/src/doctor/dependencies.test.ts +184 -0
  101. package/src/doctor/dependencies.ts +175 -0
  102. package/src/doctor/logs.test.ts +251 -0
  103. package/src/doctor/logs.ts +295 -0
  104. package/src/doctor/merge-queue.test.ts +216 -0
  105. package/src/doctor/merge-queue.ts +144 -0
  106. package/src/doctor/structure.test.ts +291 -0
  107. package/src/doctor/structure.ts +198 -0
  108. package/src/doctor/types.ts +37 -0
  109. package/src/doctor/version.test.ts +136 -0
  110. package/src/doctor/version.ts +129 -0
  111. package/src/e2e/init-sling-lifecycle.test.ts +277 -0
  112. package/src/errors.ts +217 -0
  113. package/src/events/store.test.ts +660 -0
  114. package/src/events/store.ts +369 -0
  115. package/src/events/tool-filter.test.ts +330 -0
  116. package/src/events/tool-filter.ts +126 -0
  117. package/src/index.ts +316 -0
  118. package/src/insights/analyzer.test.ts +466 -0
  119. package/src/insights/analyzer.ts +203 -0
  120. package/src/logging/color.test.ts +142 -0
  121. package/src/logging/color.ts +71 -0
  122. package/src/logging/logger.test.ts +813 -0
  123. package/src/logging/logger.ts +266 -0
  124. package/src/logging/reporter.test.ts +259 -0
  125. package/src/logging/reporter.ts +109 -0
  126. package/src/logging/sanitizer.test.ts +190 -0
  127. package/src/logging/sanitizer.ts +57 -0
  128. package/src/mail/broadcast.test.ts +203 -0
  129. package/src/mail/broadcast.ts +92 -0
  130. package/src/mail/client.test.ts +773 -0
  131. package/src/mail/client.ts +223 -0
  132. package/src/mail/store.test.ts +705 -0
  133. package/src/mail/store.ts +387 -0
  134. package/src/merge/queue.test.ts +359 -0
  135. package/src/merge/queue.ts +231 -0
  136. package/src/merge/resolver.test.ts +1345 -0
  137. package/src/merge/resolver.ts +645 -0
  138. package/src/metrics/store.test.ts +667 -0
  139. package/src/metrics/store.ts +445 -0
  140. package/src/metrics/summary.test.ts +398 -0
  141. package/src/metrics/summary.ts +178 -0
  142. package/src/metrics/transcript.test.ts +356 -0
  143. package/src/metrics/transcript.ts +175 -0
  144. package/src/mulch/client.test.ts +671 -0
  145. package/src/mulch/client.ts +332 -0
  146. package/src/sessions/compat.test.ts +280 -0
  147. package/src/sessions/compat.ts +104 -0
  148. package/src/sessions/store.test.ts +873 -0
  149. package/src/sessions/store.ts +494 -0
  150. package/src/test-helpers.test.ts +124 -0
  151. package/src/test-helpers.ts +126 -0
  152. package/src/tracker/beads.ts +56 -0
  153. package/src/tracker/factory.test.ts +80 -0
  154. package/src/tracker/factory.ts +64 -0
  155. package/src/tracker/seeds.ts +182 -0
  156. package/src/tracker/types.ts +52 -0
  157. package/src/types.ts +724 -0
  158. package/src/watchdog/daemon.test.ts +1975 -0
  159. package/src/watchdog/daemon.ts +671 -0
  160. package/src/watchdog/health.test.ts +431 -0
  161. package/src/watchdog/health.ts +264 -0
  162. package/src/watchdog/triage.test.ts +164 -0
  163. package/src/watchdog/triage.ts +179 -0
  164. package/src/worktree/manager.test.ts +439 -0
  165. package/src/worktree/manager.ts +198 -0
  166. package/src/worktree/tmux.test.ts +1009 -0
  167. package/src/worktree/tmux.ts +509 -0
  168. package/templates/CLAUDE.md.tmpl +89 -0
  169. package/templates/hooks.json.tmpl +105 -0
  170. package/templates/overlay.md.tmpl +81 -0
@@ -0,0 +1,439 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { existsSync, realpathSync } from "node:fs";
3
+ import { mkdir, mkdtemp } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { WorktreeError } from "../errors.ts";
7
+ import {
8
+ cleanupTempDir,
9
+ commitFile,
10
+ createTempGitRepo,
11
+ getDefaultBranch,
12
+ } from "../test-helpers.ts";
13
+ import { createWorktree, isBranchMerged, listWorktrees, removeWorktree } from "./manager.ts";
14
+
15
+ /**
16
+ * Run a git command in a directory and return stdout. Throws on non-zero exit.
17
+ */
18
+ async function git(cwd: string, args: string[]): Promise<string> {
19
+ const proc = Bun.spawn(["git", ...args], {
20
+ cwd,
21
+ stdout: "pipe",
22
+ stderr: "pipe",
23
+ });
24
+
25
+ const [stdout, stderr, exitCode] = await Promise.all([
26
+ new Response(proc.stdout).text(),
27
+ new Response(proc.stderr).text(),
28
+ proc.exited,
29
+ ]);
30
+
31
+ if (exitCode !== 0) {
32
+ throw new Error(`git ${args.join(" ")} failed (exit ${exitCode}): ${stderr.trim()}`);
33
+ }
34
+
35
+ return stdout;
36
+ }
37
+
38
+ describe("createWorktree", () => {
39
+ let repoDir: string;
40
+ let worktreesDir: string;
41
+ let defaultBranch: string;
42
+
43
+ beforeEach(async () => {
44
+ // realpathSync resolves macOS /var -> /private/var symlink so paths match git output
45
+ repoDir = realpathSync(await createTempGitRepo());
46
+ defaultBranch = await getDefaultBranch(repoDir);
47
+ worktreesDir = join(repoDir, ".overstory", "worktrees");
48
+ await mkdir(worktreesDir, { recursive: true });
49
+ });
50
+
51
+ afterEach(async () => {
52
+ await cleanupTempDir(repoDir);
53
+ });
54
+
55
+ test("returns correct path and branch name", async () => {
56
+ const result = await createWorktree({
57
+ repoRoot: repoDir,
58
+ baseDir: worktreesDir,
59
+ agentName: "auth-login",
60
+ baseBranch: defaultBranch,
61
+ beadId: "bead-abc123",
62
+ });
63
+
64
+ expect(result.path).toBe(join(worktreesDir, "auth-login"));
65
+ expect(result.branch).toBe("overstory/auth-login/bead-abc123");
66
+ });
67
+
68
+ test("creates worktree directory on disk", async () => {
69
+ const result = await createWorktree({
70
+ repoRoot: repoDir,
71
+ baseDir: worktreesDir,
72
+ agentName: "auth-login",
73
+ baseBranch: defaultBranch,
74
+ beadId: "bead-abc123",
75
+ });
76
+
77
+ expect(existsSync(result.path)).toBe(true);
78
+ // The worktree should contain a .git file (not a directory, since it's a linked worktree)
79
+ expect(existsSync(join(result.path, ".git"))).toBe(true);
80
+ });
81
+
82
+ test("creates the branch in the repo", async () => {
83
+ await createWorktree({
84
+ repoRoot: repoDir,
85
+ baseDir: worktreesDir,
86
+ agentName: "auth-login",
87
+ baseBranch: defaultBranch,
88
+ beadId: "bead-abc123",
89
+ });
90
+
91
+ const branchList = await git(repoDir, ["branch", "--list"]);
92
+ expect(branchList).toContain("overstory/auth-login/bead-abc123");
93
+ });
94
+
95
+ test("throws WorktreeError when creating same worktree twice", async () => {
96
+ await createWorktree({
97
+ repoRoot: repoDir,
98
+ baseDir: worktreesDir,
99
+ agentName: "auth-login",
100
+ baseBranch: defaultBranch,
101
+ beadId: "bead-abc123",
102
+ });
103
+
104
+ await expect(
105
+ createWorktree({
106
+ repoRoot: repoDir,
107
+ baseDir: worktreesDir,
108
+ agentName: "auth-login",
109
+ baseBranch: defaultBranch,
110
+ beadId: "bead-abc123",
111
+ }),
112
+ ).rejects.toThrow(WorktreeError);
113
+ });
114
+
115
+ test("WorktreeError includes worktree path and branch name", async () => {
116
+ // Create once to occupy the branch name
117
+ await createWorktree({
118
+ repoRoot: repoDir,
119
+ baseDir: worktreesDir,
120
+ agentName: "auth-login",
121
+ baseBranch: defaultBranch,
122
+ beadId: "bead-abc123",
123
+ });
124
+
125
+ try {
126
+ await createWorktree({
127
+ repoRoot: repoDir,
128
+ baseDir: worktreesDir,
129
+ agentName: "auth-login",
130
+ baseBranch: defaultBranch,
131
+ beadId: "bead-abc123",
132
+ });
133
+ // Should not reach here
134
+ expect(true).toBe(false);
135
+ } catch (err: unknown) {
136
+ expect(err).toBeInstanceOf(WorktreeError);
137
+ const wtErr = err as WorktreeError;
138
+ expect(wtErr.worktreePath).toBe(join(worktreesDir, "auth-login"));
139
+ expect(wtErr.branchName).toBe("overstory/auth-login/bead-abc123");
140
+ }
141
+ });
142
+ });
143
+
144
+ describe("listWorktrees", () => {
145
+ let repoDir: string;
146
+ let worktreesDir: string;
147
+ let defaultBranch: string;
148
+
149
+ beforeEach(async () => {
150
+ repoDir = realpathSync(await createTempGitRepo());
151
+ defaultBranch = await getDefaultBranch(repoDir);
152
+ worktreesDir = join(repoDir, ".overstory", "worktrees");
153
+ await mkdir(worktreesDir, { recursive: true });
154
+ });
155
+
156
+ afterEach(async () => {
157
+ await cleanupTempDir(repoDir);
158
+ });
159
+
160
+ test("lists main worktree when no additional worktrees exist", async () => {
161
+ const entries = await listWorktrees(repoDir);
162
+
163
+ expect(entries.length).toBeGreaterThanOrEqual(1);
164
+ // The first entry should be the main repo
165
+ const mainEntry = entries[0];
166
+ expect(mainEntry?.path).toBe(repoDir);
167
+ expect(mainEntry?.branch).toMatch(/^(main|master)$/);
168
+ expect(mainEntry?.head).toMatch(/^[a-f0-9]{40}$/);
169
+ });
170
+
171
+ test("lists multiple worktrees after creation", async () => {
172
+ await createWorktree({
173
+ repoRoot: repoDir,
174
+ baseDir: worktreesDir,
175
+ agentName: "auth-login",
176
+ baseBranch: defaultBranch,
177
+ beadId: "bead-abc",
178
+ });
179
+
180
+ await createWorktree({
181
+ repoRoot: repoDir,
182
+ baseDir: worktreesDir,
183
+ agentName: "data-sync",
184
+ baseBranch: defaultBranch,
185
+ beadId: "bead-xyz",
186
+ });
187
+
188
+ const entries = await listWorktrees(repoDir);
189
+
190
+ // Main worktree + 2 created = 3
191
+ expect(entries).toHaveLength(3);
192
+
193
+ const paths = entries.map((e) => e.path);
194
+ expect(paths).toContain(repoDir);
195
+ expect(paths).toContain(join(worktreesDir, "auth-login"));
196
+ expect(paths).toContain(join(worktreesDir, "data-sync"));
197
+
198
+ const branches = entries.map((e) => e.branch);
199
+ expect(branches).toContain("overstory/auth-login/bead-abc");
200
+ expect(branches).toContain("overstory/data-sync/bead-xyz");
201
+ });
202
+
203
+ test("strips refs/heads/ prefix from branch names", async () => {
204
+ await createWorktree({
205
+ repoRoot: repoDir,
206
+ baseDir: worktreesDir,
207
+ agentName: "feature-worker",
208
+ baseBranch: defaultBranch,
209
+ beadId: "bead-123",
210
+ });
211
+
212
+ const entries = await listWorktrees(repoDir);
213
+ const worktreeEntry = entries.find((e) => e.path === join(worktreesDir, "feature-worker"));
214
+
215
+ expect(worktreeEntry?.branch).toBe("overstory/feature-worker/bead-123");
216
+ // Ensure no refs/heads/ prefix leaked through
217
+ expect(worktreeEntry?.branch).not.toContain("refs/heads/");
218
+ });
219
+
220
+ test("each entry has a valid HEAD commit hash", async () => {
221
+ await createWorktree({
222
+ repoRoot: repoDir,
223
+ baseDir: worktreesDir,
224
+ agentName: "auth-login",
225
+ baseBranch: defaultBranch,
226
+ beadId: "bead-abc",
227
+ });
228
+
229
+ const entries = await listWorktrees(repoDir);
230
+
231
+ for (const entry of entries) {
232
+ expect(entry.head).toMatch(/^[a-f0-9]{40}$/);
233
+ }
234
+ });
235
+
236
+ test("throws WorktreeError for non-git directory", async () => {
237
+ // Use a separate temp dir outside the git repo so git won't find a parent .git
238
+ const tmpDir = realpathSync(await mkdtemp(join(tmpdir(), "overstory-notgit-")));
239
+ try {
240
+ await expect(listWorktrees(tmpDir)).rejects.toThrow(WorktreeError);
241
+ } finally {
242
+ await cleanupTempDir(tmpDir);
243
+ }
244
+ });
245
+ });
246
+
247
+ describe("isBranchMerged", () => {
248
+ let repoDir: string;
249
+ let worktreesDir: string;
250
+ let defaultBranch: string;
251
+
252
+ beforeEach(async () => {
253
+ repoDir = realpathSync(await createTempGitRepo());
254
+ defaultBranch = await getDefaultBranch(repoDir);
255
+ worktreesDir = join(repoDir, ".overstory", "worktrees");
256
+ await mkdir(worktreesDir, { recursive: true });
257
+ });
258
+
259
+ afterEach(async () => {
260
+ await cleanupTempDir(repoDir);
261
+ });
262
+
263
+ test("returns true for a branch that has been merged via git merge", async () => {
264
+ const { path: wtPath, branch } = await createWorktree({
265
+ repoRoot: repoDir,
266
+ baseDir: worktreesDir,
267
+ agentName: "feature-agent",
268
+ baseBranch: defaultBranch,
269
+ beadId: "bead-merged",
270
+ });
271
+
272
+ // Add a commit to the feature branch
273
+ await commitFile(wtPath, "feature.ts", "export const x = 1;", "add feature");
274
+
275
+ // Merge the feature branch into defaultBranch
276
+ await git(repoDir, ["merge", "--no-ff", branch, "-m", "merge feature"]);
277
+
278
+ const merged = await isBranchMerged(repoDir, branch, defaultBranch);
279
+ expect(merged).toBe(true);
280
+ });
281
+
282
+ test("returns false for a branch with unmerged commits", async () => {
283
+ const { path: wtPath, branch } = await createWorktree({
284
+ repoRoot: repoDir,
285
+ baseDir: worktreesDir,
286
+ agentName: "feature-agent",
287
+ baseBranch: defaultBranch,
288
+ beadId: "bead-unmerged",
289
+ });
290
+
291
+ // Add a commit to the feature branch (not merged)
292
+ await commitFile(wtPath, "feature.ts", "export const x = 1;", "add feature");
293
+
294
+ const merged = await isBranchMerged(repoDir, branch, defaultBranch);
295
+ expect(merged).toBe(false);
296
+ });
297
+
298
+ test("returns true for an identical branch (same commit, no additional commits)", async () => {
299
+ // A freshly created worktree branch has the same HEAD as the base branch
300
+ const { branch } = await createWorktree({
301
+ repoRoot: repoDir,
302
+ baseDir: worktreesDir,
303
+ agentName: "feature-agent",
304
+ baseBranch: defaultBranch,
305
+ beadId: "bead-same",
306
+ });
307
+
308
+ // The branch was created from defaultBranch with no additional commits,
309
+ // so its tip is an ancestor of (equal to) defaultBranch
310
+ const merged = await isBranchMerged(repoDir, branch, defaultBranch);
311
+ expect(merged).toBe(true);
312
+ });
313
+ });
314
+
315
+ describe("removeWorktree", () => {
316
+ let repoDir: string;
317
+ let worktreesDir: string;
318
+ let defaultBranch: string;
319
+
320
+ beforeEach(async () => {
321
+ repoDir = realpathSync(await createTempGitRepo());
322
+ defaultBranch = await getDefaultBranch(repoDir);
323
+ worktreesDir = join(repoDir, ".overstory", "worktrees");
324
+ await mkdir(worktreesDir, { recursive: true });
325
+ });
326
+
327
+ afterEach(async () => {
328
+ await cleanupTempDir(repoDir);
329
+ });
330
+
331
+ test("removes worktree directory from disk", async () => {
332
+ const { path: wtPath } = await createWorktree({
333
+ repoRoot: repoDir,
334
+ baseDir: worktreesDir,
335
+ agentName: "auth-login",
336
+ baseBranch: defaultBranch,
337
+ beadId: "bead-abc",
338
+ });
339
+
340
+ expect(existsSync(wtPath)).toBe(true);
341
+
342
+ await removeWorktree(repoDir, wtPath);
343
+
344
+ expect(existsSync(wtPath)).toBe(false);
345
+ });
346
+
347
+ test("deletes the associated branch after removal", async () => {
348
+ const { path: wtPath } = await createWorktree({
349
+ repoRoot: repoDir,
350
+ baseDir: worktreesDir,
351
+ agentName: "auth-login",
352
+ baseBranch: defaultBranch,
353
+ beadId: "bead-abc",
354
+ });
355
+
356
+ await removeWorktree(repoDir, wtPath);
357
+
358
+ const branchList = await git(repoDir, ["branch", "--list"]);
359
+ expect(branchList).not.toContain("overstory/auth-login/bead-abc");
360
+ });
361
+
362
+ test("worktree no longer appears in listWorktrees after removal", async () => {
363
+ const { path: wtPath } = await createWorktree({
364
+ repoRoot: repoDir,
365
+ baseDir: worktreesDir,
366
+ agentName: "auth-login",
367
+ baseBranch: defaultBranch,
368
+ beadId: "bead-abc",
369
+ });
370
+
371
+ await removeWorktree(repoDir, wtPath);
372
+
373
+ const entries = await listWorktrees(repoDir);
374
+ const paths = entries.map((e) => e.path);
375
+ expect(paths).not.toContain(wtPath);
376
+ });
377
+
378
+ test("force flag removes worktree with uncommitted changes", async () => {
379
+ const { path: wtPath } = await createWorktree({
380
+ repoRoot: repoDir,
381
+ baseDir: worktreesDir,
382
+ agentName: "auth-login",
383
+ baseBranch: defaultBranch,
384
+ beadId: "bead-abc",
385
+ });
386
+
387
+ // Create an untracked file in the worktree
388
+ await Bun.write(join(wtPath, "untracked.txt"), "some content");
389
+
390
+ // Without force, git worktree remove may fail on dirty worktrees.
391
+ // With force, it should succeed.
392
+ await removeWorktree(repoDir, wtPath, { force: true, forceBranch: true });
393
+
394
+ expect(existsSync(wtPath)).toBe(false);
395
+ });
396
+
397
+ test("forceBranch deletes unmerged branch", async () => {
398
+ const { path: wtPath } = await createWorktree({
399
+ repoRoot: repoDir,
400
+ baseDir: worktreesDir,
401
+ agentName: "auth-login",
402
+ baseBranch: defaultBranch,
403
+ beadId: "bead-abc",
404
+ });
405
+
406
+ // Add a commit in the worktree so the branch diverges (making it "unmerged")
407
+ await commitFile(wtPath, "new-file.ts", "export const x = 1;", "add new file");
408
+
409
+ // forceBranch uses -D instead of -d, so even unmerged branches get deleted
410
+ await removeWorktree(repoDir, wtPath, { force: true, forceBranch: true });
411
+
412
+ const branchList = await git(repoDir, ["branch", "--list"]);
413
+ expect(branchList).not.toContain("overstory/auth-login/bead-abc");
414
+ });
415
+
416
+ test("without forceBranch, unmerged branch deletion is silently ignored", async () => {
417
+ const { path: wtPath } = await createWorktree({
418
+ repoRoot: repoDir,
419
+ baseDir: worktreesDir,
420
+ agentName: "auth-login",
421
+ baseBranch: defaultBranch,
422
+ beadId: "bead-abc",
423
+ });
424
+
425
+ // Add a commit to make the branch unmerged
426
+ await commitFile(wtPath, "new-file.ts", "export const x = 1;", "add new file");
427
+
428
+ // Without forceBranch, branch -d will fail because it's not merged, but
429
+ // removeWorktree should not throw (it catches the error)
430
+ await removeWorktree(repoDir, wtPath, { force: true });
431
+
432
+ // Worktree is gone
433
+ expect(existsSync(wtPath)).toBe(false);
434
+
435
+ // But branch still exists because -d failed silently
436
+ const branchList = await git(repoDir, ["branch", "--list"]);
437
+ expect(branchList).toContain("overstory/auth-login/bead-abc");
438
+ });
439
+ });
@@ -0,0 +1,198 @@
1
+ import { join } from "node:path";
2
+ import { WorktreeError } from "../errors.ts";
3
+
4
+ /**
5
+ * Run a git command and return stdout. Throws WorktreeError on non-zero exit.
6
+ */
7
+ async function runGit(
8
+ repoRoot: string,
9
+ args: string[],
10
+ context?: { worktreePath?: string; branchName?: string },
11
+ ): Promise<string> {
12
+ const proc = Bun.spawn(["git", ...args], {
13
+ cwd: repoRoot,
14
+ stdout: "pipe",
15
+ stderr: "pipe",
16
+ });
17
+
18
+ const [stdout, stderr, exitCode] = await Promise.all([
19
+ new Response(proc.stdout).text(),
20
+ new Response(proc.stderr).text(),
21
+ proc.exited,
22
+ ]);
23
+
24
+ if (exitCode !== 0) {
25
+ throw new WorktreeError(
26
+ `git ${args.join(" ")} failed (exit ${exitCode}): ${stderr.trim() || stdout.trim()}`,
27
+ {
28
+ worktreePath: context?.worktreePath,
29
+ branchName: context?.branchName,
30
+ },
31
+ );
32
+ }
33
+
34
+ return stdout;
35
+ }
36
+
37
+ /**
38
+ * Create a new git worktree for an agent.
39
+ *
40
+ * Creates a worktree at `{baseDir}/{agentName}` with a new branch
41
+ * named `overstory/{agentName}/{beadId}` based on `baseBranch`.
42
+ *
43
+ * @returns The absolute worktree path and branch name.
44
+ */
45
+ export async function createWorktree(options: {
46
+ repoRoot: string;
47
+ baseDir: string;
48
+ agentName: string;
49
+ baseBranch: string;
50
+ beadId: string;
51
+ }): Promise<{ path: string; branch: string }> {
52
+ const { repoRoot, baseDir, agentName, baseBranch, beadId } = options;
53
+
54
+ const worktreePath = join(baseDir, agentName);
55
+ const branchName = `overstory/${agentName}/${beadId}`;
56
+
57
+ await runGit(repoRoot, ["worktree", "add", "-b", branchName, worktreePath, baseBranch], {
58
+ worktreePath,
59
+ branchName,
60
+ });
61
+
62
+ return { path: worktreePath, branch: branchName };
63
+ }
64
+
65
+ /**
66
+ * Parsed representation of a single worktree entry from `git worktree list --porcelain`.
67
+ */
68
+ interface WorktreeEntry {
69
+ path: string;
70
+ branch: string;
71
+ head: string;
72
+ }
73
+
74
+ /**
75
+ * Parse the output of `git worktree list --porcelain` into structured entries.
76
+ *
77
+ * Porcelain format example:
78
+ * ```
79
+ * worktree /path/to/main
80
+ * HEAD abc123
81
+ * branch refs/heads/main
82
+ *
83
+ * worktree /path/to/wt
84
+ * HEAD def456
85
+ * branch refs/heads/overstory/agent/bead
86
+ * ```
87
+ */
88
+ function parseWorktreeOutput(output: string): WorktreeEntry[] {
89
+ const entries: WorktreeEntry[] = [];
90
+ const blocks = output.trim().split("\n\n");
91
+
92
+ for (const block of blocks) {
93
+ if (block.trim() === "") continue;
94
+
95
+ let path = "";
96
+ let head = "";
97
+ let branch = "";
98
+
99
+ const lines = block.trim().split("\n");
100
+ for (const line of lines) {
101
+ if (line.startsWith("worktree ")) {
102
+ path = line.slice("worktree ".length);
103
+ } else if (line.startsWith("HEAD ")) {
104
+ head = line.slice("HEAD ".length);
105
+ } else if (line.startsWith("branch ")) {
106
+ // Strip refs/heads/ prefix to get the short branch name
107
+ const ref = line.slice("branch ".length);
108
+ branch = ref.replace(/^refs\/heads\//, "");
109
+ }
110
+ }
111
+
112
+ if (path.length > 0) {
113
+ entries.push({ path, head, branch });
114
+ }
115
+ }
116
+
117
+ return entries;
118
+ }
119
+
120
+ /**
121
+ * List all git worktrees in the repository.
122
+ *
123
+ * @returns Array of worktree entries with path, branch name, and HEAD commit.
124
+ */
125
+ export async function listWorktrees(
126
+ repoRoot: string,
127
+ ): Promise<Array<{ path: string; branch: string; head: string }>> {
128
+ const stdout = await runGit(repoRoot, ["worktree", "list", "--porcelain"]);
129
+ return parseWorktreeOutput(stdout);
130
+ }
131
+
132
+ /**
133
+ * Check if a branch has been merged into a target branch.
134
+ * Uses `git merge-base --is-ancestor` which returns exit 0 if merged, 1 if not.
135
+ */
136
+ export async function isBranchMerged(
137
+ repoRoot: string,
138
+ branch: string,
139
+ targetBranch: string,
140
+ ): Promise<boolean> {
141
+ const proc = Bun.spawn(["git", "merge-base", "--is-ancestor", branch, targetBranch], {
142
+ cwd: repoRoot,
143
+ stdout: "pipe",
144
+ stderr: "pipe",
145
+ });
146
+
147
+ const [stderr, exitCode] = await Promise.all([new Response(proc.stderr).text(), proc.exited]);
148
+
149
+ if (exitCode === 0) return true;
150
+ if (exitCode === 1) return false;
151
+
152
+ throw new WorktreeError(
153
+ `git merge-base --is-ancestor failed (exit ${exitCode}): ${stderr.trim()}`,
154
+ { branchName: branch },
155
+ );
156
+ }
157
+
158
+ /**
159
+ * Remove a git worktree and delete its associated branch.
160
+ *
161
+ * Runs `git worktree remove {path}` to remove the worktree, then
162
+ * deletes the branch. With `forceBranch: true`, uses `git branch -D`
163
+ * to force-delete even unmerged branches. Otherwise uses `git branch -d`
164
+ * which only deletes merged branches.
165
+ */
166
+ export async function removeWorktree(
167
+ repoRoot: string,
168
+ path: string,
169
+ options?: { force?: boolean; forceBranch?: boolean },
170
+ ): Promise<void> {
171
+ // First, figure out which branch this worktree is on so we can clean it up
172
+ const worktrees = await listWorktrees(repoRoot);
173
+ const entry = worktrees.find((wt) => wt.path === path);
174
+ const branchName = entry?.branch ?? "";
175
+
176
+ // Remove the worktree (--force handles untracked files and uncommitted changes)
177
+ const removeArgs = ["worktree", "remove", path];
178
+ if (options?.force) {
179
+ removeArgs.push("--force");
180
+ }
181
+ await runGit(repoRoot, removeArgs, {
182
+ worktreePath: path,
183
+ branchName,
184
+ });
185
+
186
+ // Delete the associated branch after worktree removal.
187
+ // Use -D (force) when forceBranch is set, since the branch may not have
188
+ // been merged yet. Use -d (safe) otherwise, which only deletes merged branches.
189
+ if (branchName.length > 0) {
190
+ const deleteFlag = options?.forceBranch ? "-D" : "-d";
191
+ try {
192
+ await runGit(repoRoot, ["branch", deleteFlag, branchName], { branchName });
193
+ } catch {
194
+ // Branch deletion failed — may be unmerged (with -d) or checked out elsewhere.
195
+ // This is best-effort; the worktree itself is already removed.
196
+ }
197
+ }
198
+ }