@made-by-moonlight/athene-plugin-workspace-worktree 0.9.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.
@@ -0,0 +1,1154 @@
1
+ import { describe, it, expect, beforeEach, vi } from "vitest";
2
+ // ---------------------------------------------------------------------------
3
+ // Mocks — must be declared before any import that uses the mocked modules
4
+ // ---------------------------------------------------------------------------
5
+ const { recordActivityEventMock } = vi.hoisted(() => ({
6
+ recordActivityEventMock: vi.fn(),
7
+ }));
8
+ vi.mock("node:child_process", () => {
9
+ const mockExecFile = vi.fn();
10
+ // Set custom promisify so `promisify(execFile)` returns { stdout, stderr }
11
+ mockExecFile[Symbol.for("nodejs.util.promisify.custom")] = vi.fn();
12
+ return { execFile: mockExecFile };
13
+ });
14
+ vi.mock("node:fs", () => ({
15
+ existsSync: vi.fn(),
16
+ lstatSync: vi.fn(),
17
+ statSync: vi.fn(),
18
+ symlinkSync: vi.fn(),
19
+ linkSync: vi.fn(),
20
+ cpSync: vi.fn(),
21
+ rmSync: vi.fn(),
22
+ mkdirSync: vi.fn(),
23
+ readdirSync: vi.fn(),
24
+ }));
25
+ vi.mock("@made-by-moonlight/athene-core", () => ({
26
+ getShell: vi.fn(() => ({ cmd: "sh", args: (c) => ["-c", c] })),
27
+ isWindows: vi.fn(() => false),
28
+ recordActivityEvent: recordActivityEventMock,
29
+ }));
30
+ vi.mock("node:os", () => ({
31
+ homedir: () => "/mock-home",
32
+ }));
33
+ // Force POSIX path semantics in tests so assertions like "/mock-home/..." match
34
+ // on Windows too. The real source uses platform-native path.join at runtime; we
35
+ // only override it for this test file's scope.
36
+ vi.mock("node:path", async () => {
37
+ const actual = (await vi.importActual("node:path"));
38
+ return { ...actual.posix, default: actual.posix };
39
+ });
40
+ // ---------------------------------------------------------------------------
41
+ // Imports (after mocks)
42
+ // ---------------------------------------------------------------------------
43
+ import * as childProcess from "node:child_process";
44
+ import { existsSync, lstatSync, statSync, symlinkSync, linkSync, cpSync, rmSync, mkdirSync, readdirSync, } from "node:fs";
45
+ import * as core from "@made-by-moonlight/athene-core";
46
+ import { create, manifest } from "../index.js";
47
+ // ---------------------------------------------------------------------------
48
+ // Typed mock references
49
+ // ---------------------------------------------------------------------------
50
+ const mockExecFileAsync = childProcess.execFile[Symbol.for("nodejs.util.promisify.custom")];
51
+ const mockExistsSync = existsSync;
52
+ const mockLstatSync = lstatSync;
53
+ const mockStatSync = statSync;
54
+ const mockSymlinkSync = symlinkSync;
55
+ const mockLinkSync = linkSync;
56
+ const mockCpSync = cpSync;
57
+ const mockRmSync = rmSync;
58
+ const mockMkdirSync = mkdirSync;
59
+ const mockReaddirSync = readdirSync;
60
+ const mockGetShell = core.getShell;
61
+ const mockIsWindows = core.isWindows;
62
+ // ---------------------------------------------------------------------------
63
+ // Helpers
64
+ // ---------------------------------------------------------------------------
65
+ function mockGitSuccess(stdout) {
66
+ mockExecFileAsync.mockResolvedValueOnce({ stdout: stdout + "\n", stderr: "" });
67
+ }
68
+ function mockGitError(message) {
69
+ mockExecFileAsync.mockRejectedValueOnce(new Error(message));
70
+ }
71
+ function mockOriginRemote(fetchSucceeds = true) {
72
+ mockGitSuccess(""); // git remote get-url origin
73
+ if (fetchSucceeds) {
74
+ mockGitSuccess(""); // git fetch origin --quiet
75
+ }
76
+ else {
77
+ mockGitError("Could not resolve host"); // git fetch origin --quiet
78
+ }
79
+ }
80
+ function makeProject(overrides) {
81
+ return {
82
+ name: "test-project",
83
+ repo: "test/repo",
84
+ path: "/repo/path",
85
+ defaultBranch: "main",
86
+ sessionPrefix: "test",
87
+ ...overrides,
88
+ };
89
+ }
90
+ function makeCreateConfig(overrides) {
91
+ return {
92
+ projectId: "myproject",
93
+ project: makeProject(),
94
+ sessionId: "session-1",
95
+ branch: "feat/TEST-1",
96
+ ...overrides,
97
+ };
98
+ }
99
+ // ---------------------------------------------------------------------------
100
+ // Reset mocks before each test
101
+ // ---------------------------------------------------------------------------
102
+ beforeEach(() => {
103
+ vi.clearAllMocks();
104
+ });
105
+ // ===========================================================================
106
+ // Tests
107
+ // ===========================================================================
108
+ describe("manifest", () => {
109
+ it("has name 'worktree' and slot 'workspace'", () => {
110
+ expect(manifest.name).toBe("worktree");
111
+ expect(manifest.slot).toBe("workspace");
112
+ expect(manifest.version).toBe("0.1.0");
113
+ expect(manifest.description).toBe("Workspace plugin: git worktrees");
114
+ });
115
+ });
116
+ describe("create() factory", () => {
117
+ it("uses ~/.worktrees as default base dir", async () => {
118
+ const ws = create();
119
+ mockOriginRemote();
120
+ mockGitSuccess(""); // git rev-parse --verify --quiet origin/main
121
+ mockGitSuccess(""); // worktree add
122
+ const info = await ws.create(makeCreateConfig());
123
+ expect(info.path).toBe("/mock-home/.worktrees/myproject/session-1");
124
+ });
125
+ it("uses custom worktreeDir from config", async () => {
126
+ const ws = create({ worktreeDir: "/custom/worktrees" });
127
+ mockOriginRemote();
128
+ mockGitSuccess(""); // git rev-parse --verify --quiet origin/main
129
+ mockGitSuccess(""); // worktree add
130
+ const info = await ws.create(makeCreateConfig());
131
+ expect(info.path).toBe("/custom/worktrees/myproject/session-1");
132
+ });
133
+ it("expands tilde in custom worktreeDir", async () => {
134
+ const ws = create({ worktreeDir: "~/custom-path" });
135
+ mockOriginRemote();
136
+ mockGitSuccess(""); // git rev-parse --verify --quiet origin/main
137
+ mockGitSuccess(""); // worktree add
138
+ const info = await ws.create(makeCreateConfig());
139
+ expect(info.path).toBe("/mock-home/custom-path/myproject/session-1");
140
+ });
141
+ it("uses per-call worktreeDir override instead of plugin default", async () => {
142
+ const ws = create(); // default: ~/.worktrees
143
+ mockOriginRemote();
144
+ mockGitSuccess(""); // git rev-parse --verify --quiet origin/main
145
+ mockGitSuccess(""); // worktree add
146
+ const info = await ws.create(makeCreateConfig({
147
+ worktreeDir: "/mock-home/.agent-orchestrator/projects/myproject/worktrees",
148
+ }));
149
+ // worktreeDir is used directly (not joined with projectId) — session-manager passes the project-scoped dir
150
+ expect(info.path).toBe("/mock-home/.agent-orchestrator/projects/myproject/worktrees/session-1");
151
+ });
152
+ it("per-call worktreeDir overrides plugin-level worktreeDir", async () => {
153
+ const ws = create({ worktreeDir: "/old/worktrees" });
154
+ mockOriginRemote();
155
+ mockGitSuccess(""); // git rev-parse --verify --quiet origin/main
156
+ mockGitSuccess(""); // worktree add
157
+ const info = await ws.create(makeCreateConfig({ worktreeDir: "/new/v2/worktrees" }));
158
+ expect(info.path).toBe("/new/v2/worktrees/session-1");
159
+ });
160
+ });
161
+ describe("workspace.create()", () => {
162
+ it("calls git fetch and git worktree add with correct args", async () => {
163
+ const ws = create();
164
+ mockOriginRemote();
165
+ mockGitSuccess(""); // git rev-parse --verify --quiet origin/main
166
+ mockGitSuccess(""); // worktree add
167
+ await ws.create(makeCreateConfig());
168
+ // First call: git remote get-url origin
169
+ expect(mockExecFileAsync).toHaveBeenCalledWith("git", ["remote", "get-url", "origin"], {
170
+ cwd: "/repo/path",
171
+ windowsHide: true, timeout: 30_000,
172
+ });
173
+ // Second call: git fetch origin --quiet
174
+ expect(mockExecFileAsync).toHaveBeenCalledWith("git", ["fetch", "origin", "--quiet"], {
175
+ cwd: "/repo/path",
176
+ windowsHide: true, timeout: 30_000,
177
+ });
178
+ // Third call: git rev-parse --verify --quiet origin/main
179
+ expect(mockExecFileAsync).toHaveBeenCalledWith("git", ["rev-parse", "--verify", "--quiet", "origin/main"], { cwd: "/repo/path", windowsHide: true, timeout: 30_000 });
180
+ // Fourth call: git worktree add -b <branch> <path> <baseRef>
181
+ expect(mockExecFileAsync).toHaveBeenCalledWith("git", [
182
+ "worktree",
183
+ "add",
184
+ "-b",
185
+ "feat/TEST-1",
186
+ "/mock-home/.worktrees/myproject/session-1",
187
+ "origin/main",
188
+ ], { cwd: "/repo/path", windowsHide: true, timeout: 30_000 });
189
+ });
190
+ it("creates the project worktree directory", async () => {
191
+ const ws = create();
192
+ mockOriginRemote();
193
+ mockGitSuccess(""); // git rev-parse --verify --quiet origin/main
194
+ mockGitSuccess(""); // worktree add
195
+ await ws.create(makeCreateConfig());
196
+ expect(mockMkdirSync).toHaveBeenCalledWith("/mock-home/.worktrees/myproject", {
197
+ recursive: true,
198
+ });
199
+ });
200
+ it("removes a stale unregistered worktree directory before creating a new worktree", async () => {
201
+ const ws = create();
202
+ mockExistsSync.mockReturnValueOnce(true);
203
+ mockGitSuccess(""); // git worktree prune
204
+ mockGitSuccess("worktree /repo/path\nHEAD deadbeef\nbranch refs/heads/main"); // git worktree list --porcelain
205
+ mockOriginRemote();
206
+ mockGitSuccess(""); // git rev-parse --verify --quiet origin/main
207
+ mockGitSuccess(""); // worktree add
208
+ await ws.create(makeCreateConfig());
209
+ expect(mockRmSync).toHaveBeenCalledWith("/mock-home/.worktrees/myproject/session-1", {
210
+ recursive: true,
211
+ force: true,
212
+ });
213
+ expect(mockExecFileAsync).toHaveBeenCalledWith("git", [
214
+ "worktree",
215
+ "add",
216
+ "-b",
217
+ "feat/TEST-1",
218
+ "/mock-home/.worktrees/myproject/session-1",
219
+ "origin/main",
220
+ ], { cwd: "/repo/path", windowsHide: true, timeout: 30_000 });
221
+ });
222
+ it("throws a useful error when the existing worktree path is still registered with git", async () => {
223
+ const ws = create();
224
+ mockExistsSync.mockReturnValueOnce(true);
225
+ mockGitSuccess(""); // git worktree prune
226
+ mockGitSuccess("worktree /mock-home/.worktrees/myproject/session-1\nHEAD deadbeef\nbranch refs/heads/feat/TEST-1"); // git worktree list --porcelain
227
+ await expect(ws.create(makeCreateConfig())).rejects.toThrow('Worktree path "/mock-home/.worktrees/myproject/session-1" already exists and is still registered with git');
228
+ });
229
+ it("finds an adoptable worktree in the project-scoped worktree directory", async () => {
230
+ const ws = create();
231
+ mockGitSuccess([
232
+ "worktree /mock-home/.agent-orchestrator/projects/myproject/worktrees/session-1",
233
+ "HEAD deadbeef",
234
+ "branch refs/heads/feat/TEST-1",
235
+ ].join("\n"));
236
+ mockExistsSync.mockReturnValueOnce(true);
237
+ const info = await ws.findManagedWorkspace?.(makeCreateConfig({
238
+ worktreeDir: "/mock-home/.agent-orchestrator/projects/myproject/worktrees",
239
+ }));
240
+ expect(info).toEqual({
241
+ path: "/mock-home/.agent-orchestrator/projects/myproject/worktrees/session-1",
242
+ branch: "feat/TEST-1",
243
+ sessionId: "session-1",
244
+ projectId: "myproject",
245
+ });
246
+ });
247
+ it("finds an adoptable worktree in the legacy managed worktree directory", async () => {
248
+ const ws = create();
249
+ mockGitSuccess([
250
+ "worktree /mock-home/.worktrees/myproject/session-1",
251
+ "HEAD deadbeef",
252
+ "branch refs/heads/feat/TEST-1",
253
+ ].join("\n"));
254
+ mockExistsSync.mockReturnValueOnce(true);
255
+ const info = await ws.findManagedWorkspace?.(makeCreateConfig({
256
+ worktreeDir: "/mock-home/.agent-orchestrator/projects/myproject/worktrees",
257
+ }));
258
+ expect(info?.path).toBe("/mock-home/.worktrees/myproject/session-1");
259
+ });
260
+ it("returns null when no managed worktree tracks the requested branch", async () => {
261
+ const ws = create();
262
+ mockGitSuccess([
263
+ "worktree /mock-home/.worktrees/myproject/session-2",
264
+ "HEAD deadbeef",
265
+ "branch refs/heads/feat/OTHER",
266
+ ].join("\n"));
267
+ const info = await ws.findManagedWorkspace?.(makeCreateConfig());
268
+ expect(info).toBeNull();
269
+ });
270
+ it("throws when the matching branch is checked out outside AO-managed worktree directories", async () => {
271
+ const ws = create();
272
+ mockGitSuccess([
273
+ "worktree /tmp/manual-worktree",
274
+ "HEAD deadbeef",
275
+ "branch refs/heads/feat/TEST-1",
276
+ ].join("\n"));
277
+ mockExistsSync.mockReturnValueOnce(true);
278
+ await expect(ws.findManagedWorkspace?.(makeCreateConfig())).rejects.toThrow('outside AO-managed worktree directories');
279
+ });
280
+ it("skips worktree entries whose path no longer exists on disk", async () => {
281
+ const ws = create();
282
+ // The worktree is listed by git but the directory was manually deleted
283
+ mockGitSuccess([
284
+ "worktree /mock-home/.worktrees/myproject/session-1",
285
+ "HEAD deadbeef",
286
+ "branch refs/heads/feat/TEST-1",
287
+ ].join("\n"));
288
+ // existsSync returns false for the deleted worktree path
289
+ mockExistsSync.mockReturnValueOnce(false);
290
+ const info = await ws.findManagedWorkspace?.(makeCreateConfig());
291
+ expect(info).toBeNull();
292
+ });
293
+ it("handles CRLF line endings in git worktree list output", async () => {
294
+ const ws = create();
295
+ // Simulate Windows git output with \r\n line endings
296
+ mockGitSuccess([
297
+ "worktree /mock-home/.worktrees/myproject/session-1",
298
+ "HEAD deadbeef",
299
+ "branch refs/heads/feat/TEST-1",
300
+ ].join("\r\n"));
301
+ mockExistsSync.mockReturnValueOnce(true);
302
+ const info = await ws.findManagedWorkspace?.(makeCreateConfig());
303
+ expect(info).toEqual({
304
+ path: "/mock-home/.worktrees/myproject/session-1",
305
+ branch: "feat/TEST-1",
306
+ sessionId: "session-1",
307
+ projectId: "myproject",
308
+ });
309
+ });
310
+ it("continues when fetch fails (offline)", async () => {
311
+ const ws = create();
312
+ mockOriginRemote(false);
313
+ mockGitSuccess(""); // git rev-parse --verify --quiet origin/main
314
+ mockGitSuccess(""); // worktree add succeeds
315
+ const info = await ws.create(makeCreateConfig());
316
+ expect(info.path).toBe("/mock-home/.worktrees/myproject/session-1");
317
+ });
318
+ it("uses refs/heads/<defaultBranch> when origin is missing", async () => {
319
+ const ws = create();
320
+ mockGitError("fatal: not a git repository"); // git remote get-url origin fails
321
+ mockGitSuccess(""); // git rev-parse --verify --quiet refs/heads/main
322
+ mockGitSuccess(""); // worktree add succeeds
323
+ await ws.create(makeCreateConfig());
324
+ expect(mockExecFileAsync).toHaveBeenCalledWith("git", ["rev-parse", "--verify", "--quiet", "refs/heads/main"], { cwd: "/repo/path", windowsHide: true, timeout: 30_000 });
325
+ expect(mockExecFileAsync).toHaveBeenCalledWith("git", [
326
+ "worktree",
327
+ "add",
328
+ "-b",
329
+ "feat/TEST-1",
330
+ "/mock-home/.worktrees/myproject/session-1",
331
+ "refs/heads/main",
332
+ ], { cwd: "/repo/path", windowsHide: true, timeout: 30_000 });
333
+ });
334
+ it("throws when neither origin nor the local default branch can be resolved", async () => {
335
+ const ws = create();
336
+ mockGitError("fatal: not a git repository"); // git remote get-url origin fails
337
+ mockGitError("fatal: invalid reference"); // git rev-parse --verify --quiet refs/heads/main
338
+ await expect(ws.create(makeCreateConfig())).rejects.toThrow('Unable to resolve base ref for default branch "main"');
339
+ });
340
+ it("reuses an existing branch when it already matches the resolved base", async () => {
341
+ const ws = create();
342
+ mockOriginRemote();
343
+ mockGitSuccess(""); // git rev-parse --verify --quiet origin/main
344
+ mockGitError("already exists"); // worktree add -b fails
345
+ mockGitSuccess("base-sha"); // git rev-parse origin/main
346
+ mockGitSuccess(""); // git rev-parse --verify --quiet refs/heads/feat/TEST-1
347
+ mockGitSuccess("base-sha"); // git rev-parse refs/heads/feat/TEST-1
348
+ mockGitSuccess(""); // worktree add existing branch
349
+ const info = await ws.create(makeCreateConfig());
350
+ expect(mockExecFileAsync).toHaveBeenCalledWith("git", ["worktree", "add", "/mock-home/.worktrees/myproject/session-1", "feat/TEST-1"], { cwd: "/repo/path", windowsHide: true, timeout: 30_000 });
351
+ expect(info.branch).toBe("feat/TEST-1");
352
+ });
353
+ it("resets an existing stale branch against the resolved base", async () => {
354
+ const ws = create();
355
+ mockOriginRemote();
356
+ mockGitSuccess(""); // git rev-parse --verify --quiet origin/main
357
+ mockGitError("already exists"); // worktree add -b fails
358
+ mockGitSuccess("base-sha"); // git rev-parse origin/main
359
+ mockGitSuccess(""); // git rev-parse --verify --quiet refs/heads/feat/TEST-1
360
+ mockGitSuccess("old-sha"); // git rev-parse refs/heads/feat/TEST-1
361
+ mockGitSuccess(""); // worktree add -B existing branch
362
+ const info = await ws.create(makeCreateConfig());
363
+ expect(mockExecFileAsync).toHaveBeenCalledWith("git", [
364
+ "worktree",
365
+ "add",
366
+ "-B",
367
+ "feat/TEST-1",
368
+ "/mock-home/.worktrees/myproject/session-1",
369
+ "origin/main",
370
+ ], { cwd: "/repo/path", windowsHide: true, timeout: 30_000 });
371
+ expect(info.branch).toBe("feat/TEST-1");
372
+ });
373
+ it("handles existing branch with local default branch when origin is missing", async () => {
374
+ const ws = create();
375
+ mockGitError("fatal: not a git repository"); // git remote get-url origin fails
376
+ mockGitSuccess(""); // git rev-parse --verify --quiet refs/heads/main
377
+ mockGitError("already exists"); // worktree add -b fails
378
+ mockGitSuccess("base-sha"); // git rev-parse refs/heads/main
379
+ mockGitSuccess(""); // git rev-parse --verify --quiet refs/heads/feat/TEST-1
380
+ mockGitSuccess("base-sha"); // git rev-parse refs/heads/feat/TEST-1
381
+ mockGitSuccess(""); // worktree add existing branch
382
+ const info = await ws.create(makeCreateConfig());
383
+ expect(mockExecFileAsync).toHaveBeenCalledWith("git", ["worktree", "add", "/mock-home/.worktrees/myproject/session-1", "feat/TEST-1"], { cwd: "/repo/path", windowsHide: true, timeout: 30_000 });
384
+ expect(info.branch).toBe("feat/TEST-1");
385
+ });
386
+ it("cleans up worktree on retry failure", async () => {
387
+ const ws = create();
388
+ mockOriginRemote();
389
+ mockGitSuccess(""); // git rev-parse --verify --quiet origin/main
390
+ mockGitError("already exists"); // worktree add -b fails
391
+ mockGitSuccess("base-sha"); // git rev-parse origin/main
392
+ mockGitSuccess(""); // git rev-parse --verify --quiet refs/heads/feat/TEST-1
393
+ mockGitSuccess("old-sha"); // git rev-parse refs/heads/feat/TEST-1
394
+ mockGitError("worktree add failed: branch checked out"); // worktree add -B fails
395
+ mockGitSuccess(""); // worktree remove (cleanup)
396
+ await expect(ws.create(makeCreateConfig())).rejects.toThrow('Failed to create worktree for branch "feat/TEST-1": worktree add failed: branch checked out');
397
+ // Verify cleanup was attempted
398
+ expect(mockExecFileAsync).toHaveBeenCalledWith("git", ["worktree", "remove", "--force", "/mock-home/.worktrees/myproject/session-1"], { cwd: "/repo/path", windowsHide: true, timeout: 30_000 });
399
+ });
400
+ it("still throws the retry failure even if cleanup fails", async () => {
401
+ const ws = create();
402
+ mockOriginRemote();
403
+ mockGitSuccess(""); // git rev-parse --verify --quiet origin/main
404
+ mockGitError("already exists"); // worktree add -b fails
405
+ mockGitSuccess("base-sha"); // git rev-parse origin/main
406
+ mockGitSuccess(""); // git rev-parse --verify --quiet refs/heads/feat/TEST-1
407
+ mockGitSuccess("old-sha"); // git rev-parse refs/heads/feat/TEST-1
408
+ mockGitError("worktree add failed"); // worktree add -B fails
409
+ mockGitError("worktree remove failed"); // cleanup also fails
410
+ await expect(ws.create(makeCreateConfig())).rejects.toThrow('Failed to create worktree for branch "feat/TEST-1": worktree add failed');
411
+ });
412
+ it("throws for non-already-exists worktree add errors", async () => {
413
+ const ws = create();
414
+ mockOriginRemote();
415
+ mockGitSuccess(""); // git rev-parse --verify --quiet origin/main
416
+ mockGitError("fatal: invalid reference"); // worktree add fails with other error
417
+ await expect(ws.create(makeCreateConfig())).rejects.toThrow('Failed to create worktree for branch "feat/TEST-1": fatal: invalid reference');
418
+ });
419
+ it("rejects invalid projectId", async () => {
420
+ const ws = create();
421
+ await expect(ws.create(makeCreateConfig({ projectId: "bad/project" }))).rejects.toThrow('Invalid projectId "bad/project"');
422
+ });
423
+ it("rejects projectId with dots", async () => {
424
+ const ws = create();
425
+ await expect(ws.create(makeCreateConfig({ projectId: "my.project" }))).rejects.toThrow('Invalid projectId "my.project"');
426
+ });
427
+ it("rejects invalid sessionId", async () => {
428
+ const ws = create();
429
+ await expect(ws.create(makeCreateConfig({ sessionId: "../escape" }))).rejects.toThrow('Invalid sessionId "../escape"');
430
+ });
431
+ it("rejects sessionId with spaces", async () => {
432
+ const ws = create();
433
+ await expect(ws.create(makeCreateConfig({ sessionId: "bad session" }))).rejects.toThrow('Invalid sessionId "bad session"');
434
+ });
435
+ it("returns correct WorkspaceInfo", async () => {
436
+ const ws = create();
437
+ mockOriginRemote();
438
+ mockGitSuccess(""); // git rev-parse --verify --quiet origin/main
439
+ mockGitSuccess(""); // worktree add
440
+ const info = await ws.create(makeCreateConfig());
441
+ expect(info).toEqual({
442
+ path: "/mock-home/.worktrees/myproject/session-1",
443
+ branch: "feat/TEST-1",
444
+ sessionId: "session-1",
445
+ projectId: "myproject",
446
+ });
447
+ });
448
+ it("expands tilde in project path", async () => {
449
+ const ws = create();
450
+ mockOriginRemote();
451
+ mockGitSuccess(""); // git rev-parse --verify --quiet origin/main
452
+ mockGitSuccess(""); // worktree add
453
+ await ws.create(makeCreateConfig({
454
+ project: makeProject({ path: "~/my-repo" }),
455
+ }));
456
+ // fetch should use expanded path
457
+ expect(mockExecFileAsync).toHaveBeenCalledWith("git", ["fetch", "origin", "--quiet"], {
458
+ cwd: "/mock-home/my-repo",
459
+ windowsHide: true, timeout: 30_000,
460
+ });
461
+ });
462
+ it("uses the local default branch when origin remote is missing", async () => {
463
+ const ws = create();
464
+ mockGitError("fatal: not a git repository"); // git remote get-url origin fails
465
+ mockGitSuccess(""); // git rev-parse --verify --quiet refs/heads/main
466
+ mockGitSuccess(""); // worktree add
467
+ await ws.create(makeCreateConfig());
468
+ expect(mockExecFileAsync).toHaveBeenCalledWith("git", [
469
+ "worktree",
470
+ "add",
471
+ "-b",
472
+ "feat/TEST-1",
473
+ "/mock-home/.worktrees/myproject/session-1",
474
+ "refs/heads/main",
475
+ ], { cwd: "/repo/path", windowsHide: true, timeout: 30_000 });
476
+ });
477
+ });
478
+ describe("workspace.restore()", () => {
479
+ it("prefers origin branch refs when origin exists", async () => {
480
+ const ws = create();
481
+ mockGitSuccess(""); // git worktree prune
482
+ mockOriginRemote();
483
+ mockGitError("fatal: invalid reference"); // git worktree add workspacePath cfg.branch fails
484
+ mockGitError("fatal: bad ref"); // refExists(refs/heads/feat/TEST-1) → false (branch missing)
485
+ // createBranchFromBase → cleanupStaleWorkspacePath
486
+ mockGitSuccess(""); // worktree remove --force <path> (best-effort)
487
+ mockExistsSync.mockReturnValueOnce(false); // no leftover dir, skip cleanup
488
+ mockGitSuccess(""); // git rev-parse --verify --quiet origin/feat/TEST-1
489
+ mockGitSuccess(""); // git worktree add -b cfg.branch workspacePath origin/feat/TEST-1
490
+ const info = await ws.restore(makeCreateConfig(), "/mock-home/.worktrees/myproject/session-1");
491
+ expect(mockExecFileAsync).toHaveBeenCalledWith("git", [
492
+ "worktree",
493
+ "add",
494
+ "-b",
495
+ "feat/TEST-1",
496
+ "/mock-home/.worktrees/myproject/session-1",
497
+ "origin/feat/TEST-1",
498
+ ], { cwd: "/repo/path", windowsHide: true, timeout: 30_000 });
499
+ expect(info.branch).toBe("feat/TEST-1");
500
+ });
501
+ it("uses the local default branch when origin remote is missing", async () => {
502
+ const ws = create();
503
+ mockGitSuccess(""); // git worktree prune
504
+ mockGitError("fatal: not a git repository"); // git remote get-url origin fails
505
+ mockGitError("fatal: invalid reference"); // git worktree add workspacePath cfg.branch fails
506
+ mockGitError("fatal: bad ref"); // refExists(refs/heads/feat/TEST-1) → false (branch missing)
507
+ // createBranchFromBase → cleanupStaleWorkspacePath
508
+ mockGitSuccess(""); // worktree remove --force <path> (best-effort)
509
+ mockExistsSync.mockReturnValueOnce(false); // no leftover dir, skip cleanup
510
+ mockGitSuccess(""); // git rev-parse --verify --quiet refs/heads/main
511
+ mockGitSuccess(""); // git worktree add -b cfg.branch workspacePath refs/heads/main
512
+ const info = await ws.restore(makeCreateConfig(), "/mock-home/.worktrees/myproject/session-1");
513
+ expect(mockExecFileAsync).toHaveBeenCalledWith("git", [
514
+ "worktree",
515
+ "add",
516
+ "-b",
517
+ "feat/TEST-1",
518
+ "/mock-home/.worktrees/myproject/session-1",
519
+ "refs/heads/main",
520
+ ], { cwd: "/repo/path", windowsHide: true, timeout: 30_000 });
521
+ expect(info).toEqual({
522
+ path: "/mock-home/.worktrees/myproject/session-1",
523
+ branch: "feat/TEST-1",
524
+ sessionId: "session-1",
525
+ projectId: "myproject",
526
+ });
527
+ });
528
+ // --- Regression coverage for #1741 ---------------------------------------
529
+ // When the local session branch already exists (destroy() preserves it on
530
+ // purpose), restore() must re-attach it instead of falling through to the
531
+ // -b path that would either fail ("branch already exists") or discard
532
+ // commits. See https://github.com/slievr/Athene/issues/1741.
533
+ //
534
+ // The recovery sequence (in reattachExistingBranch):
535
+ // 1. `git worktree remove --force <path>` (best-effort: clears registry)
536
+ // 2. existsSync(<path>) — bail if dir already gone
537
+ // 3. `git worktree list --porcelain` (isRegisteredWorktree)
538
+ // 4. rmSync(<path>) if not still registered (else throw — data safety)
539
+ // 5. `git worktree add <path> <branch>` retry (no -b/-B)
540
+ //
541
+ // The entry-point prune in restore() is sufficient — no second prune in
542
+ // the recovery path.
543
+ it("re-attaches existing local branch when stale registry conflicts", async () => {
544
+ // Path was registered as a worktree but the dir was already cleaned up.
545
+ // worktree remove --force succeeds; the stale-dir cleanup short-circuits
546
+ // because existsSync returns false; retry succeeds.
547
+ const ws = create();
548
+ mockGitSuccess(""); // git worktree prune (entry-point)
549
+ mockOriginRemote();
550
+ mockGitError("fatal: 'feat/TEST-1' is already checked out"); // first worktree add fails
551
+ mockGitSuccess(""); // refExists(refs/heads/feat/TEST-1) → true
552
+ mockGitSuccess(""); // worktree remove --force <path>
553
+ mockExistsSync.mockReturnValueOnce(false); // no leftover dir, skip cleanup
554
+ mockGitSuccess(""); // RETRY: worktree add <path> <branch> succeeds
555
+ const info = await ws.restore(makeCreateConfig(), "/mock-home/.worktrees/myproject/session-1");
556
+ // The recovery call must re-attach the existing branch — no -b, no -B.
557
+ expect(mockExecFileAsync).toHaveBeenLastCalledWith("git", ["worktree", "add", "/mock-home/.worktrees/myproject/session-1", "feat/TEST-1"], { cwd: "/repo/path", windowsHide: true, timeout: 30_000 });
558
+ // No -b or -B should ever appear when the branch already exists locally.
559
+ const calls = mockExecFileAsync.mock.calls;
560
+ for (const [, args] of calls) {
561
+ if (Array.isArray(args)) {
562
+ expect(args).not.toContain("-b");
563
+ expect(args).not.toContain("-B");
564
+ }
565
+ }
566
+ expect(info).toEqual({
567
+ path: "/mock-home/.worktrees/myproject/session-1",
568
+ branch: "feat/TEST-1",
569
+ sessionId: "session-1",
570
+ projectId: "myproject",
571
+ });
572
+ });
573
+ it("rmSyncs a stale workspace directory before retrying worktree add", async () => {
574
+ // Direct repro of the user's #1741 follow-on failure: the dir physically
575
+ // exists on disk (workspace.exists() returned false because it's not a
576
+ // git working tree, just leftover files). worktree add fails with
577
+ // "<path> already exists" — recovery must rmSync the stale dir, not loop.
578
+ const ws = create();
579
+ mockGitSuccess(""); // git worktree prune (entry-point)
580
+ mockOriginRemote();
581
+ mockGitError("fatal: '/mock-home/.worktrees/myproject/session-1' already exists"); // first worktree add fails because dir exists
582
+ mockGitSuccess(""); // refExists → true
583
+ mockGitError("fatal: not a working tree"); // worktree remove --force fails (path not registered)
584
+ mockExistsSync.mockReturnValueOnce(true); // dir exists
585
+ mockGitSuccess("worktree /some/other\nbranch refs/heads/main"); // worktree list — no entry for our path
586
+ // rmSync called (mocked) — no second prune
587
+ mockGitSuccess(""); // RETRY: worktree add succeeds
588
+ const info = await ws.restore(makeCreateConfig(), "/mock-home/.worktrees/myproject/session-1");
589
+ // The stale dir must have been removed.
590
+ expect(mockRmSync).toHaveBeenCalledWith("/mock-home/.worktrees/myproject/session-1", {
591
+ recursive: true,
592
+ force: true,
593
+ });
594
+ // Retry must be the no-flag form.
595
+ expect(mockExecFileAsync).toHaveBeenLastCalledWith("git", ["worktree", "add", "/mock-home/.worktrees/myproject/session-1", "feat/TEST-1"], { cwd: "/repo/path", windowsHide: true, timeout: 30_000 });
596
+ expect(info.branch).toBe("feat/TEST-1");
597
+ });
598
+ it("refuses to rmSync a still-registered worktree dir (data safety)", async () => {
599
+ // If after `worktree remove --force` the path is STILL registered,
600
+ // something is very wrong. reattachExistingBranch throws rather than
601
+ // rmSync a registered worktree (which could destroy the user's work).
602
+ // The error must propagate, not be swallowed.
603
+ const ws = create();
604
+ mockGitSuccess(""); // git worktree prune (entry-point)
605
+ mockOriginRemote();
606
+ mockGitError("fatal: 'feat/TEST-1' is already checked out"); // first worktree add fails
607
+ mockGitSuccess(""); // refExists → true
608
+ mockGitError("fatal: cannot remove"); // worktree remove --force fails
609
+ mockExistsSync.mockReturnValueOnce(true); // dir exists
610
+ // Path is still registered — isRegisteredWorktree returns our path
611
+ mockGitSuccess("worktree /mock-home/.worktrees/myproject/session-1\nbranch refs/heads/feat/TEST-1");
612
+ await expect(ws.restore(makeCreateConfig(), "/mock-home/.worktrees/myproject/session-1")).rejects.toThrow(/already exists and is still registered/);
613
+ // rmSync MUST NOT have been called — we never delete a registered worktree.
614
+ expect(mockRmSync).not.toHaveBeenCalled();
615
+ });
616
+ it("propagates retry error when worktree add fails after cleanup", async () => {
617
+ const ws = create();
618
+ mockGitSuccess(""); // prune (entry-point)
619
+ mockOriginRemote();
620
+ mockGitError("fatal: first failure"); // first worktree add fails
621
+ mockGitSuccess(""); // refExists → true
622
+ mockGitSuccess(""); // worktree remove --force
623
+ mockExistsSync.mockReturnValueOnce(false); // no leftover dir, skip cleanup
624
+ mockGitError("fatal: persistent failure"); // RETRY also fails
625
+ await expect(ws.restore(makeCreateConfig(), "/mock-home/.worktrees/myproject/session-1")).rejects.toThrow(/persistent failure/);
626
+ // Crucially, the failure surface is the underlying git error — NOT a
627
+ // misleading "branch already exists" from a -b fallback.
628
+ const calls = mockExecFileAsync.mock.calls;
629
+ for (const [, args] of calls) {
630
+ if (Array.isArray(args)) {
631
+ expect(args).not.toContain("-b");
632
+ expect(args).not.toContain("-B");
633
+ }
634
+ }
635
+ });
636
+ it("never force-resets an existing branch (preserves session commits)", async () => {
637
+ // Defense-in-depth: confirm restore() never uses -B even in the
638
+ // recovery path. -B would silently discard the user's commits,
639
+ // which is the opposite of what restore must do.
640
+ const ws = create();
641
+ mockGitSuccess(""); // prune (entry-point)
642
+ mockOriginRemote();
643
+ mockGitError("fatal: registry conflict"); // first worktree add fails
644
+ mockGitSuccess(""); // refExists → true
645
+ mockGitSuccess(""); // worktree remove --force
646
+ mockExistsSync.mockReturnValueOnce(false); // no leftover dir, skip cleanup
647
+ mockGitSuccess(""); // RETRY succeeds
648
+ await ws.restore(makeCreateConfig(), "/mock-home/.worktrees/myproject/session-1");
649
+ const calls = mockExecFileAsync.mock.calls;
650
+ const dashBigB = calls.filter(([, args]) => Array.isArray(args) && args.includes("-B"));
651
+ expect(dashBigB).toHaveLength(0);
652
+ });
653
+ it("checks branch existence with rev-parse --verify --quiet refs/heads/<branch>", async () => {
654
+ // Lock in the exact ref form used. If someone later refactors refExists or
655
+ // forgets the refs/heads/ prefix, this regression test catches it.
656
+ const ws = create();
657
+ mockGitSuccess(""); // prune (entry-point)
658
+ mockOriginRemote();
659
+ mockGitError("fatal: first failure"); // first worktree add fails
660
+ mockGitSuccess(""); // refExists → true
661
+ mockGitSuccess(""); // worktree remove --force
662
+ mockExistsSync.mockReturnValueOnce(false); // no leftover dir, skip cleanup
663
+ mockGitSuccess(""); // RETRY succeeds
664
+ await ws.restore(makeCreateConfig(), "/mock-home/.worktrees/myproject/session-1");
665
+ expect(mockExecFileAsync).toHaveBeenCalledWith("git", ["rev-parse", "--verify", "--quiet", "refs/heads/feat/TEST-1"], { cwd: "/repo/path", windowsHide: true, timeout: 30_000 });
666
+ });
667
+ it("matches registered worktree even when workspacePath has trailing slash", async () => {
668
+ // Path normalization safety: if `workspacePath` is passed in a non-canonical
669
+ // form (trailing slash, ".." segments) and git reports a canonical path,
670
+ // strict string equality false-negatives. That would mistakenly rmSync a
671
+ // still-registered worktree → DATA LOSS. Both sides must be resolve()d.
672
+ const ws = create();
673
+ mockGitSuccess(""); // entry-point prune
674
+ mockOriginRemote();
675
+ mockGitError("fatal: 'feat/TEST-1' is already checked out"); // first worktree add fails
676
+ mockGitSuccess(""); // refExists → true
677
+ // reattachExistingBranch → cleanupStaleWorkspacePath
678
+ mockGitError("fatal: cannot remove"); // worktree remove --force fails
679
+ mockExistsSync.mockReturnValueOnce(true); // dir exists
680
+ // git reports canonical path (no trailing slash); we call restore with trailing slash
681
+ mockGitSuccess("worktree /mock-home/.worktrees/myproject/session-1\nbranch refs/heads/feat/TEST-1");
682
+ await expect(ws.restore(makeCreateConfig(), "/mock-home/.worktrees/myproject/session-1/")).rejects.toThrow(/already exists and is still registered/);
683
+ // CRITICAL: rmSync MUST NOT have been called — the resolve() normalization
684
+ // correctly identified the path as still-registered despite the trailing slash.
685
+ expect(mockRmSync).not.toHaveBeenCalled();
686
+ });
687
+ it("createBranchFromBase also clears stale workspace dir before worktree add -b", async () => {
688
+ // Mirror of the re-attach path: when the local branch is MISSING and the
689
+ // workspacePath has stale state, createBranchFromBase must also do the
690
+ // cleanup. Otherwise `git worktree add -b ...` fails with the same
691
+ // "<path> already exists" error the re-attach path was fixed for.
692
+ const ws = create();
693
+ mockGitSuccess(""); // entry-point prune
694
+ mockOriginRemote();
695
+ mockGitError("fatal: '/mock-home/.worktrees/myproject/session-1' already exists"); // first worktree add fails
696
+ mockGitError("fatal: bad ref"); // refExists → false (branch missing)
697
+ // createBranchFromBase → cleanupStaleWorkspacePath
698
+ mockGitError("fatal: not registered"); // worktree remove --force fails
699
+ mockExistsSync.mockReturnValueOnce(true); // dir exists as junk
700
+ mockGitSuccess("worktree /some/other\nbranch refs/heads/main"); // not registered
701
+ // rmSync called (mocked)
702
+ mockGitSuccess(""); // resolveBaseRef: rev-parse origin/feat/TEST-1
703
+ mockGitSuccess(""); // worktree add -b ... origin/feat/TEST-1 succeeds
704
+ const info = await ws.restore(makeCreateConfig(), "/mock-home/.worktrees/myproject/session-1");
705
+ // Stale dir must have been removed before -b add.
706
+ expect(mockRmSync).toHaveBeenCalledWith("/mock-home/.worktrees/myproject/session-1", {
707
+ recursive: true,
708
+ force: true,
709
+ });
710
+ expect(info.branch).toBe("feat/TEST-1");
711
+ expect(mockExecFileAsync).toHaveBeenLastCalledWith("git", [
712
+ "worktree",
713
+ "add",
714
+ "-b",
715
+ "feat/TEST-1",
716
+ "/mock-home/.worktrees/myproject/session-1",
717
+ "origin/feat/TEST-1",
718
+ ], { cwd: "/repo/path", windowsHide: true, timeout: 30_000 });
719
+ });
720
+ it("happy path: restore re-attaches branch when first worktree add already succeeds", async () => {
721
+ // No catch path — the first attempt works. Confirms we don't accidentally
722
+ // run the cleanup/retry sequence in the common case.
723
+ const ws = create();
724
+ mockGitSuccess(""); // prune (entry-point)
725
+ mockOriginRemote();
726
+ mockGitSuccess(""); // worktree add <path> <branch> succeeds first try
727
+ const info = await ws.restore(makeCreateConfig(), "/mock-home/.worktrees/myproject/session-1");
728
+ expect(info.branch).toBe("feat/TEST-1");
729
+ // Total calls: prune + remote get-url + fetch + worktree add = 4
730
+ expect(mockExecFileAsync).toHaveBeenCalledTimes(4);
731
+ });
732
+ });
733
+ describe("workspace.destroy()", () => {
734
+ it("removes worktree via git commands", async () => {
735
+ const ws = create();
736
+ // rev-parse returns the .git dir
737
+ mockGitSuccess("/repo/path/.git");
738
+ // worktree remove succeeds
739
+ mockGitSuccess("");
740
+ await ws.destroy("/mock-home/.worktrees/myproject/session-1");
741
+ // First call: rev-parse
742
+ expect(mockExecFileAsync).toHaveBeenCalledWith("git", ["rev-parse", "--path-format=absolute", "--git-common-dir"], { cwd: "/mock-home/.worktrees/myproject/session-1", windowsHide: true, timeout: 30_000 });
743
+ // Second call: worktree remove
744
+ expect(mockExecFileAsync).toHaveBeenCalledWith("git", ["worktree", "remove", "--force", "/mock-home/.worktrees/myproject/session-1"], { cwd: "/repo/path", windowsHide: true, timeout: 30_000 });
745
+ });
746
+ it("falls back to rmSync when git commands fail", async () => {
747
+ const ws = create();
748
+ mockGitError("not a git repository"); // rev-parse fails
749
+ mockExistsSync.mockReturnValueOnce(true);
750
+ await ws.destroy("/mock-home/.worktrees/myproject/session-1");
751
+ expect(mockRmSync).toHaveBeenCalledWith("/mock-home/.worktrees/myproject/session-1", {
752
+ recursive: true,
753
+ force: true,
754
+ });
755
+ });
756
+ it("does nothing if git fails and directory does not exist", async () => {
757
+ const ws = create();
758
+ mockGitError("not a git repository");
759
+ mockExistsSync.mockReturnValueOnce(false);
760
+ await ws.destroy("/nonexistent/path");
761
+ expect(mockRmSync).not.toHaveBeenCalled();
762
+ });
763
+ it("retries rmSync on Windows when first attempts fail (file-handle drain)", async () => {
764
+ mockIsWindows.mockReturnValue(true);
765
+ const ws = create();
766
+ mockGitError("not a git repository"); // force fallback
767
+ // existsSync sequence: top guard (true), then between-retries checks
768
+ mockExistsSync.mockReturnValueOnce(true); // destroy() top guard
769
+ mockExistsSync.mockReturnValueOnce(true); // after attempt #1 — still there
770
+ mockExistsSync.mockReturnValueOnce(true); // after attempt #2 — still there
771
+ mockExistsSync.mockReturnValueOnce(false); // after attempt #3 — gone
772
+ let calls = 0;
773
+ mockRmSync.mockImplementation(() => {
774
+ calls++;
775
+ if (calls < 3)
776
+ throw Object.assign(new Error("EBUSY"), { code: "EBUSY" });
777
+ });
778
+ await ws.destroy("/mock-home/.worktrees/myproject/session-1");
779
+ expect(mockRmSync.mock.calls.length).toBeGreaterThanOrEqual(3);
780
+ mockIsWindows.mockReturnValue(false);
781
+ mockRmSync.mockReset();
782
+ });
783
+ it("throws on Windows after exhausting retries (handles never released)", async () => {
784
+ mockIsWindows.mockReturnValue(true);
785
+ const ws = create();
786
+ mockGitError("not a git repository");
787
+ mockExistsSync.mockReturnValue(true); // always exists — never drains
788
+ mockRmSync.mockImplementation(() => {
789
+ throw Object.assign(new Error("EBUSY"), { code: "EBUSY" });
790
+ });
791
+ await expect(ws.destroy("/mock-home/.worktrees/myproject/session-1")).rejects.toThrow(/Windows file-handle drain/);
792
+ expect(mockRmSync.mock.calls.length).toBe(6);
793
+ mockIsWindows.mockReturnValue(false);
794
+ mockExistsSync.mockReset();
795
+ mockRmSync.mockReset();
796
+ });
797
+ });
798
+ describe("workspace.list()", () => {
799
+ it("returns empty array when project directory does not exist", async () => {
800
+ const ws = create();
801
+ mockExistsSync.mockReturnValueOnce(false);
802
+ const result = await ws.list("myproject");
803
+ expect(result).toEqual([]);
804
+ });
805
+ it("returns empty array when project directory has no subdirectories", async () => {
806
+ const ws = create();
807
+ mockExistsSync.mockReturnValueOnce(true);
808
+ mockReaddirSync.mockReturnValueOnce([]);
809
+ const result = await ws.list("myproject");
810
+ expect(result).toEqual([]);
811
+ });
812
+ it("parses worktree list porcelain output", async () => {
813
+ const ws = create();
814
+ mockExistsSync.mockReturnValueOnce(true);
815
+ mockReaddirSync.mockReturnValueOnce([
816
+ { name: "session-1", isDirectory: () => true },
817
+ { name: "session-2", isDirectory: () => true },
818
+ ]);
819
+ const porcelainOutput = [
820
+ "worktree /mock-home/.worktrees/myproject/session-1",
821
+ "HEAD abc1234",
822
+ "branch refs/heads/feat/TEST-1",
823
+ "",
824
+ "worktree /mock-home/.worktrees/myproject/session-2",
825
+ "HEAD def5678",
826
+ "branch refs/heads/feat/TEST-2",
827
+ "",
828
+ "worktree /repo/path",
829
+ "HEAD 0000000",
830
+ "branch refs/heads/main",
831
+ ].join("\n");
832
+ mockGitSuccess(porcelainOutput);
833
+ const result = await ws.list("myproject");
834
+ expect(result).toHaveLength(2);
835
+ expect(result[0]).toEqual({
836
+ path: "/mock-home/.worktrees/myproject/session-1",
837
+ branch: "feat/TEST-1",
838
+ sessionId: "session-1",
839
+ projectId: "myproject",
840
+ });
841
+ expect(result[1]).toEqual({
842
+ path: "/mock-home/.worktrees/myproject/session-2",
843
+ branch: "feat/TEST-2",
844
+ sessionId: "session-2",
845
+ projectId: "myproject",
846
+ });
847
+ });
848
+ it("handles detached HEAD worktrees", async () => {
849
+ const ws = create();
850
+ mockExistsSync.mockReturnValueOnce(true);
851
+ mockReaddirSync.mockReturnValueOnce([{ name: "session-1", isDirectory: () => true }]);
852
+ const porcelainOutput = [
853
+ "worktree /mock-home/.worktrees/myproject/session-1",
854
+ "HEAD abc1234",
855
+ "detached",
856
+ ].join("\n");
857
+ mockGitSuccess(porcelainOutput);
858
+ const result = await ws.list("myproject");
859
+ expect(result).toHaveLength(1);
860
+ expect(result[0].branch).toBe("detached");
861
+ });
862
+ it("excludes worktrees outside the project directory", async () => {
863
+ const ws = create();
864
+ mockExistsSync.mockReturnValueOnce(true);
865
+ mockReaddirSync.mockReturnValueOnce([{ name: "session-1", isDirectory: () => true }]);
866
+ const porcelainOutput = [
867
+ "worktree /other/path/session-1",
868
+ "HEAD abc1234",
869
+ "branch refs/heads/feat/other",
870
+ "",
871
+ "worktree /mock-home/.worktrees/myproject/session-1",
872
+ "HEAD def5678",
873
+ "branch refs/heads/feat/TEST-1",
874
+ ].join("\n");
875
+ mockGitSuccess(porcelainOutput);
876
+ const result = await ws.list("myproject");
877
+ expect(result).toHaveLength(1);
878
+ expect(result[0].sessionId).toBe("session-1");
879
+ });
880
+ it("returns empty when all git worktree list calls fail", async () => {
881
+ const ws = create();
882
+ mockExistsSync.mockReturnValueOnce(true);
883
+ mockReaddirSync.mockReturnValueOnce([{ name: "session-1", isDirectory: () => true }]);
884
+ mockGitError("fatal: not a git repository");
885
+ const result = await ws.list("myproject");
886
+ expect(result).toEqual([]);
887
+ });
888
+ it("tries next directory when first worktree list fails", async () => {
889
+ const ws = create();
890
+ mockExistsSync.mockReturnValueOnce(true);
891
+ mockReaddirSync.mockReturnValueOnce([
892
+ { name: "session-1", isDirectory: () => true },
893
+ { name: "session-2", isDirectory: () => true },
894
+ ]);
895
+ // First dir fails
896
+ mockGitError("fatal: not a git repository");
897
+ // Second dir succeeds
898
+ const porcelainOutput = [
899
+ "worktree /mock-home/.worktrees/myproject/session-2",
900
+ "HEAD abc1234",
901
+ "branch refs/heads/feat/TEST-2",
902
+ ].join("\n");
903
+ mockGitSuccess(porcelainOutput);
904
+ const result = await ws.list("myproject");
905
+ expect(result).toHaveLength(1);
906
+ expect(result[0].sessionId).toBe("session-2");
907
+ });
908
+ it("rejects invalid projectId", async () => {
909
+ const ws = create();
910
+ await expect(ws.list("bad/id")).rejects.toThrow('Invalid projectId "bad/id"');
911
+ });
912
+ it("filters out non-directory entries", async () => {
913
+ const ws = create();
914
+ mockExistsSync.mockReturnValueOnce(true);
915
+ mockReaddirSync.mockReturnValueOnce([
916
+ { name: "session-1", isDirectory: () => true },
917
+ { name: ".DS_Store", isDirectory: () => false },
918
+ { name: "readme.txt", isDirectory: () => false },
919
+ ]);
920
+ const porcelainOutput = [
921
+ "worktree /mock-home/.worktrees/myproject/session-1",
922
+ "HEAD abc1234",
923
+ "branch refs/heads/feat/TEST-1",
924
+ ].join("\n");
925
+ mockGitSuccess(porcelainOutput);
926
+ const result = await ws.list("myproject");
927
+ expect(result).toHaveLength(1);
928
+ });
929
+ });
930
+ describe("workspace.postCreate()", () => {
931
+ const workspaceInfo = {
932
+ path: "/mock-home/.worktrees/myproject/session-1",
933
+ branch: "feat/TEST-1",
934
+ sessionId: "session-1",
935
+ projectId: "myproject",
936
+ };
937
+ it("creates symlinks for configured paths", async () => {
938
+ const ws = create();
939
+ const project = makeProject({ symlinks: ["node_modules", ".env"] });
940
+ // First symlink: node_modules exists, target lstat throws (doesn't exist)
941
+ mockExistsSync.mockReturnValueOnce(true); // sourcePath exists
942
+ mockLstatSync.mockImplementationOnce(() => {
943
+ throw new Error("ENOENT");
944
+ });
945
+ // Second symlink: .env exists, target lstat throws (doesn't exist)
946
+ mockExistsSync.mockReturnValueOnce(true); // sourcePath exists
947
+ mockLstatSync.mockImplementationOnce(() => {
948
+ throw new Error("ENOENT");
949
+ });
950
+ await ws.postCreate(workspaceInfo, project);
951
+ expect(mockSymlinkSync).toHaveBeenCalledTimes(2);
952
+ expect(mockSymlinkSync).toHaveBeenCalledWith("/repo/path/node_modules", "/mock-home/.worktrees/myproject/session-1/node_modules");
953
+ expect(mockSymlinkSync).toHaveBeenCalledWith("/repo/path/.env", "/mock-home/.worktrees/myproject/session-1/.env");
954
+ });
955
+ it("removes existing target before symlinking", async () => {
956
+ const ws = create();
957
+ const project = makeProject({ symlinks: ["node_modules"] });
958
+ mockExistsSync.mockReturnValueOnce(true); // sourcePath exists
959
+ mockLstatSync.mockReturnValueOnce({
960
+ isSymbolicLink: () => true,
961
+ isFile: () => false,
962
+ isDirectory: () => false,
963
+ });
964
+ await ws.postCreate(workspaceInfo, project);
965
+ expect(mockRmSync).toHaveBeenCalledWith("/mock-home/.worktrees/myproject/session-1/node_modules", { recursive: true, force: true });
966
+ expect(mockSymlinkSync).toHaveBeenCalledTimes(1);
967
+ });
968
+ it("skips symlinks when source does not exist", async () => {
969
+ const ws = create();
970
+ const project = makeProject({ symlinks: ["nonexistent"] });
971
+ mockExistsSync.mockReturnValueOnce(false); // sourcePath does not exist
972
+ await ws.postCreate(workspaceInfo, project);
973
+ expect(mockSymlinkSync).not.toHaveBeenCalled();
974
+ });
975
+ it("rejects absolute symlink paths", async () => {
976
+ const ws = create();
977
+ const project = makeProject({ symlinks: ["/absolute/path"] });
978
+ await expect(ws.postCreate(workspaceInfo, project)).rejects.toThrow('Invalid symlink path "/absolute/path": must be a relative path without ".." segments');
979
+ });
980
+ it("rejects .. directory traversal in symlink paths", async () => {
981
+ const ws = create();
982
+ const project = makeProject({ symlinks: ["../escape"] });
983
+ await expect(ws.postCreate(workspaceInfo, project)).rejects.toThrow('Invalid symlink path "../escape": must be a relative path without ".." segments');
984
+ });
985
+ it("rejects .. embedded in symlink paths", async () => {
986
+ const ws = create();
987
+ const project = makeProject({ symlinks: ["foo/../../../etc/passwd"] });
988
+ await expect(ws.postCreate(workspaceInfo, project)).rejects.toThrow('must be a relative path without ".." segments');
989
+ });
990
+ it("rejects Windows drive-letter absolute symlink paths (e.g. C:\\path)", async () => {
991
+ const ws = create();
992
+ const project = makeProject({ symlinks: ["C:\\Windows\\System32"] });
993
+ await expect(ws.postCreate(workspaceInfo, project)).rejects.toThrow('must be a relative path without ".." segments');
994
+ });
995
+ it("rejects Windows UNC absolute symlink paths (e.g. \\\\server\\share)", async () => {
996
+ const ws = create();
997
+ const project = makeProject({ symlinks: ["\\\\server\\share\\file"] });
998
+ await expect(ws.postCreate(workspaceInfo, project)).rejects.toThrow('must be a relative path without ".." segments');
999
+ });
1000
+ it("creates parent directories for nested symlink targets", async () => {
1001
+ const ws = create();
1002
+ const project = makeProject({ symlinks: ["config/settings"] });
1003
+ mockExistsSync.mockReturnValueOnce(true); // sourcePath exists
1004
+ mockLstatSync.mockImplementationOnce(() => {
1005
+ throw new Error("ENOENT");
1006
+ });
1007
+ await ws.postCreate(workspaceInfo, project);
1008
+ expect(mockMkdirSync).toHaveBeenCalledWith("/mock-home/.worktrees/myproject/session-1/config", {
1009
+ recursive: true,
1010
+ });
1011
+ });
1012
+ it("runs postCreate commands using getShell()", async () => {
1013
+ const ws = create();
1014
+ const project = makeProject({
1015
+ postCreate: ["pnpm install", "pnpm build"],
1016
+ });
1017
+ mockGetShell.mockReturnValue({ cmd: "sh", args: (c) => ["-c", c] });
1018
+ // Two shell calls
1019
+ mockExecFileAsync.mockResolvedValueOnce({ stdout: "", stderr: "" });
1020
+ mockExecFileAsync.mockResolvedValueOnce({ stdout: "", stderr: "" });
1021
+ await ws.postCreate(workspaceInfo, project);
1022
+ expect(mockGetShell).toHaveBeenCalled();
1023
+ expect(mockExecFileAsync).toHaveBeenCalledWith("sh", ["-c", "pnpm install"], {
1024
+ cwd: "/mock-home/.worktrees/myproject/session-1",
1025
+ windowsHide: true,
1026
+ });
1027
+ expect(mockExecFileAsync).toHaveBeenCalledWith("sh", ["-c", "pnpm build"], {
1028
+ cwd: "/mock-home/.worktrees/myproject/session-1",
1029
+ windowsHide: true,
1030
+ });
1031
+ });
1032
+ it("uses Windows shell (pwsh) when getShell returns pwsh", async () => {
1033
+ const ws = create();
1034
+ const project = makeProject({ postCreate: ["npm install"] });
1035
+ mockGetShell.mockReturnValueOnce({
1036
+ cmd: "pwsh",
1037
+ args: (c) => ["-NoLogo", "-NonInteractive", "-Command", c],
1038
+ });
1039
+ mockExecFileAsync.mockResolvedValueOnce({ stdout: "", stderr: "" });
1040
+ await ws.postCreate(workspaceInfo, project);
1041
+ expect(mockExecFileAsync).toHaveBeenCalledWith("pwsh", ["-NoLogo", "-NonInteractive", "-Command", "npm install"], { cwd: "/mock-home/.worktrees/myproject/session-1", windowsHide: true });
1042
+ });
1043
+ it("falls back to junction for directories on Windows (B19)", async () => {
1044
+ const ws = create();
1045
+ const project = makeProject({ symlinks: ["node_modules"] });
1046
+ mockIsWindows.mockReturnValue(true);
1047
+ mockExistsSync.mockReturnValueOnce(true);
1048
+ mockLstatSync.mockImplementationOnce(() => {
1049
+ throw new Error("ENOENT");
1050
+ });
1051
+ const symlinkError = Object.assign(new Error("symlink requires elevation"), { code: "EPERM" });
1052
+ mockSymlinkSync
1053
+ .mockImplementationOnce(() => {
1054
+ throw symlinkError;
1055
+ })
1056
+ .mockImplementationOnce(() => undefined); // junction succeeds
1057
+ mockStatSync.mockReturnValueOnce({ isDirectory: () => true });
1058
+ await ws.postCreate(workspaceInfo, project);
1059
+ expect(mockSymlinkSync).toHaveBeenLastCalledWith(expect.stringContaining("node_modules"), expect.stringContaining("node_modules"), "junction");
1060
+ expect(mockLinkSync).not.toHaveBeenCalled();
1061
+ expect(mockCpSync).not.toHaveBeenCalled();
1062
+ });
1063
+ it("falls back to hardlink for files on Windows (B19)", async () => {
1064
+ const ws = create();
1065
+ const project = makeProject({ symlinks: [".env"] });
1066
+ mockIsWindows.mockReturnValue(true);
1067
+ mockExistsSync.mockReturnValueOnce(true);
1068
+ mockLstatSync.mockImplementationOnce(() => {
1069
+ throw new Error("ENOENT");
1070
+ });
1071
+ const symlinkError = Object.assign(new Error("symlink requires elevation"), { code: "EPERM" });
1072
+ mockSymlinkSync.mockImplementationOnce(() => {
1073
+ throw symlinkError;
1074
+ });
1075
+ mockStatSync.mockReturnValueOnce({ isDirectory: () => false });
1076
+ mockLinkSync.mockImplementationOnce(() => undefined);
1077
+ await ws.postCreate(workspaceInfo, project);
1078
+ expect(mockLinkSync).toHaveBeenCalledWith(expect.stringContaining(".env"), expect.stringContaining(".env"));
1079
+ expect(mockCpSync).not.toHaveBeenCalled();
1080
+ });
1081
+ it("falls back to cpSync when junction also fails on Windows (B19)", async () => {
1082
+ const ws = create();
1083
+ const project = makeProject({ symlinks: ["node_modules"] });
1084
+ mockIsWindows.mockReturnValue(true);
1085
+ mockExistsSync.mockReturnValueOnce(true);
1086
+ mockLstatSync.mockImplementationOnce(() => {
1087
+ throw new Error("ENOENT");
1088
+ });
1089
+ const symlinkError = Object.assign(new Error("symlink requires elevation"), { code: "EPERM" });
1090
+ mockSymlinkSync
1091
+ .mockImplementationOnce(() => {
1092
+ throw symlinkError;
1093
+ })
1094
+ .mockImplementationOnce(() => {
1095
+ throw new Error("junction failed");
1096
+ });
1097
+ mockStatSync.mockReturnValueOnce({ isDirectory: () => true });
1098
+ await ws.postCreate(workspaceInfo, project);
1099
+ expect(mockCpSync).toHaveBeenCalledWith(expect.stringContaining("node_modules"), expect.stringContaining("node_modules"), { recursive: true });
1100
+ });
1101
+ it("re-throws symlink errors on non-Windows (B19)", async () => {
1102
+ const ws = create();
1103
+ const project = makeProject({ symlinks: ["node_modules"] });
1104
+ mockIsWindows.mockReturnValue(false);
1105
+ mockExistsSync.mockReturnValueOnce(true);
1106
+ mockLstatSync.mockImplementationOnce(() => {
1107
+ throw new Error("ENOENT");
1108
+ });
1109
+ const symlinkError = new Error("permission denied");
1110
+ mockSymlinkSync.mockImplementationOnce(() => {
1111
+ throw symlinkError;
1112
+ });
1113
+ await expect(ws.postCreate(workspaceInfo, project)).rejects.toThrow("permission denied");
1114
+ expect(mockCpSync).not.toHaveBeenCalled();
1115
+ });
1116
+ it("does nothing when no symlinks or postCreate configured", async () => {
1117
+ const ws = create();
1118
+ const project = makeProject();
1119
+ await ws.postCreate(workspaceInfo, project);
1120
+ expect(mockSymlinkSync).not.toHaveBeenCalled();
1121
+ expect(mockExecFileAsync).not.toHaveBeenCalled();
1122
+ });
1123
+ it("handles both symlinks and postCreate commands together", async () => {
1124
+ const ws = create();
1125
+ const project = makeProject({
1126
+ symlinks: ["node_modules"],
1127
+ postCreate: ["pnpm install"],
1128
+ });
1129
+ // Symlink: source exists, target doesn't
1130
+ mockExistsSync.mockReturnValueOnce(true);
1131
+ mockLstatSync.mockImplementationOnce(() => {
1132
+ throw new Error("ENOENT");
1133
+ });
1134
+ // postCreate command
1135
+ mockExecFileAsync.mockResolvedValueOnce({ stdout: "", stderr: "" });
1136
+ await ws.postCreate(workspaceInfo, project);
1137
+ expect(mockSymlinkSync).toHaveBeenCalledTimes(1);
1138
+ expect(mockExecFileAsync).toHaveBeenCalledWith("sh", ["-c", "pnpm install"], {
1139
+ cwd: "/mock-home/.worktrees/myproject/session-1",
1140
+ windowsHide: true,
1141
+ }); // getShell() returns { cmd: "sh", args: ["-c", cmd] } in tests
1142
+ });
1143
+ it("expands tilde in project path for symlink sources", async () => {
1144
+ const ws = create();
1145
+ const project = makeProject({ path: "~/my-repo", symlinks: ["data"] });
1146
+ mockExistsSync.mockReturnValueOnce(true);
1147
+ mockLstatSync.mockImplementationOnce(() => {
1148
+ throw new Error("ENOENT");
1149
+ });
1150
+ await ws.postCreate(workspaceInfo, project);
1151
+ expect(mockSymlinkSync).toHaveBeenCalledWith("/mock-home/my-repo/data", "/mock-home/.worktrees/myproject/session-1/data");
1152
+ });
1153
+ });
1154
+ //# sourceMappingURL=index.test.js.map