@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.
- package/LICENSE +21 -0
- package/README.md +381 -0
- package/agents/builder.md +137 -0
- package/agents/coordinator.md +263 -0
- package/agents/lead.md +301 -0
- package/agents/merger.md +160 -0
- package/agents/monitor.md +214 -0
- package/agents/reviewer.md +140 -0
- package/agents/scout.md +119 -0
- package/agents/supervisor.md +423 -0
- package/package.json +47 -0
- package/src/agents/checkpoint.test.ts +88 -0
- package/src/agents/checkpoint.ts +101 -0
- package/src/agents/hooks-deployer.test.ts +2040 -0
- package/src/agents/hooks-deployer.ts +607 -0
- package/src/agents/identity.test.ts +603 -0
- package/src/agents/identity.ts +384 -0
- package/src/agents/lifecycle.test.ts +196 -0
- package/src/agents/lifecycle.ts +183 -0
- package/src/agents/manifest.test.ts +746 -0
- package/src/agents/manifest.ts +354 -0
- package/src/agents/overlay.test.ts +676 -0
- package/src/agents/overlay.ts +308 -0
- package/src/beads/client.test.ts +217 -0
- package/src/beads/client.ts +202 -0
- package/src/beads/molecules.test.ts +338 -0
- package/src/beads/molecules.ts +198 -0
- package/src/commands/agents.test.ts +322 -0
- package/src/commands/agents.ts +287 -0
- package/src/commands/clean.test.ts +670 -0
- package/src/commands/clean.ts +618 -0
- package/src/commands/completions.test.ts +342 -0
- package/src/commands/completions.ts +887 -0
- package/src/commands/coordinator.test.ts +1530 -0
- package/src/commands/coordinator.ts +733 -0
- package/src/commands/costs.test.ts +1119 -0
- package/src/commands/costs.ts +564 -0
- package/src/commands/dashboard.test.ts +308 -0
- package/src/commands/dashboard.ts +838 -0
- package/src/commands/doctor.test.ts +294 -0
- package/src/commands/doctor.ts +213 -0
- package/src/commands/errors.test.ts +647 -0
- package/src/commands/errors.ts +248 -0
- package/src/commands/feed.test.ts +578 -0
- package/src/commands/feed.ts +361 -0
- package/src/commands/group.test.ts +262 -0
- package/src/commands/group.ts +511 -0
- package/src/commands/hooks.test.ts +458 -0
- package/src/commands/hooks.ts +253 -0
- package/src/commands/init.test.ts +347 -0
- package/src/commands/init.ts +650 -0
- package/src/commands/inspect.test.ts +670 -0
- package/src/commands/inspect.ts +431 -0
- package/src/commands/log.test.ts +1454 -0
- package/src/commands/log.ts +724 -0
- package/src/commands/logs.test.ts +379 -0
- package/src/commands/logs.ts +546 -0
- package/src/commands/mail.test.ts +1270 -0
- package/src/commands/mail.ts +771 -0
- package/src/commands/merge.test.ts +670 -0
- package/src/commands/merge.ts +355 -0
- package/src/commands/metrics.test.ts +444 -0
- package/src/commands/metrics.ts +143 -0
- package/src/commands/monitor.test.ts +191 -0
- package/src/commands/monitor.ts +390 -0
- package/src/commands/nudge.test.ts +230 -0
- package/src/commands/nudge.ts +372 -0
- package/src/commands/prime.test.ts +470 -0
- package/src/commands/prime.ts +381 -0
- package/src/commands/replay.test.ts +741 -0
- package/src/commands/replay.ts +360 -0
- package/src/commands/run.test.ts +431 -0
- package/src/commands/run.ts +351 -0
- package/src/commands/sling.test.ts +657 -0
- package/src/commands/sling.ts +661 -0
- package/src/commands/spec.test.ts +203 -0
- package/src/commands/spec.ts +168 -0
- package/src/commands/status.test.ts +430 -0
- package/src/commands/status.ts +398 -0
- package/src/commands/stop.test.ts +420 -0
- package/src/commands/stop.ts +151 -0
- package/src/commands/supervisor.test.ts +187 -0
- package/src/commands/supervisor.ts +535 -0
- package/src/commands/trace.test.ts +745 -0
- package/src/commands/trace.ts +325 -0
- package/src/commands/watch.test.ts +145 -0
- package/src/commands/watch.ts +247 -0
- package/src/commands/worktree.test.ts +786 -0
- package/src/commands/worktree.ts +311 -0
- package/src/config.test.ts +822 -0
- package/src/config.ts +829 -0
- package/src/doctor/agents.test.ts +454 -0
- package/src/doctor/agents.ts +396 -0
- package/src/doctor/config-check.test.ts +190 -0
- package/src/doctor/config-check.ts +183 -0
- package/src/doctor/consistency.test.ts +651 -0
- package/src/doctor/consistency.ts +294 -0
- package/src/doctor/databases.test.ts +290 -0
- package/src/doctor/databases.ts +218 -0
- package/src/doctor/dependencies.test.ts +184 -0
- package/src/doctor/dependencies.ts +175 -0
- package/src/doctor/logs.test.ts +251 -0
- package/src/doctor/logs.ts +295 -0
- package/src/doctor/merge-queue.test.ts +216 -0
- package/src/doctor/merge-queue.ts +144 -0
- package/src/doctor/structure.test.ts +291 -0
- package/src/doctor/structure.ts +198 -0
- package/src/doctor/types.ts +37 -0
- package/src/doctor/version.test.ts +136 -0
- package/src/doctor/version.ts +129 -0
- package/src/e2e/init-sling-lifecycle.test.ts +277 -0
- package/src/errors.ts +217 -0
- package/src/events/store.test.ts +660 -0
- package/src/events/store.ts +369 -0
- package/src/events/tool-filter.test.ts +330 -0
- package/src/events/tool-filter.ts +126 -0
- package/src/index.ts +316 -0
- package/src/insights/analyzer.test.ts +466 -0
- package/src/insights/analyzer.ts +203 -0
- package/src/logging/color.test.ts +142 -0
- package/src/logging/color.ts +71 -0
- package/src/logging/logger.test.ts +813 -0
- package/src/logging/logger.ts +266 -0
- package/src/logging/reporter.test.ts +259 -0
- package/src/logging/reporter.ts +109 -0
- package/src/logging/sanitizer.test.ts +190 -0
- package/src/logging/sanitizer.ts +57 -0
- package/src/mail/broadcast.test.ts +203 -0
- package/src/mail/broadcast.ts +92 -0
- package/src/mail/client.test.ts +773 -0
- package/src/mail/client.ts +223 -0
- package/src/mail/store.test.ts +705 -0
- package/src/mail/store.ts +387 -0
- package/src/merge/queue.test.ts +359 -0
- package/src/merge/queue.ts +231 -0
- package/src/merge/resolver.test.ts +1345 -0
- package/src/merge/resolver.ts +645 -0
- package/src/metrics/store.test.ts +667 -0
- package/src/metrics/store.ts +445 -0
- package/src/metrics/summary.test.ts +398 -0
- package/src/metrics/summary.ts +178 -0
- package/src/metrics/transcript.test.ts +356 -0
- package/src/metrics/transcript.ts +175 -0
- package/src/mulch/client.test.ts +671 -0
- package/src/mulch/client.ts +332 -0
- package/src/sessions/compat.test.ts +280 -0
- package/src/sessions/compat.ts +104 -0
- package/src/sessions/store.test.ts +873 -0
- package/src/sessions/store.ts +494 -0
- package/src/test-helpers.test.ts +124 -0
- package/src/test-helpers.ts +126 -0
- package/src/tracker/beads.ts +56 -0
- package/src/tracker/factory.test.ts +80 -0
- package/src/tracker/factory.ts +64 -0
- package/src/tracker/seeds.ts +182 -0
- package/src/tracker/types.ts +52 -0
- package/src/types.ts +724 -0
- package/src/watchdog/daemon.test.ts +1975 -0
- package/src/watchdog/daemon.ts +671 -0
- package/src/watchdog/health.test.ts +431 -0
- package/src/watchdog/health.ts +264 -0
- package/src/watchdog/triage.test.ts +164 -0
- package/src/watchdog/triage.ts +179 -0
- package/src/worktree/manager.test.ts +439 -0
- package/src/worktree/manager.ts +198 -0
- package/src/worktree/tmux.test.ts +1009 -0
- package/src/worktree/tmux.ts +509 -0
- package/templates/CLAUDE.md.tmpl +89 -0
- package/templates/hooks.json.tmpl +105 -0
- 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
|
+
}
|