@aku11i/phantom 0.1.0 → 0.1.2
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/README.md +6 -1
- package/dist/garden.js +394 -0
- package/dist/garden.js.map +7 -0
- package/dist/phantom.js +566 -0
- package/dist/phantom.js.map +7 -0
- package/package.json +6 -4
- package/src/bin/garden.ts +0 -74
- package/src/bin/phantom.ts +0 -121
- package/src/commands/exec.test.ts +0 -256
- package/src/commands/exec.ts +0 -81
- package/src/commands/shell.test.ts +0 -248
- package/src/commands/shell.ts +0 -91
- package/src/gardens/commands/create.test.ts +0 -176
- package/src/gardens/commands/create.ts +0 -67
- package/src/gardens/commands/delete.test.ts +0 -316
- package/src/gardens/commands/delete.ts +0 -134
- package/src/gardens/commands/list.test.ts +0 -243
- package/src/gardens/commands/list.ts +0 -152
- package/src/gardens/commands/where.test.ts +0 -149
- package/src/gardens/commands/where.ts +0 -53
- package/src/git/libs/add-worktree.ts +0 -16
- package/src/git/libs/get-current-branch.ts +0 -9
- package/src/git/libs/get-git-root.ts +0 -9
|
@@ -1,316 +0,0 @@
|
|
|
1
|
-
import { strictEqual } from "node:assert";
|
|
2
|
-
import { before, describe, it, mock } from "node:test";
|
|
3
|
-
|
|
4
|
-
describe("deleteGarden", () => {
|
|
5
|
-
let accessMock: ReturnType<typeof mock.fn>;
|
|
6
|
-
let execMock: ReturnType<typeof mock.fn>;
|
|
7
|
-
let deleteGarden: typeof import("./delete.ts").deleteGarden;
|
|
8
|
-
|
|
9
|
-
before(async () => {
|
|
10
|
-
accessMock = mock.fn();
|
|
11
|
-
execMock = mock.fn();
|
|
12
|
-
|
|
13
|
-
mock.module("node:fs/promises", {
|
|
14
|
-
namedExports: {
|
|
15
|
-
access: accessMock,
|
|
16
|
-
},
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
mock.module("node:child_process", {
|
|
20
|
-
namedExports: {
|
|
21
|
-
exec: execMock,
|
|
22
|
-
},
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
mock.module("node:util", {
|
|
26
|
-
namedExports: {
|
|
27
|
-
promisify: (fn: unknown) => fn,
|
|
28
|
-
},
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
({ deleteGarden } = await import("./delete.ts"));
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
it("should return error when name is not provided", async () => {
|
|
35
|
-
const result = await deleteGarden("");
|
|
36
|
-
strictEqual(result.success, false);
|
|
37
|
-
strictEqual(result.message, "Error: garden name required");
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
it("should return error when garden does not exist", async () => {
|
|
41
|
-
accessMock.mock.resetCalls();
|
|
42
|
-
execMock.mock.resetCalls();
|
|
43
|
-
|
|
44
|
-
// Mock getGitRoot
|
|
45
|
-
execMock.mock.mockImplementation((cmd: string) => {
|
|
46
|
-
if (cmd === "git rev-parse --show-toplevel") {
|
|
47
|
-
return Promise.resolve({ stdout: "/test/repo\n", stderr: "" });
|
|
48
|
-
}
|
|
49
|
-
return Promise.resolve({ stdout: "", stderr: "" });
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
// Mock garden doesn't exist
|
|
53
|
-
accessMock.mock.mockImplementation(() => {
|
|
54
|
-
return Promise.reject(new Error("ENOENT"));
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
const result = await deleteGarden("nonexistent-garden");
|
|
58
|
-
|
|
59
|
-
strictEqual(result.success, false);
|
|
60
|
-
strictEqual(
|
|
61
|
-
result.message,
|
|
62
|
-
"Error: Garden 'nonexistent-garden' does not exist",
|
|
63
|
-
);
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
it("should delete clean garden successfully", async () => {
|
|
67
|
-
accessMock.mock.resetCalls();
|
|
68
|
-
execMock.mock.resetCalls();
|
|
69
|
-
|
|
70
|
-
// Mock git commands
|
|
71
|
-
execMock.mock.mockImplementation(
|
|
72
|
-
(cmd: string, options?: { cwd?: string }) => {
|
|
73
|
-
if (cmd === "git rev-parse --show-toplevel") {
|
|
74
|
-
return Promise.resolve({ stdout: "/test/repo\n", stderr: "" });
|
|
75
|
-
}
|
|
76
|
-
if (cmd === "git status --porcelain") {
|
|
77
|
-
return Promise.resolve({ stdout: "", stderr: "" }); // Clean status
|
|
78
|
-
}
|
|
79
|
-
if (cmd.includes("git worktree remove")) {
|
|
80
|
-
return Promise.resolve({ stdout: "", stderr: "" });
|
|
81
|
-
}
|
|
82
|
-
if (cmd.includes("git branch -D")) {
|
|
83
|
-
return Promise.resolve({ stdout: "", stderr: "" });
|
|
84
|
-
}
|
|
85
|
-
return Promise.resolve({ stdout: "", stderr: "" });
|
|
86
|
-
},
|
|
87
|
-
);
|
|
88
|
-
|
|
89
|
-
// Mock garden exists
|
|
90
|
-
accessMock.mock.mockImplementation(() => Promise.resolve());
|
|
91
|
-
|
|
92
|
-
const result = await deleteGarden("clean-garden");
|
|
93
|
-
|
|
94
|
-
strictEqual(result.success, true);
|
|
95
|
-
strictEqual(
|
|
96
|
-
result.message,
|
|
97
|
-
"Deleted garden 'clean-garden' and its branch 'phantom/gardens/clean-garden'",
|
|
98
|
-
);
|
|
99
|
-
strictEqual(result.hasUncommittedChanges, false);
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
it("should refuse to delete dirty garden without --force", async () => {
|
|
103
|
-
accessMock.mock.resetCalls();
|
|
104
|
-
execMock.mock.resetCalls();
|
|
105
|
-
|
|
106
|
-
// Mock git commands
|
|
107
|
-
execMock.mock.mockImplementation(
|
|
108
|
-
(cmd: string, options?: { cwd?: string }) => {
|
|
109
|
-
if (cmd === "git rev-parse --show-toplevel") {
|
|
110
|
-
return Promise.resolve({ stdout: "/test/repo\n", stderr: "" });
|
|
111
|
-
}
|
|
112
|
-
if (cmd === "git status --porcelain") {
|
|
113
|
-
return Promise.resolve({
|
|
114
|
-
stdout: " M file1.ts\n?? file2.ts\n",
|
|
115
|
-
stderr: "",
|
|
116
|
-
}); // Dirty status with 2 files
|
|
117
|
-
}
|
|
118
|
-
return Promise.resolve({ stdout: "", stderr: "" });
|
|
119
|
-
},
|
|
120
|
-
);
|
|
121
|
-
|
|
122
|
-
// Mock garden exists
|
|
123
|
-
accessMock.mock.mockImplementation(() => Promise.resolve());
|
|
124
|
-
|
|
125
|
-
const result = await deleteGarden("dirty-garden");
|
|
126
|
-
|
|
127
|
-
strictEqual(result.success, false);
|
|
128
|
-
strictEqual(
|
|
129
|
-
result.message,
|
|
130
|
-
"Error: Garden 'dirty-garden' has uncommitted changes (2 files). Use --force to delete anyway.",
|
|
131
|
-
);
|
|
132
|
-
strictEqual(result.hasUncommittedChanges, true);
|
|
133
|
-
strictEqual(result.changedFiles, 2);
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
it("should delete dirty garden with --force", async () => {
|
|
137
|
-
accessMock.mock.resetCalls();
|
|
138
|
-
execMock.mock.resetCalls();
|
|
139
|
-
|
|
140
|
-
// Mock git commands
|
|
141
|
-
execMock.mock.mockImplementation(
|
|
142
|
-
(cmd: string, options?: { cwd?: string }) => {
|
|
143
|
-
if (cmd === "git rev-parse --show-toplevel") {
|
|
144
|
-
return Promise.resolve({ stdout: "/test/repo\n", stderr: "" });
|
|
145
|
-
}
|
|
146
|
-
if (cmd === "git status --porcelain") {
|
|
147
|
-
return Promise.resolve({
|
|
148
|
-
stdout: " M file1.ts\n?? file2.ts\n",
|
|
149
|
-
stderr: "",
|
|
150
|
-
}); // Dirty status with 2 files
|
|
151
|
-
}
|
|
152
|
-
if (cmd.includes("git worktree remove")) {
|
|
153
|
-
return Promise.resolve({ stdout: "", stderr: "" });
|
|
154
|
-
}
|
|
155
|
-
if (cmd.includes("git branch -D")) {
|
|
156
|
-
return Promise.resolve({ stdout: "", stderr: "" });
|
|
157
|
-
}
|
|
158
|
-
return Promise.resolve({ stdout: "", stderr: "" });
|
|
159
|
-
},
|
|
160
|
-
);
|
|
161
|
-
|
|
162
|
-
// Mock garden exists
|
|
163
|
-
accessMock.mock.mockImplementation(() => Promise.resolve());
|
|
164
|
-
|
|
165
|
-
const result = await deleteGarden("dirty-garden", { force: true });
|
|
166
|
-
|
|
167
|
-
strictEqual(result.success, true);
|
|
168
|
-
strictEqual(
|
|
169
|
-
result.message,
|
|
170
|
-
"Warning: Garden 'dirty-garden' had uncommitted changes (2 files)\nDeleted garden 'dirty-garden' and its branch 'phantom/gardens/dirty-garden'",
|
|
171
|
-
);
|
|
172
|
-
strictEqual(result.hasUncommittedChanges, true);
|
|
173
|
-
strictEqual(result.changedFiles, 2);
|
|
174
|
-
});
|
|
175
|
-
|
|
176
|
-
it("should handle worktree remove failure and try force removal", async () => {
|
|
177
|
-
accessMock.mock.resetCalls();
|
|
178
|
-
execMock.mock.resetCalls();
|
|
179
|
-
|
|
180
|
-
// Mock git commands
|
|
181
|
-
execMock.mock.mockImplementation(
|
|
182
|
-
(cmd: string, options?: { cwd?: string }) => {
|
|
183
|
-
if (cmd === "git rev-parse --show-toplevel") {
|
|
184
|
-
return Promise.resolve({ stdout: "/test/repo\n", stderr: "" });
|
|
185
|
-
}
|
|
186
|
-
if (cmd === "git status --porcelain") {
|
|
187
|
-
return Promise.resolve({ stdout: "", stderr: "" });
|
|
188
|
-
}
|
|
189
|
-
if (cmd.includes("git worktree remove") && !cmd.includes("--force")) {
|
|
190
|
-
return Promise.reject(new Error("Worktree remove failed"));
|
|
191
|
-
}
|
|
192
|
-
if (cmd.includes("git worktree remove --force")) {
|
|
193
|
-
return Promise.resolve({ stdout: "", stderr: "" });
|
|
194
|
-
}
|
|
195
|
-
if (cmd.includes("git branch -D")) {
|
|
196
|
-
return Promise.resolve({ stdout: "", stderr: "" });
|
|
197
|
-
}
|
|
198
|
-
return Promise.resolve({ stdout: "", stderr: "" });
|
|
199
|
-
},
|
|
200
|
-
);
|
|
201
|
-
|
|
202
|
-
// Mock garden exists
|
|
203
|
-
accessMock.mock.mockImplementation(() => Promise.resolve());
|
|
204
|
-
|
|
205
|
-
const result = await deleteGarden("stubborn-garden");
|
|
206
|
-
|
|
207
|
-
strictEqual(result.success, true);
|
|
208
|
-
strictEqual(
|
|
209
|
-
result.message,
|
|
210
|
-
"Deleted garden 'stubborn-garden' and its branch 'phantom/gardens/stubborn-garden'",
|
|
211
|
-
);
|
|
212
|
-
});
|
|
213
|
-
|
|
214
|
-
it("should handle case where branch doesn't exist", async () => {
|
|
215
|
-
accessMock.mock.resetCalls();
|
|
216
|
-
execMock.mock.resetCalls();
|
|
217
|
-
|
|
218
|
-
// Mock git commands
|
|
219
|
-
execMock.mock.mockImplementation(
|
|
220
|
-
(cmd: string, options?: { cwd?: string }) => {
|
|
221
|
-
if (cmd === "git rev-parse --show-toplevel") {
|
|
222
|
-
return Promise.resolve({ stdout: "/test/repo\n", stderr: "" });
|
|
223
|
-
}
|
|
224
|
-
if (cmd === "git status --porcelain") {
|
|
225
|
-
return Promise.resolve({ stdout: "", stderr: "" });
|
|
226
|
-
}
|
|
227
|
-
if (cmd.includes("git worktree remove")) {
|
|
228
|
-
return Promise.resolve({ stdout: "", stderr: "" });
|
|
229
|
-
}
|
|
230
|
-
if (cmd.includes("git branch -D")) {
|
|
231
|
-
return Promise.reject(new Error("Branch not found"));
|
|
232
|
-
}
|
|
233
|
-
return Promise.resolve({ stdout: "", stderr: "" });
|
|
234
|
-
},
|
|
235
|
-
);
|
|
236
|
-
|
|
237
|
-
// Mock garden exists
|
|
238
|
-
accessMock.mock.mockImplementation(() => Promise.resolve());
|
|
239
|
-
|
|
240
|
-
const result = await deleteGarden("branch-missing-garden");
|
|
241
|
-
|
|
242
|
-
strictEqual(result.success, true);
|
|
243
|
-
strictEqual(
|
|
244
|
-
result.message,
|
|
245
|
-
"Deleted garden 'branch-missing-garden' and its branch 'phantom/gardens/branch-missing-garden'",
|
|
246
|
-
);
|
|
247
|
-
});
|
|
248
|
-
|
|
249
|
-
it("should return error when force worktree removal also fails", async () => {
|
|
250
|
-
accessMock.mock.resetCalls();
|
|
251
|
-
execMock.mock.resetCalls();
|
|
252
|
-
|
|
253
|
-
// Mock git commands
|
|
254
|
-
execMock.mock.mockImplementation(
|
|
255
|
-
(cmd: string, options?: { cwd?: string }) => {
|
|
256
|
-
if (cmd === "git rev-parse --show-toplevel") {
|
|
257
|
-
return Promise.resolve({ stdout: "/test/repo\n", stderr: "" });
|
|
258
|
-
}
|
|
259
|
-
if (cmd === "git status --porcelain") {
|
|
260
|
-
return Promise.resolve({ stdout: "", stderr: "" });
|
|
261
|
-
}
|
|
262
|
-
if (cmd.includes("git worktree remove")) {
|
|
263
|
-
return Promise.reject(new Error("Worktree removal failed"));
|
|
264
|
-
}
|
|
265
|
-
return Promise.resolve({ stdout: "", stderr: "" });
|
|
266
|
-
},
|
|
267
|
-
);
|
|
268
|
-
|
|
269
|
-
// Mock garden exists
|
|
270
|
-
accessMock.mock.mockImplementation(() => Promise.resolve());
|
|
271
|
-
|
|
272
|
-
const result = await deleteGarden("impossible-garden");
|
|
273
|
-
|
|
274
|
-
strictEqual(result.success, false);
|
|
275
|
-
strictEqual(
|
|
276
|
-
result.message,
|
|
277
|
-
"Error: Failed to remove worktree for garden 'impossible-garden'",
|
|
278
|
-
);
|
|
279
|
-
});
|
|
280
|
-
|
|
281
|
-
it("should handle git status errors gracefully", async () => {
|
|
282
|
-
accessMock.mock.resetCalls();
|
|
283
|
-
execMock.mock.resetCalls();
|
|
284
|
-
|
|
285
|
-
// Mock git commands
|
|
286
|
-
execMock.mock.mockImplementation(
|
|
287
|
-
(cmd: string, options?: { cwd?: string }) => {
|
|
288
|
-
if (cmd === "git rev-parse --show-toplevel") {
|
|
289
|
-
return Promise.resolve({ stdout: "/test/repo\n", stderr: "" });
|
|
290
|
-
}
|
|
291
|
-
if (cmd === "git status --porcelain") {
|
|
292
|
-
return Promise.reject(new Error("Git status failed"));
|
|
293
|
-
}
|
|
294
|
-
if (cmd.includes("git worktree remove")) {
|
|
295
|
-
return Promise.resolve({ stdout: "", stderr: "" });
|
|
296
|
-
}
|
|
297
|
-
if (cmd.includes("git branch -D")) {
|
|
298
|
-
return Promise.resolve({ stdout: "", stderr: "" });
|
|
299
|
-
}
|
|
300
|
-
return Promise.resolve({ stdout: "", stderr: "" });
|
|
301
|
-
},
|
|
302
|
-
);
|
|
303
|
-
|
|
304
|
-
// Mock garden exists
|
|
305
|
-
accessMock.mock.mockImplementation(() => Promise.resolve());
|
|
306
|
-
|
|
307
|
-
const result = await deleteGarden("status-error-garden");
|
|
308
|
-
|
|
309
|
-
strictEqual(result.success, true);
|
|
310
|
-
strictEqual(
|
|
311
|
-
result.message,
|
|
312
|
-
"Deleted garden 'status-error-garden' and its branch 'phantom/gardens/status-error-garden'",
|
|
313
|
-
);
|
|
314
|
-
strictEqual(result.hasUncommittedChanges, false);
|
|
315
|
-
});
|
|
316
|
-
});
|
|
@@ -1,134 +0,0 @@
|
|
|
1
|
-
import { exec } from "node:child_process";
|
|
2
|
-
import { access } from "node:fs/promises";
|
|
3
|
-
import { join } from "node:path";
|
|
4
|
-
import { exit } from "node:process";
|
|
5
|
-
import { promisify } from "node:util";
|
|
6
|
-
import { getGitRoot } from "../../git/libs/get-git-root.ts";
|
|
7
|
-
|
|
8
|
-
const execAsync = promisify(exec);
|
|
9
|
-
|
|
10
|
-
export async function deleteGarden(
|
|
11
|
-
name: string,
|
|
12
|
-
options: { force?: boolean } = {},
|
|
13
|
-
): Promise<{
|
|
14
|
-
success: boolean;
|
|
15
|
-
message: string;
|
|
16
|
-
hasUncommittedChanges?: boolean;
|
|
17
|
-
changedFiles?: number;
|
|
18
|
-
}> {
|
|
19
|
-
if (!name) {
|
|
20
|
-
return { success: false, message: "Error: garden name required" };
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
const { force = false } = options;
|
|
24
|
-
|
|
25
|
-
try {
|
|
26
|
-
const gitRoot = await getGitRoot();
|
|
27
|
-
const gardensPath = join(gitRoot, ".git", "phantom", "gardens");
|
|
28
|
-
const gardenPath = join(gardensPath, name);
|
|
29
|
-
|
|
30
|
-
// Check if garden exists
|
|
31
|
-
try {
|
|
32
|
-
await access(gardenPath);
|
|
33
|
-
} catch {
|
|
34
|
-
return {
|
|
35
|
-
success: false,
|
|
36
|
-
message: `Error: Garden '${name}' does not exist`,
|
|
37
|
-
};
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
// Check for uncommitted changes
|
|
41
|
-
let hasUncommittedChanges = false;
|
|
42
|
-
let changedFiles = 0;
|
|
43
|
-
try {
|
|
44
|
-
const { stdout } = await execAsync("git status --porcelain", {
|
|
45
|
-
cwd: gardenPath,
|
|
46
|
-
});
|
|
47
|
-
const changes = stdout.trim();
|
|
48
|
-
if (changes) {
|
|
49
|
-
hasUncommittedChanges = true;
|
|
50
|
-
changedFiles = changes.split("\n").length;
|
|
51
|
-
}
|
|
52
|
-
} catch {
|
|
53
|
-
// If git status fails, assume no changes
|
|
54
|
-
hasUncommittedChanges = false;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
// If garden has uncommitted changes and --force is not specified, refuse deletion
|
|
58
|
-
if (hasUncommittedChanges && !force) {
|
|
59
|
-
return {
|
|
60
|
-
success: false,
|
|
61
|
-
message: `Error: Garden '${name}' has uncommitted changes (${changedFiles} files). Use --force to delete anyway.`,
|
|
62
|
-
hasUncommittedChanges: true,
|
|
63
|
-
changedFiles,
|
|
64
|
-
};
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
// Remove git worktree
|
|
68
|
-
try {
|
|
69
|
-
await execAsync(`git worktree remove "${gardenPath}"`, {
|
|
70
|
-
cwd: gitRoot,
|
|
71
|
-
});
|
|
72
|
-
} catch (error) {
|
|
73
|
-
// If worktree remove fails, try force removal
|
|
74
|
-
try {
|
|
75
|
-
await execAsync(`git worktree remove --force "${gardenPath}"`, {
|
|
76
|
-
cwd: gitRoot,
|
|
77
|
-
});
|
|
78
|
-
} catch {
|
|
79
|
-
return {
|
|
80
|
-
success: false,
|
|
81
|
-
message: `Error: Failed to remove worktree for garden '${name}'`,
|
|
82
|
-
};
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
// Delete associated branch
|
|
87
|
-
const branchName = `phantom/gardens/${name}`;
|
|
88
|
-
try {
|
|
89
|
-
await execAsync(`git branch -D "${branchName}"`, {
|
|
90
|
-
cwd: gitRoot,
|
|
91
|
-
});
|
|
92
|
-
} catch {
|
|
93
|
-
// Branch might not exist or already deleted - this is not an error
|
|
94
|
-
// We'll still report success for the worktree removal
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
let message = `Deleted garden '${name}' and its branch '${branchName}'`;
|
|
98
|
-
if (hasUncommittedChanges) {
|
|
99
|
-
message = `Warning: Garden '${name}' had uncommitted changes (${changedFiles} files)\n${message}`;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
return {
|
|
103
|
-
success: true,
|
|
104
|
-
message,
|
|
105
|
-
hasUncommittedChanges,
|
|
106
|
-
changedFiles: hasUncommittedChanges ? changedFiles : undefined,
|
|
107
|
-
};
|
|
108
|
-
} catch (error) {
|
|
109
|
-
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
110
|
-
return {
|
|
111
|
-
success: false,
|
|
112
|
-
message: `Error deleting garden: ${errorMessage}`,
|
|
113
|
-
};
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
export async function gardensDeleteHandler(args: string[]): Promise<void> {
|
|
118
|
-
// Parse arguments for --force flag
|
|
119
|
-
const forceIndex = args.indexOf("--force");
|
|
120
|
-
const force = forceIndex !== -1;
|
|
121
|
-
|
|
122
|
-
// Remove --force from args to get the garden name
|
|
123
|
-
const filteredArgs = args.filter((arg) => arg !== "--force");
|
|
124
|
-
const name = filteredArgs[0];
|
|
125
|
-
|
|
126
|
-
const result = await deleteGarden(name, { force });
|
|
127
|
-
|
|
128
|
-
if (!result.success) {
|
|
129
|
-
console.error(result.message);
|
|
130
|
-
exit(1);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
console.log(result.message);
|
|
134
|
-
}
|
|
@@ -1,243 +0,0 @@
|
|
|
1
|
-
import { deepStrictEqual, strictEqual } from "node:assert";
|
|
2
|
-
import { before, describe, it, mock } from "node:test";
|
|
3
|
-
|
|
4
|
-
describe("listGardens", () => {
|
|
5
|
-
let accessMock: ReturnType<typeof mock.fn>;
|
|
6
|
-
let readdirMock: ReturnType<typeof mock.fn>;
|
|
7
|
-
let execMock: ReturnType<typeof mock.fn>;
|
|
8
|
-
let listGardens: typeof import("./list.ts").listGardens;
|
|
9
|
-
|
|
10
|
-
before(async () => {
|
|
11
|
-
accessMock = mock.fn();
|
|
12
|
-
readdirMock = mock.fn();
|
|
13
|
-
execMock = mock.fn();
|
|
14
|
-
|
|
15
|
-
mock.module("node:fs/promises", {
|
|
16
|
-
namedExports: {
|
|
17
|
-
access: accessMock,
|
|
18
|
-
readdir: readdirMock,
|
|
19
|
-
},
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
mock.module("node:child_process", {
|
|
23
|
-
namedExports: {
|
|
24
|
-
exec: execMock,
|
|
25
|
-
},
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
mock.module("node:util", {
|
|
29
|
-
namedExports: {
|
|
30
|
-
promisify: (fn: unknown) => fn,
|
|
31
|
-
},
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
({ listGardens } = await import("./list.ts"));
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
it("should return empty array when gardens directory doesn't exist", async () => {
|
|
38
|
-
accessMock.mock.resetCalls();
|
|
39
|
-
readdirMock.mock.resetCalls();
|
|
40
|
-
execMock.mock.resetCalls();
|
|
41
|
-
|
|
42
|
-
// Mock getGitRoot
|
|
43
|
-
execMock.mock.mockImplementation((cmd: string) => {
|
|
44
|
-
if (cmd === "git rev-parse --show-toplevel") {
|
|
45
|
-
return Promise.resolve({ stdout: "/test/repo\n", stderr: "" });
|
|
46
|
-
}
|
|
47
|
-
return Promise.resolve({ stdout: "", stderr: "" });
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
// Mock gardens directory doesn't exist
|
|
51
|
-
accessMock.mock.mockImplementation((path: string) => {
|
|
52
|
-
if (path === "/test/repo/.git/phantom/gardens") {
|
|
53
|
-
return Promise.reject(new Error("ENOENT"));
|
|
54
|
-
}
|
|
55
|
-
return Promise.resolve();
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
const result = await listGardens();
|
|
59
|
-
|
|
60
|
-
strictEqual(result.success, true);
|
|
61
|
-
deepStrictEqual(result.gardens, []);
|
|
62
|
-
strictEqual(
|
|
63
|
-
result.message,
|
|
64
|
-
"No gardens found (gardens directory doesn't exist)",
|
|
65
|
-
);
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
it("should return empty array when gardens directory is empty", async () => {
|
|
69
|
-
accessMock.mock.resetCalls();
|
|
70
|
-
readdirMock.mock.resetCalls();
|
|
71
|
-
execMock.mock.resetCalls();
|
|
72
|
-
|
|
73
|
-
// Mock getGitRoot
|
|
74
|
-
execMock.mock.mockImplementation((cmd: string) => {
|
|
75
|
-
if (cmd === "git rev-parse --show-toplevel") {
|
|
76
|
-
return Promise.resolve({ stdout: "/test/repo\n", stderr: "" });
|
|
77
|
-
}
|
|
78
|
-
return Promise.resolve({ stdout: "", stderr: "" });
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
// Mock gardens directory exists but is empty
|
|
82
|
-
accessMock.mock.mockImplementation(() => Promise.resolve());
|
|
83
|
-
readdirMock.mock.mockImplementation(() => Promise.resolve([]));
|
|
84
|
-
|
|
85
|
-
const result = await listGardens();
|
|
86
|
-
|
|
87
|
-
strictEqual(result.success, true);
|
|
88
|
-
deepStrictEqual(result.gardens, []);
|
|
89
|
-
strictEqual(result.message, "No gardens found");
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
it("should list gardens with clean status", async () => {
|
|
93
|
-
accessMock.mock.resetCalls();
|
|
94
|
-
readdirMock.mock.resetCalls();
|
|
95
|
-
execMock.mock.resetCalls();
|
|
96
|
-
|
|
97
|
-
// Mock getGitRoot and git commands
|
|
98
|
-
execMock.mock.mockImplementation(
|
|
99
|
-
(cmd: string, options?: { cwd?: string }) => {
|
|
100
|
-
if (cmd === "git rev-parse --show-toplevel") {
|
|
101
|
-
return Promise.resolve({ stdout: "/test/repo\n", stderr: "" });
|
|
102
|
-
}
|
|
103
|
-
if (cmd === "git branch --show-current") {
|
|
104
|
-
if (options?.cwd?.includes("test-garden-1")) {
|
|
105
|
-
return Promise.resolve({ stdout: "feature/test\n", stderr: "" });
|
|
106
|
-
}
|
|
107
|
-
if (options?.cwd?.includes("test-garden-2")) {
|
|
108
|
-
return Promise.resolve({ stdout: "main\n", stderr: "" });
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
if (cmd === "git status --porcelain") {
|
|
112
|
-
return Promise.resolve({ stdout: "", stderr: "" }); // Clean status
|
|
113
|
-
}
|
|
114
|
-
return Promise.resolve({ stdout: "", stderr: "" });
|
|
115
|
-
},
|
|
116
|
-
);
|
|
117
|
-
|
|
118
|
-
// Mock gardens directory and contents
|
|
119
|
-
accessMock.mock.mockImplementation(() => Promise.resolve());
|
|
120
|
-
readdirMock.mock.mockImplementation(() =>
|
|
121
|
-
Promise.resolve(["test-garden-1", "test-garden-2"]),
|
|
122
|
-
);
|
|
123
|
-
|
|
124
|
-
const result = await listGardens();
|
|
125
|
-
|
|
126
|
-
strictEqual(result.success, true);
|
|
127
|
-
strictEqual(result.gardens?.length, 2);
|
|
128
|
-
strictEqual(result.gardens?.[0].name, "test-garden-1");
|
|
129
|
-
strictEqual(result.gardens?.[0].branch, "feature/test");
|
|
130
|
-
strictEqual(result.gardens?.[0].status, "clean");
|
|
131
|
-
strictEqual(result.gardens?.[1].name, "test-garden-2");
|
|
132
|
-
strictEqual(result.gardens?.[1].branch, "main");
|
|
133
|
-
strictEqual(result.gardens?.[1].status, "clean");
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
it("should list gardens with dirty status", async () => {
|
|
137
|
-
accessMock.mock.resetCalls();
|
|
138
|
-
readdirMock.mock.resetCalls();
|
|
139
|
-
execMock.mock.resetCalls();
|
|
140
|
-
|
|
141
|
-
// Mock getGitRoot and git commands
|
|
142
|
-
execMock.mock.mockImplementation(
|
|
143
|
-
(cmd: string, options?: { cwd?: string }) => {
|
|
144
|
-
if (cmd === "git rev-parse --show-toplevel") {
|
|
145
|
-
return Promise.resolve({ stdout: "/test/repo\n", stderr: "" });
|
|
146
|
-
}
|
|
147
|
-
if (cmd === "git branch --show-current") {
|
|
148
|
-
return Promise.resolve({ stdout: "feature/dirty\n", stderr: "" });
|
|
149
|
-
}
|
|
150
|
-
if (cmd === "git status --porcelain") {
|
|
151
|
-
return Promise.resolve({
|
|
152
|
-
stdout: " M file1.ts\n?? file2.ts\n",
|
|
153
|
-
stderr: "",
|
|
154
|
-
}); // Dirty status with 2 files
|
|
155
|
-
}
|
|
156
|
-
return Promise.resolve({ stdout: "", stderr: "" });
|
|
157
|
-
},
|
|
158
|
-
);
|
|
159
|
-
|
|
160
|
-
// Mock gardens directory and contents
|
|
161
|
-
accessMock.mock.mockImplementation(() => Promise.resolve());
|
|
162
|
-
readdirMock.mock.mockImplementation(() =>
|
|
163
|
-
Promise.resolve(["dirty-garden"]),
|
|
164
|
-
);
|
|
165
|
-
|
|
166
|
-
const result = await listGardens();
|
|
167
|
-
|
|
168
|
-
strictEqual(result.success, true);
|
|
169
|
-
strictEqual(result.gardens?.length, 1);
|
|
170
|
-
strictEqual(result.gardens?.[0].name, "dirty-garden");
|
|
171
|
-
strictEqual(result.gardens?.[0].branch, "feature/dirty");
|
|
172
|
-
strictEqual(result.gardens?.[0].status, "dirty");
|
|
173
|
-
strictEqual(result.gardens?.[0].changedFiles, 2);
|
|
174
|
-
});
|
|
175
|
-
|
|
176
|
-
it("should handle git command errors gracefully", async () => {
|
|
177
|
-
accessMock.mock.resetCalls();
|
|
178
|
-
readdirMock.mock.resetCalls();
|
|
179
|
-
execMock.mock.resetCalls();
|
|
180
|
-
|
|
181
|
-
// Mock getGitRoot and failing git commands
|
|
182
|
-
execMock.mock.mockImplementation((cmd: string) => {
|
|
183
|
-
if (cmd === "git rev-parse --show-toplevel") {
|
|
184
|
-
return Promise.resolve({ stdout: "/test/repo\n", stderr: "" });
|
|
185
|
-
}
|
|
186
|
-
if (cmd === "git branch --show-current") {
|
|
187
|
-
return Promise.reject(new Error("Not a git repository"));
|
|
188
|
-
}
|
|
189
|
-
if (cmd === "git status --porcelain") {
|
|
190
|
-
return Promise.reject(new Error("Git command failed"));
|
|
191
|
-
}
|
|
192
|
-
return Promise.resolve({ stdout: "", stderr: "" });
|
|
193
|
-
});
|
|
194
|
-
|
|
195
|
-
// Mock gardens directory and contents
|
|
196
|
-
accessMock.mock.mockImplementation(() => Promise.resolve());
|
|
197
|
-
readdirMock.mock.mockImplementation(() =>
|
|
198
|
-
Promise.resolve(["error-garden"]),
|
|
199
|
-
);
|
|
200
|
-
|
|
201
|
-
const result = await listGardens();
|
|
202
|
-
|
|
203
|
-
strictEqual(result.success, true);
|
|
204
|
-
strictEqual(result.gardens?.length, 1);
|
|
205
|
-
strictEqual(result.gardens?.[0].name, "error-garden");
|
|
206
|
-
strictEqual(result.gardens?.[0].branch, "unknown");
|
|
207
|
-
strictEqual(result.gardens?.[0].status, "clean");
|
|
208
|
-
});
|
|
209
|
-
|
|
210
|
-
it("should handle detached HEAD state", async () => {
|
|
211
|
-
accessMock.mock.resetCalls();
|
|
212
|
-
readdirMock.mock.resetCalls();
|
|
213
|
-
execMock.mock.resetCalls();
|
|
214
|
-
|
|
215
|
-
// Mock getGitRoot and git commands
|
|
216
|
-
execMock.mock.mockImplementation((cmd: string) => {
|
|
217
|
-
if (cmd === "git rev-parse --show-toplevel") {
|
|
218
|
-
return Promise.resolve({ stdout: "/test/repo\n", stderr: "" });
|
|
219
|
-
}
|
|
220
|
-
if (cmd === "git branch --show-current") {
|
|
221
|
-
return Promise.resolve({ stdout: "\n", stderr: "" }); // Empty output = detached HEAD
|
|
222
|
-
}
|
|
223
|
-
if (cmd === "git status --porcelain") {
|
|
224
|
-
return Promise.resolve({ stdout: "", stderr: "" });
|
|
225
|
-
}
|
|
226
|
-
return Promise.resolve({ stdout: "", stderr: "" });
|
|
227
|
-
});
|
|
228
|
-
|
|
229
|
-
// Mock gardens directory and contents
|
|
230
|
-
accessMock.mock.mockImplementation(() => Promise.resolve());
|
|
231
|
-
readdirMock.mock.mockImplementation(() =>
|
|
232
|
-
Promise.resolve(["detached-garden"]),
|
|
233
|
-
);
|
|
234
|
-
|
|
235
|
-
const result = await listGardens();
|
|
236
|
-
|
|
237
|
-
strictEqual(result.success, true);
|
|
238
|
-
strictEqual(result.gardens?.length, 1);
|
|
239
|
-
strictEqual(result.gardens?.[0].name, "detached-garden");
|
|
240
|
-
strictEqual(result.gardens?.[0].branch, "detached HEAD");
|
|
241
|
-
strictEqual(result.gardens?.[0].status, "clean");
|
|
242
|
-
});
|
|
243
|
-
});
|