@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.
- package/LICENSE +22 -0
- package/dist/__tests__/activity-events.test.d.ts +8 -0
- package/dist/__tests__/activity-events.test.d.ts.map +1 -0
- package/dist/__tests__/activity-events.test.js +151 -0
- package/dist/__tests__/activity-events.test.js.map +1 -0
- package/dist/__tests__/index.test.d.ts +2 -0
- package/dist/__tests__/index.test.d.ts.map +1 -0
- package/dist/__tests__/index.test.js +1154 -0
- package/dist/__tests__/index.test.js.map +1 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +613 -0
- package/dist/index.js.map +1 -0
- package/package.json +48 -0
|
@@ -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
|