@katyella/legio 0.1.0

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 (219) hide show
  1. package/CHANGELOG.md +422 -0
  2. package/LICENSE +21 -0
  3. package/README.md +555 -0
  4. package/agents/builder.md +141 -0
  5. package/agents/coordinator.md +351 -0
  6. package/agents/cto.md +196 -0
  7. package/agents/gateway.md +276 -0
  8. package/agents/lead.md +281 -0
  9. package/agents/merger.md +156 -0
  10. package/agents/monitor.md +212 -0
  11. package/agents/reviewer.md +142 -0
  12. package/agents/scout.md +131 -0
  13. package/agents/supervisor.md +416 -0
  14. package/bin/legio.mjs +38 -0
  15. package/package.json +77 -0
  16. package/src/agents/checkpoint.test.ts +88 -0
  17. package/src/agents/checkpoint.ts +102 -0
  18. package/src/agents/hooks-deployer.test.ts +1820 -0
  19. package/src/agents/hooks-deployer.ts +574 -0
  20. package/src/agents/identity.test.ts +614 -0
  21. package/src/agents/identity.ts +385 -0
  22. package/src/agents/lifecycle.test.ts +202 -0
  23. package/src/agents/lifecycle.ts +184 -0
  24. package/src/agents/manifest.test.ts +558 -0
  25. package/src/agents/manifest.ts +297 -0
  26. package/src/agents/overlay.test.ts +592 -0
  27. package/src/agents/overlay.ts +316 -0
  28. package/src/beads/client.test.ts +210 -0
  29. package/src/beads/client.ts +227 -0
  30. package/src/beads/molecules.test.ts +320 -0
  31. package/src/beads/molecules.ts +209 -0
  32. package/src/commands/agents.test.ts +325 -0
  33. package/src/commands/agents.ts +286 -0
  34. package/src/commands/clean.test.ts +730 -0
  35. package/src/commands/clean.ts +653 -0
  36. package/src/commands/completions.test.ts +346 -0
  37. package/src/commands/completions.ts +950 -0
  38. package/src/commands/coordinator.test.ts +1524 -0
  39. package/src/commands/coordinator.ts +880 -0
  40. package/src/commands/costs.test.ts +1015 -0
  41. package/src/commands/costs.ts +473 -0
  42. package/src/commands/dashboard.test.ts +94 -0
  43. package/src/commands/dashboard.ts +607 -0
  44. package/src/commands/doctor.test.ts +295 -0
  45. package/src/commands/doctor.ts +213 -0
  46. package/src/commands/down.test.ts +308 -0
  47. package/src/commands/down.ts +124 -0
  48. package/src/commands/errors.test.ts +648 -0
  49. package/src/commands/errors.ts +255 -0
  50. package/src/commands/feed.test.ts +579 -0
  51. package/src/commands/feed.ts +368 -0
  52. package/src/commands/gateway.test.ts +698 -0
  53. package/src/commands/gateway.ts +419 -0
  54. package/src/commands/group.test.ts +262 -0
  55. package/src/commands/group.ts +539 -0
  56. package/src/commands/hooks.test.ts +292 -0
  57. package/src/commands/hooks.ts +210 -0
  58. package/src/commands/init.test.ts +211 -0
  59. package/src/commands/init.ts +622 -0
  60. package/src/commands/inspect.test.ts +670 -0
  61. package/src/commands/inspect.ts +455 -0
  62. package/src/commands/log.test.ts +1556 -0
  63. package/src/commands/log.ts +752 -0
  64. package/src/commands/logs.test.ts +379 -0
  65. package/src/commands/logs.ts +544 -0
  66. package/src/commands/mail.test.ts +1726 -0
  67. package/src/commands/mail.ts +926 -0
  68. package/src/commands/merge.test.ts +676 -0
  69. package/src/commands/merge.ts +374 -0
  70. package/src/commands/metrics.test.ts +444 -0
  71. package/src/commands/metrics.ts +150 -0
  72. package/src/commands/monitor.test.ts +151 -0
  73. package/src/commands/monitor.ts +394 -0
  74. package/src/commands/nudge.test.ts +230 -0
  75. package/src/commands/nudge.ts +373 -0
  76. package/src/commands/prime.test.ts +467 -0
  77. package/src/commands/prime.ts +386 -0
  78. package/src/commands/replay.test.ts +742 -0
  79. package/src/commands/replay.ts +367 -0
  80. package/src/commands/run.test.ts +443 -0
  81. package/src/commands/run.ts +365 -0
  82. package/src/commands/server.test.ts +626 -0
  83. package/src/commands/server.ts +298 -0
  84. package/src/commands/sling.test.ts +810 -0
  85. package/src/commands/sling.ts +700 -0
  86. package/src/commands/spec.test.ts +206 -0
  87. package/src/commands/spec.ts +171 -0
  88. package/src/commands/status.test.ts +276 -0
  89. package/src/commands/status.ts +339 -0
  90. package/src/commands/stop.test.ts +357 -0
  91. package/src/commands/stop.ts +119 -0
  92. package/src/commands/supervisor.test.ts +186 -0
  93. package/src/commands/supervisor.ts +544 -0
  94. package/src/commands/trace.test.ts +746 -0
  95. package/src/commands/trace.ts +332 -0
  96. package/src/commands/up.test.ts +597 -0
  97. package/src/commands/up.ts +275 -0
  98. package/src/commands/watch.test.ts +152 -0
  99. package/src/commands/watch.ts +238 -0
  100. package/src/commands/worktree.test.ts +648 -0
  101. package/src/commands/worktree.ts +266 -0
  102. package/src/config.test.ts +496 -0
  103. package/src/config.ts +616 -0
  104. package/src/doctor/agents.test.ts +448 -0
  105. package/src/doctor/agents.ts +396 -0
  106. package/src/doctor/config-check.test.ts +184 -0
  107. package/src/doctor/config-check.ts +185 -0
  108. package/src/doctor/consistency.test.ts +645 -0
  109. package/src/doctor/consistency.ts +294 -0
  110. package/src/doctor/databases.test.ts +284 -0
  111. package/src/doctor/databases.ts +211 -0
  112. package/src/doctor/dependencies.test.ts +150 -0
  113. package/src/doctor/dependencies.ts +179 -0
  114. package/src/doctor/logs.test.ts +244 -0
  115. package/src/doctor/logs.ts +295 -0
  116. package/src/doctor/merge-queue.test.ts +210 -0
  117. package/src/doctor/merge-queue.ts +144 -0
  118. package/src/doctor/structure.test.ts +285 -0
  119. package/src/doctor/structure.ts +195 -0
  120. package/src/doctor/types.ts +37 -0
  121. package/src/doctor/version.test.ts +130 -0
  122. package/src/doctor/version.ts +131 -0
  123. package/src/e2e/chat-flow.test.ts +346 -0
  124. package/src/e2e/init-sling-lifecycle.test.ts +288 -0
  125. package/src/errors.test.ts +21 -0
  126. package/src/errors.ts +246 -0
  127. package/src/events/store.test.ts +660 -0
  128. package/src/events/store.ts +344 -0
  129. package/src/events/tool-filter.test.ts +330 -0
  130. package/src/events/tool-filter.ts +126 -0
  131. package/src/global-setup.ts +14 -0
  132. package/src/index.ts +339 -0
  133. package/src/insights/analyzer.test.ts +466 -0
  134. package/src/insights/analyzer.ts +203 -0
  135. package/src/logging/color.test.ts +118 -0
  136. package/src/logging/color.ts +71 -0
  137. package/src/logging/logger.test.ts +812 -0
  138. package/src/logging/logger.ts +266 -0
  139. package/src/logging/reporter.test.ts +258 -0
  140. package/src/logging/reporter.ts +109 -0
  141. package/src/logging/sanitizer.test.ts +190 -0
  142. package/src/logging/sanitizer.ts +57 -0
  143. package/src/mail/broadcast.test.ts +203 -0
  144. package/src/mail/broadcast.ts +92 -0
  145. package/src/mail/client.test.ts +873 -0
  146. package/src/mail/client.ts +236 -0
  147. package/src/mail/store.test.ts +815 -0
  148. package/src/mail/store.ts +402 -0
  149. package/src/merge/queue.test.ts +449 -0
  150. package/src/merge/queue.ts +262 -0
  151. package/src/merge/resolver.test.ts +1453 -0
  152. package/src/merge/resolver.ts +759 -0
  153. package/src/metrics/store.test.ts +1167 -0
  154. package/src/metrics/store.ts +511 -0
  155. package/src/metrics/summary.test.ts +397 -0
  156. package/src/metrics/summary.ts +178 -0
  157. package/src/metrics/transcript.test.ts +643 -0
  158. package/src/metrics/transcript.ts +351 -0
  159. package/src/mulch/client.test.ts +547 -0
  160. package/src/mulch/client.ts +416 -0
  161. package/src/server/audit-store.test.ts +384 -0
  162. package/src/server/audit-store.ts +257 -0
  163. package/src/server/headless.test.ts +180 -0
  164. package/src/server/headless.ts +151 -0
  165. package/src/server/index.test.ts +241 -0
  166. package/src/server/index.ts +317 -0
  167. package/src/server/public/app.js +187 -0
  168. package/src/server/public/apple-touch-icon.png +0 -0
  169. package/src/server/public/components/agent-badge.js +37 -0
  170. package/src/server/public/components/data-table.js +114 -0
  171. package/src/server/public/components/gateway-chat.js +256 -0
  172. package/src/server/public/components/issue-card.js +96 -0
  173. package/src/server/public/components/layout.js +88 -0
  174. package/src/server/public/components/message-bubble.js +120 -0
  175. package/src/server/public/components/stat-card.js +26 -0
  176. package/src/server/public/components/terminal-panel.js +140 -0
  177. package/src/server/public/favicon-16.png +0 -0
  178. package/src/server/public/favicon-32.png +0 -0
  179. package/src/server/public/favicon.ico +0 -0
  180. package/src/server/public/favicon.png +0 -0
  181. package/src/server/public/index.html +64 -0
  182. package/src/server/public/lib/api.js +35 -0
  183. package/src/server/public/lib/markdown.js +8 -0
  184. package/src/server/public/lib/preact-setup.js +8 -0
  185. package/src/server/public/lib/state.js +99 -0
  186. package/src/server/public/lib/utils.js +309 -0
  187. package/src/server/public/lib/ws.js +79 -0
  188. package/src/server/public/views/chat.js +983 -0
  189. package/src/server/public/views/costs.js +692 -0
  190. package/src/server/public/views/dashboard.js +781 -0
  191. package/src/server/public/views/gateway-chat.js +622 -0
  192. package/src/server/public/views/inspect.js +399 -0
  193. package/src/server/public/views/issues.js +470 -0
  194. package/src/server/public/views/setup.js +94 -0
  195. package/src/server/public/views/task-detail.js +422 -0
  196. package/src/server/routes.test.ts +3816 -0
  197. package/src/server/routes.ts +1964 -0
  198. package/src/server/websocket.test.ts +288 -0
  199. package/src/server/websocket.ts +196 -0
  200. package/src/sessions/compat.test.ts +109 -0
  201. package/src/sessions/compat.ts +17 -0
  202. package/src/sessions/store.test.ts +969 -0
  203. package/src/sessions/store.ts +480 -0
  204. package/src/test-helpers.test.ts +97 -0
  205. package/src/test-helpers.ts +143 -0
  206. package/src/types.ts +708 -0
  207. package/src/watchdog/daemon.test.ts +1233 -0
  208. package/src/watchdog/daemon.ts +533 -0
  209. package/src/watchdog/health.test.ts +371 -0
  210. package/src/watchdog/health.ts +248 -0
  211. package/src/watchdog/triage.test.ts +162 -0
  212. package/src/watchdog/triage.ts +193 -0
  213. package/src/worktree/manager.test.ts +444 -0
  214. package/src/worktree/manager.ts +224 -0
  215. package/src/worktree/tmux.test.ts +1238 -0
  216. package/src/worktree/tmux.ts +644 -0
  217. package/templates/CLAUDE.md.tmpl +89 -0
  218. package/templates/hooks.json.tmpl +132 -0
  219. package/templates/overlay.md.tmpl +79 -0
@@ -0,0 +1,444 @@
1
+ import { spawn } from "node:child_process";
2
+ import { existsSync, realpathSync } from "node:fs";
3
+ import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { afterEach, beforeEach, describe, expect, test } from "vitest";
7
+ import { WorktreeError } from "../errors.ts";
8
+ import { cleanupTempDir, cloneFixtureRepo, commitFile, getDefaultBranch } from "../test-helpers.ts";
9
+ import { createWorktree, isBranchMerged, listWorktrees, removeWorktree } from "./manager.ts";
10
+
11
+ /**
12
+ * Run a git command in a directory and return stdout. Throws on non-zero exit.
13
+ */
14
+ async function git(cwd: string, args: string[]): Promise<string> {
15
+ return new Promise((resolve, reject) => {
16
+ const proc = spawn("git", args, {
17
+ cwd,
18
+ stdio: ["ignore", "pipe", "pipe"],
19
+ });
20
+
21
+ const stdoutChunks: Buffer[] = [];
22
+ const stderrChunks: Buffer[] = [];
23
+ proc.stdout.on("data", (data: Buffer) => stdoutChunks.push(data));
24
+ proc.stderr.on("data", (data: Buffer) => stderrChunks.push(data));
25
+ proc.on("error", reject);
26
+ proc.on("close", (code) => {
27
+ const stdout = Buffer.concat(stdoutChunks).toString();
28
+ const stderr = Buffer.concat(stderrChunks).toString();
29
+ if (code !== 0) {
30
+ reject(new Error(`git ${args.join(" ")} failed (exit ${code}): ${stderr.trim()}`));
31
+ } else {
32
+ resolve(stdout);
33
+ }
34
+ });
35
+ });
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 cloneFixtureRepo());
46
+ defaultBranch = await getDefaultBranch(repoDir);
47
+ worktreesDir = join(repoDir, ".legio", "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("legio/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("legio/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("legio/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 cloneFixtureRepo());
151
+ defaultBranch = await getDefaultBranch(repoDir);
152
+ worktreesDir = join(repoDir, ".legio", "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("legio/auth-login/bead-abc");
200
+ expect(branches).toContain("legio/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("legio/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(), "legio-notgit-")));
239
+ try {
240
+ await expect(listWorktrees(tmpDir)).rejects.toThrow(WorktreeError);
241
+ } finally {
242
+ await cleanupTempDir(tmpDir);
243
+ }
244
+ });
245
+ });
246
+
247
+ describe("removeWorktree", () => {
248
+ let repoDir: string;
249
+ let worktreesDir: string;
250
+ let defaultBranch: string;
251
+
252
+ beforeEach(async () => {
253
+ repoDir = realpathSync(await cloneFixtureRepo());
254
+ defaultBranch = await getDefaultBranch(repoDir);
255
+ worktreesDir = join(repoDir, ".legio", "worktrees");
256
+ await mkdir(worktreesDir, { recursive: true });
257
+ });
258
+
259
+ afterEach(async () => {
260
+ await cleanupTempDir(repoDir);
261
+ });
262
+
263
+ test("removes worktree directory from disk", async () => {
264
+ const { path: wtPath } = await createWorktree({
265
+ repoRoot: repoDir,
266
+ baseDir: worktreesDir,
267
+ agentName: "auth-login",
268
+ baseBranch: defaultBranch,
269
+ beadId: "bead-abc",
270
+ });
271
+
272
+ expect(existsSync(wtPath)).toBe(true);
273
+
274
+ await removeWorktree(repoDir, wtPath);
275
+
276
+ expect(existsSync(wtPath)).toBe(false);
277
+ });
278
+
279
+ test("deletes the associated branch after removal", async () => {
280
+ const { path: wtPath } = await createWorktree({
281
+ repoRoot: repoDir,
282
+ baseDir: worktreesDir,
283
+ agentName: "auth-login",
284
+ baseBranch: defaultBranch,
285
+ beadId: "bead-abc",
286
+ });
287
+
288
+ await removeWorktree(repoDir, wtPath);
289
+
290
+ const branchList = await git(repoDir, ["branch", "--list"]);
291
+ expect(branchList).not.toContain("legio/auth-login/bead-abc");
292
+ });
293
+
294
+ test("worktree no longer appears in listWorktrees after removal", async () => {
295
+ const { path: wtPath } = await createWorktree({
296
+ repoRoot: repoDir,
297
+ baseDir: worktreesDir,
298
+ agentName: "auth-login",
299
+ baseBranch: defaultBranch,
300
+ beadId: "bead-abc",
301
+ });
302
+
303
+ await removeWorktree(repoDir, wtPath);
304
+
305
+ const entries = await listWorktrees(repoDir);
306
+ const paths = entries.map((e) => e.path);
307
+ expect(paths).not.toContain(wtPath);
308
+ });
309
+
310
+ test("force flag removes worktree with uncommitted changes", async () => {
311
+ const { path: wtPath } = await createWorktree({
312
+ repoRoot: repoDir,
313
+ baseDir: worktreesDir,
314
+ agentName: "auth-login",
315
+ baseBranch: defaultBranch,
316
+ beadId: "bead-abc",
317
+ });
318
+
319
+ // Create an untracked file in the worktree
320
+ await writeFile(join(wtPath, "untracked.txt"), "some content", "utf-8");
321
+
322
+ // Without force, git worktree remove may fail on dirty worktrees.
323
+ // With force, it should succeed.
324
+ await removeWorktree(repoDir, wtPath, { force: true, forceBranch: true });
325
+
326
+ expect(existsSync(wtPath)).toBe(false);
327
+ });
328
+
329
+ test("forceBranch deletes unmerged branch", async () => {
330
+ const { path: wtPath } = await createWorktree({
331
+ repoRoot: repoDir,
332
+ baseDir: worktreesDir,
333
+ agentName: "auth-login",
334
+ baseBranch: defaultBranch,
335
+ beadId: "bead-abc",
336
+ });
337
+
338
+ // Add a commit in the worktree so the branch diverges (making it "unmerged")
339
+ await commitFile(wtPath, "new-file.ts", "export const x = 1;", "add new file");
340
+
341
+ // forceBranch uses -D instead of -d, so even unmerged branches get deleted
342
+ await removeWorktree(repoDir, wtPath, { force: true, forceBranch: true });
343
+
344
+ const branchList = await git(repoDir, ["branch", "--list"]);
345
+ expect(branchList).not.toContain("legio/auth-login/bead-abc");
346
+ });
347
+
348
+ test("throws WorktreeError when branch is unmerged and forceBranch is false", async () => {
349
+ const { path: wtPath } = await createWorktree({
350
+ repoRoot: repoDir,
351
+ baseDir: worktreesDir,
352
+ agentName: "auth-login",
353
+ baseBranch: defaultBranch,
354
+ beadId: "bead-abc",
355
+ });
356
+
357
+ // Add a commit to make the branch unmerged
358
+ await commitFile(wtPath, "new-file.ts", "export const x = 1;", "add new file");
359
+
360
+ // Without forceBranch, removeWorktree should throw WorktreeError because
361
+ // the branch has unmerged commits (git branch -d refuses to delete it).
362
+ await expect(removeWorktree(repoDir, wtPath, { force: true })).rejects.toThrow(WorktreeError);
363
+
364
+ // Worktree directory is removed before the throw (worktree remove runs first)
365
+ expect(existsSync(wtPath)).toBe(false);
366
+
367
+ // Branch still exists because -d failed and we threw instead of silently ignoring
368
+ const branchList = await git(repoDir, ["branch", "--list"]);
369
+ expect(branchList).toContain("legio/auth-login/bead-abc");
370
+ });
371
+ });
372
+
373
+ describe("isBranchMerged", () => {
374
+ let repoDir: string;
375
+ let worktreesDir: string;
376
+ let defaultBranch: string;
377
+
378
+ beforeEach(async () => {
379
+ repoDir = realpathSync(await cloneFixtureRepo());
380
+ defaultBranch = await getDefaultBranch(repoDir);
381
+ worktreesDir = join(repoDir, ".legio", "worktrees");
382
+ await mkdir(worktreesDir, { recursive: true });
383
+ });
384
+
385
+ afterEach(async () => {
386
+ await cleanupTempDir(repoDir);
387
+ });
388
+
389
+ test("returns true for a branch with no extra commits (same tip as target)", async () => {
390
+ const { branch } = await createWorktree({
391
+ repoRoot: repoDir,
392
+ baseDir: worktreesDir,
393
+ agentName: "agent-a",
394
+ baseBranch: defaultBranch,
395
+ beadId: "bead-001",
396
+ });
397
+
398
+ // Branch was created from defaultBranch with no extra commits — its tip IS HEAD
399
+ const merged = await isBranchMerged(repoDir, branch, "HEAD");
400
+ expect(merged).toBe(true);
401
+ });
402
+
403
+ test("returns false for a branch with unmerged commits", async () => {
404
+ const { path: wtPath, branch } = await createWorktree({
405
+ repoRoot: repoDir,
406
+ baseDir: worktreesDir,
407
+ agentName: "agent-b",
408
+ baseBranch: defaultBranch,
409
+ beadId: "bead-002",
410
+ });
411
+
412
+ // Commit diverges the branch from HEAD
413
+ await commitFile(wtPath, "feature.ts", "export const y = 2;", "add feature");
414
+
415
+ const merged = await isBranchMerged(repoDir, branch, "HEAD");
416
+ expect(merged).toBe(false);
417
+ });
418
+
419
+ test("returns true after branch is merged into HEAD", async () => {
420
+ const { path: wtPath, branch } = await createWorktree({
421
+ repoRoot: repoDir,
422
+ baseDir: worktreesDir,
423
+ agentName: "agent-c",
424
+ baseBranch: defaultBranch,
425
+ beadId: "bead-003",
426
+ });
427
+
428
+ // Commit diverges the branch
429
+ await commitFile(wtPath, "feature.ts", "export const z = 3;", "add feature");
430
+
431
+ // Not merged yet
432
+ expect(await isBranchMerged(repoDir, branch, "HEAD")).toBe(false);
433
+
434
+ // Remove the worktree (but NOT the branch) so it is no longer checked out.
435
+ // Use git directly to detach just the worktree without deleting the branch ref.
436
+ await git(repoDir, ["worktree", "remove", "--force", wtPath]);
437
+
438
+ // Merge the branch into the main repo's HEAD
439
+ await git(repoDir, ["merge", branch, "--no-ff", "-m", `Merge ${branch}`]);
440
+
441
+ // Now the branch tip is an ancestor of HEAD
442
+ expect(await isBranchMerged(repoDir, branch, "HEAD")).toBe(true);
443
+ });
444
+ });
@@ -0,0 +1,224 @@
1
+ import { spawn } from "node:child_process";
2
+ import { join } from "node:path";
3
+ import { WorktreeError } from "../errors.ts";
4
+
5
+ /**
6
+ * Run a shell command and capture its output.
7
+ */
8
+ async function runCommand(
9
+ cmd: string[],
10
+ cwd?: string,
11
+ ): Promise<{ stdout: string; stderr: string; exitCode: number }> {
12
+ const [command, ...args] = cmd;
13
+ if (!command) throw new Error("Empty command");
14
+ return new Promise((resolve, reject) => {
15
+ const proc = spawn(command, args, {
16
+ cwd,
17
+ stdio: ["ignore", "pipe", "pipe"],
18
+ });
19
+ const chunks: { stdout: Buffer[]; stderr: Buffer[] } = { stdout: [], stderr: [] };
20
+ proc.stdout.on("data", (data: Buffer) => chunks.stdout.push(data));
21
+ proc.stderr.on("data", (data: Buffer) => chunks.stderr.push(data));
22
+ proc.on("error", reject);
23
+ proc.on("close", (code) => {
24
+ resolve({
25
+ stdout: Buffer.concat(chunks.stdout).toString(),
26
+ stderr: Buffer.concat(chunks.stderr).toString(),
27
+ exitCode: code ?? 1,
28
+ });
29
+ });
30
+ });
31
+ }
32
+
33
+ /**
34
+ * Run a git command and return stdout. Throws WorktreeError on non-zero exit.
35
+ */
36
+ async function runGit(
37
+ repoRoot: string,
38
+ args: string[],
39
+ context?: { worktreePath?: string; branchName?: string },
40
+ ): Promise<string> {
41
+ const { stdout, stderr, exitCode } = await runCommand(["git", ...args], repoRoot);
42
+
43
+ if (exitCode !== 0) {
44
+ throw new WorktreeError(
45
+ `git ${args.join(" ")} failed (exit ${exitCode}): ${stderr.trim() || stdout.trim()}`,
46
+ {
47
+ worktreePath: context?.worktreePath,
48
+ branchName: context?.branchName,
49
+ },
50
+ );
51
+ }
52
+
53
+ return stdout;
54
+ }
55
+
56
+ /**
57
+ * Create a new git worktree for an agent.
58
+ *
59
+ * Creates a worktree at `{baseDir}/{agentName}` with a new branch
60
+ * named `legio/{agentName}/{beadId}` based on `baseBranch`.
61
+ *
62
+ * @returns The absolute worktree path and branch name.
63
+ */
64
+ export async function createWorktree(options: {
65
+ repoRoot: string;
66
+ baseDir: string;
67
+ agentName: string;
68
+ baseBranch: string;
69
+ beadId: string;
70
+ }): Promise<{ path: string; branch: string }> {
71
+ const { repoRoot, baseDir, agentName, baseBranch, beadId } = options;
72
+
73
+ const worktreePath = join(baseDir, agentName);
74
+ const branchName = `legio/${agentName}/${beadId}`;
75
+
76
+ await runGit(repoRoot, ["worktree", "add", "-b", branchName, worktreePath, baseBranch], {
77
+ worktreePath,
78
+ branchName,
79
+ });
80
+
81
+ return { path: worktreePath, branch: branchName };
82
+ }
83
+
84
+ /**
85
+ * Parsed representation of a single worktree entry from `git worktree list --porcelain`.
86
+ */
87
+ interface WorktreeEntry {
88
+ path: string;
89
+ branch: string;
90
+ head: string;
91
+ }
92
+
93
+ /**
94
+ * Parse the output of `git worktree list --porcelain` into structured entries.
95
+ *
96
+ * Porcelain format example:
97
+ * ```
98
+ * worktree /path/to/main
99
+ * HEAD abc123
100
+ * branch refs/heads/main
101
+ *
102
+ * worktree /path/to/wt
103
+ * HEAD def456
104
+ * branch refs/heads/legio/agent/bead
105
+ * ```
106
+ */
107
+ function parseWorktreeOutput(output: string): WorktreeEntry[] {
108
+ const entries: WorktreeEntry[] = [];
109
+ const blocks = output.trim().split("\n\n");
110
+
111
+ for (const block of blocks) {
112
+ if (block.trim() === "") continue;
113
+
114
+ let path = "";
115
+ let head = "";
116
+ let branch = "";
117
+
118
+ const lines = block.trim().split("\n");
119
+ for (const line of lines) {
120
+ if (line.startsWith("worktree ")) {
121
+ path = line.slice("worktree ".length);
122
+ } else if (line.startsWith("HEAD ")) {
123
+ head = line.slice("HEAD ".length);
124
+ } else if (line.startsWith("branch ")) {
125
+ // Strip refs/heads/ prefix to get the short branch name
126
+ const ref = line.slice("branch ".length);
127
+ branch = ref.replace(/^refs\/heads\//, "");
128
+ }
129
+ }
130
+
131
+ if (path.length > 0) {
132
+ entries.push({ path, head, branch });
133
+ }
134
+ }
135
+
136
+ return entries;
137
+ }
138
+
139
+ /**
140
+ * List all git worktrees in the repository.
141
+ *
142
+ * @returns Array of worktree entries with path, branch name, and HEAD commit.
143
+ */
144
+ export async function listWorktrees(
145
+ repoRoot: string,
146
+ ): Promise<Array<{ path: string; branch: string; head: string }>> {
147
+ const stdout = await runGit(repoRoot, ["worktree", "list", "--porcelain"]);
148
+ return parseWorktreeOutput(stdout);
149
+ }
150
+
151
+ /**
152
+ * Check whether a branch has been merged into a target ref.
153
+ *
154
+ * Uses `git merge-base --is-ancestor <branch> <target>`, which exits 0
155
+ * when branch is an ancestor of target (i.e., merged) and 1 when it is not.
156
+ * Any other exit code (e.g., unknown object) is treated as not merged.
157
+ *
158
+ * @param repoRoot - Absolute path to the git repository root.
159
+ * @param branch - Branch to check.
160
+ * @param target - Ref to check against (e.g. "HEAD", "main").
161
+ */
162
+ export async function isBranchMerged(
163
+ repoRoot: string,
164
+ branch: string,
165
+ target: string,
166
+ ): Promise<boolean> {
167
+ const { exitCode } = await runCommand(
168
+ ["git", "merge-base", "--is-ancestor", branch, target],
169
+ repoRoot,
170
+ );
171
+ // exit 0 = is ancestor (merged), exit 1 = not ancestor, other = error → treat as not merged
172
+ return exitCode === 0;
173
+ }
174
+
175
+ /**
176
+ * Remove a git worktree and delete its associated branch.
177
+ *
178
+ * Runs `git worktree remove {path}` to remove the worktree, then
179
+ * deletes the branch. With `forceBranch: true`, uses `git branch -D`
180
+ * to force-delete even unmerged branches and swallows deletion errors
181
+ * (best-effort). Without `forceBranch`, uses `git branch -d` which only
182
+ * deletes merged branches — if deletion fails (branch is unmerged), throws
183
+ * `WorktreeError` to signal that unmerged work remains.
184
+ */
185
+ export async function removeWorktree(
186
+ repoRoot: string,
187
+ path: string,
188
+ options?: { force?: boolean; forceBranch?: boolean },
189
+ ): Promise<void> {
190
+ // First, figure out which branch this worktree is on so we can clean it up
191
+ const worktrees = await listWorktrees(repoRoot);
192
+ const entry = worktrees.find((wt) => wt.path === path);
193
+ const branchName = entry?.branch ?? "";
194
+
195
+ // Remove the worktree (--force handles untracked files and uncommitted changes)
196
+ const removeArgs = ["worktree", "remove", path];
197
+ if (options?.force) {
198
+ removeArgs.push("--force");
199
+ }
200
+ await runGit(repoRoot, removeArgs, {
201
+ worktreePath: path,
202
+ branchName,
203
+ });
204
+
205
+ // Delete the associated branch after worktree removal.
206
+ // Use -D (force) when forceBranch is set, since the branch may not have
207
+ // been merged yet. Use -d (safe) otherwise, which only deletes merged branches.
208
+ if (branchName.length > 0) {
209
+ const deleteFlag = options?.forceBranch ? "-D" : "-d";
210
+ try {
211
+ await runGit(repoRoot, ["branch", deleteFlag, branchName], { branchName });
212
+ } catch {
213
+ if (!options?.forceBranch) {
214
+ // Branch deletion failed without forceBranch — the branch has unmerged commits.
215
+ // Throw so callers know unmerged work remains on the branch.
216
+ throw new WorktreeError(
217
+ `Branch "${branchName}" has unmerged commits; pass forceBranch: true to force-delete.`,
218
+ { worktreePath: path, branchName },
219
+ );
220
+ }
221
+ // forceBranch: branch deletion is best-effort, swallow the error.
222
+ }
223
+ }
224
+ }