@aku11i/phantom 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +191 -0
- package/package.json +53 -0
- package/src/bin/garden.ts +74 -0
- package/src/bin/phantom.ts +121 -0
- package/src/commands/exec.test.ts +256 -0
- package/src/commands/exec.ts +81 -0
- package/src/commands/shell.test.ts +248 -0
- package/src/commands/shell.ts +91 -0
- package/src/gardens/commands/create.test.ts +176 -0
- package/src/gardens/commands/create.ts +67 -0
- package/src/gardens/commands/delete.test.ts +316 -0
- package/src/gardens/commands/delete.ts +134 -0
- package/src/gardens/commands/list.test.ts +243 -0
- package/src/gardens/commands/list.ts +152 -0
- package/src/gardens/commands/where.test.ts +149 -0
- package/src/gardens/commands/where.ts +53 -0
- package/src/git/libs/add-worktree.ts +16 -0
- package/src/git/libs/get-current-branch.ts +9 -0
- package/src/git/libs/get-git-root.ts +9 -0
|
@@ -0,0 +1,243 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { exec } from "node:child_process";
|
|
2
|
+
import { access, readdir } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
import { getGitRoot } from "../../git/libs/get-git-root.ts";
|
|
6
|
+
|
|
7
|
+
const execAsync = promisify(exec);
|
|
8
|
+
|
|
9
|
+
export interface GardenInfo {
|
|
10
|
+
name: string;
|
|
11
|
+
branch: string;
|
|
12
|
+
status: "clean" | "dirty";
|
|
13
|
+
changedFiles?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function listGardens(): Promise<{
|
|
17
|
+
success: boolean;
|
|
18
|
+
message?: string;
|
|
19
|
+
gardens?: GardenInfo[];
|
|
20
|
+
}> {
|
|
21
|
+
try {
|
|
22
|
+
const gitRoot = await getGitRoot();
|
|
23
|
+
const gardensPath = join(gitRoot, ".git", "phantom", "gardens");
|
|
24
|
+
|
|
25
|
+
// Check if gardens directory exists
|
|
26
|
+
try {
|
|
27
|
+
await access(gardensPath);
|
|
28
|
+
} catch {
|
|
29
|
+
return {
|
|
30
|
+
success: true,
|
|
31
|
+
gardens: [],
|
|
32
|
+
message: "No gardens found (gardens directory doesn't exist)",
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Read gardens directory
|
|
37
|
+
let gardenNames: string[];
|
|
38
|
+
try {
|
|
39
|
+
const entries = await readdir(gardensPath);
|
|
40
|
+
// Filter entries to only include directories
|
|
41
|
+
const validEntries = await Promise.all(
|
|
42
|
+
entries.map(async (entry) => {
|
|
43
|
+
try {
|
|
44
|
+
const entryPath = join(gardensPath, entry);
|
|
45
|
+
await access(entryPath);
|
|
46
|
+
return entry;
|
|
47
|
+
} catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}),
|
|
51
|
+
);
|
|
52
|
+
gardenNames = validEntries.filter(
|
|
53
|
+
(entry): entry is string => entry !== null,
|
|
54
|
+
);
|
|
55
|
+
} catch {
|
|
56
|
+
return {
|
|
57
|
+
success: true,
|
|
58
|
+
gardens: [],
|
|
59
|
+
message: "No gardens found (unable to read gardens directory)",
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (gardenNames.length === 0) {
|
|
64
|
+
return {
|
|
65
|
+
success: true,
|
|
66
|
+
gardens: [],
|
|
67
|
+
message: "No gardens found",
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Get detailed information for each garden
|
|
72
|
+
const gardens: GardenInfo[] = await Promise.all(
|
|
73
|
+
gardenNames.map(async (name) => {
|
|
74
|
+
const gardenPath = join(gardensPath, name);
|
|
75
|
+
|
|
76
|
+
// Get current branch
|
|
77
|
+
let branch = "unknown";
|
|
78
|
+
try {
|
|
79
|
+
const { stdout } = await execAsync("git branch --show-current", {
|
|
80
|
+
cwd: gardenPath,
|
|
81
|
+
});
|
|
82
|
+
branch = stdout.trim() || "detached HEAD";
|
|
83
|
+
} catch {
|
|
84
|
+
branch = "unknown";
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Get working directory status
|
|
88
|
+
let status: "clean" | "dirty" = "clean";
|
|
89
|
+
let changedFiles: number | undefined;
|
|
90
|
+
try {
|
|
91
|
+
const { stdout } = await execAsync("git status --porcelain", {
|
|
92
|
+
cwd: gardenPath,
|
|
93
|
+
});
|
|
94
|
+
const changes = stdout.trim();
|
|
95
|
+
if (changes) {
|
|
96
|
+
status = "dirty";
|
|
97
|
+
changedFiles = changes.split("\n").length;
|
|
98
|
+
}
|
|
99
|
+
} catch {
|
|
100
|
+
// If git status fails, assume unknown status
|
|
101
|
+
status = "clean";
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
name,
|
|
106
|
+
branch,
|
|
107
|
+
status,
|
|
108
|
+
changedFiles,
|
|
109
|
+
};
|
|
110
|
+
}),
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
success: true,
|
|
115
|
+
gardens,
|
|
116
|
+
};
|
|
117
|
+
} catch (error) {
|
|
118
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
119
|
+
return {
|
|
120
|
+
success: false,
|
|
121
|
+
message: `Error listing gardens: ${errorMessage}`,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function gardensListHandler(): Promise<void> {
|
|
127
|
+
const result = await listGardens();
|
|
128
|
+
|
|
129
|
+
if (!result.success) {
|
|
130
|
+
console.error(result.message);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (!result.gardens || result.gardens.length === 0) {
|
|
135
|
+
console.log(result.message || "No gardens found");
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
console.log("Gardens:");
|
|
140
|
+
for (const garden of result.gardens) {
|
|
141
|
+
const statusText =
|
|
142
|
+
garden.status === "clean"
|
|
143
|
+
? "[clean]"
|
|
144
|
+
: `[dirty: ${garden.changedFiles} files]`;
|
|
145
|
+
|
|
146
|
+
console.log(
|
|
147
|
+
` ${garden.name.padEnd(20)} (branch: ${garden.branch.padEnd(20)}) ${statusText}`,
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
console.log(`\nTotal: ${result.gardens.length} gardens`);
|
|
152
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { strictEqual } from "node:assert";
|
|
2
|
+
import { before, describe, it, mock } from "node:test";
|
|
3
|
+
|
|
4
|
+
describe("whereGarden", () => {
|
|
5
|
+
let accessMock: ReturnType<typeof mock.fn>;
|
|
6
|
+
let execMock: ReturnType<typeof mock.fn>;
|
|
7
|
+
let whereGarden: typeof import("./where.ts").whereGarden;
|
|
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
|
+
({ whereGarden } = await import("./where.ts"));
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("should return error when name is not provided", async () => {
|
|
35
|
+
const result = await whereGarden("");
|
|
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 whereGarden("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 return path when garden exists", async () => {
|
|
67
|
+
accessMock.mock.resetCalls();
|
|
68
|
+
execMock.mock.resetCalls();
|
|
69
|
+
|
|
70
|
+
// Mock getGitRoot
|
|
71
|
+
execMock.mock.mockImplementation((cmd: string) => {
|
|
72
|
+
if (cmd === "git rev-parse --show-toplevel") {
|
|
73
|
+
return Promise.resolve({ stdout: "/test/repo\n", stderr: "" });
|
|
74
|
+
}
|
|
75
|
+
return Promise.resolve({ stdout: "", stderr: "" });
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// Mock garden exists
|
|
79
|
+
accessMock.mock.mockImplementation(() => Promise.resolve());
|
|
80
|
+
|
|
81
|
+
const result = await whereGarden("existing-garden");
|
|
82
|
+
|
|
83
|
+
strictEqual(result.success, true);
|
|
84
|
+
strictEqual(result.path, "/test/repo/.git/phantom/gardens/existing-garden");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("should handle git root detection failures", async () => {
|
|
88
|
+
accessMock.mock.resetCalls();
|
|
89
|
+
execMock.mock.resetCalls();
|
|
90
|
+
|
|
91
|
+
// Mock getGitRoot failure
|
|
92
|
+
execMock.mock.mockImplementation(() => {
|
|
93
|
+
return Promise.reject(new Error("Not a git repository"));
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const result = await whereGarden("some-garden");
|
|
97
|
+
|
|
98
|
+
strictEqual(result.success, false);
|
|
99
|
+
strictEqual(result.message, "Error locating garden: Not a git repository");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("should handle different garden names correctly", async () => {
|
|
103
|
+
accessMock.mock.resetCalls();
|
|
104
|
+
execMock.mock.resetCalls();
|
|
105
|
+
|
|
106
|
+
// Mock getGitRoot
|
|
107
|
+
execMock.mock.mockImplementation((cmd: string) => {
|
|
108
|
+
if (cmd === "git rev-parse --show-toplevel") {
|
|
109
|
+
return Promise.resolve({ stdout: "/different/repo\n", stderr: "" });
|
|
110
|
+
}
|
|
111
|
+
return Promise.resolve({ stdout: "", stderr: "" });
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// Mock garden exists
|
|
115
|
+
accessMock.mock.mockImplementation(() => Promise.resolve());
|
|
116
|
+
|
|
117
|
+
const result = await whereGarden("feature-branch-123");
|
|
118
|
+
|
|
119
|
+
strictEqual(result.success, true);
|
|
120
|
+
strictEqual(
|
|
121
|
+
result.path,
|
|
122
|
+
"/different/repo/.git/phantom/gardens/feature-branch-123",
|
|
123
|
+
);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("should handle special characters in garden names", async () => {
|
|
127
|
+
accessMock.mock.resetCalls();
|
|
128
|
+
execMock.mock.resetCalls();
|
|
129
|
+
|
|
130
|
+
// Mock getGitRoot
|
|
131
|
+
execMock.mock.mockImplementation((cmd: string) => {
|
|
132
|
+
if (cmd === "git rev-parse --show-toplevel") {
|
|
133
|
+
return Promise.resolve({ stdout: "/test/repo\n", stderr: "" });
|
|
134
|
+
}
|
|
135
|
+
return Promise.resolve({ stdout: "", stderr: "" });
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// Mock garden exists
|
|
139
|
+
accessMock.mock.mockImplementation(() => Promise.resolve());
|
|
140
|
+
|
|
141
|
+
const result = await whereGarden("feature-with-dashes_and_underscores");
|
|
142
|
+
|
|
143
|
+
strictEqual(result.success, true);
|
|
144
|
+
strictEqual(
|
|
145
|
+
result.path,
|
|
146
|
+
"/test/repo/.git/phantom/gardens/feature-with-dashes_and_underscores",
|
|
147
|
+
);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { access } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { exit } from "node:process";
|
|
4
|
+
import { getGitRoot } from "../../git/libs/get-git-root.ts";
|
|
5
|
+
|
|
6
|
+
export async function whereGarden(name: string): Promise<{
|
|
7
|
+
success: boolean;
|
|
8
|
+
message?: string;
|
|
9
|
+
path?: string;
|
|
10
|
+
}> {
|
|
11
|
+
if (!name) {
|
|
12
|
+
return { success: false, message: "Error: garden name required" };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
const gitRoot = await getGitRoot();
|
|
17
|
+
const gardensPath = join(gitRoot, ".git", "phantom", "gardens");
|
|
18
|
+
const gardenPath = join(gardensPath, name);
|
|
19
|
+
|
|
20
|
+
// Check if garden exists
|
|
21
|
+
try {
|
|
22
|
+
await access(gardenPath);
|
|
23
|
+
} catch {
|
|
24
|
+
return {
|
|
25
|
+
success: false,
|
|
26
|
+
message: `Error: Garden '${name}' does not exist`,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
success: true,
|
|
32
|
+
path: gardenPath,
|
|
33
|
+
};
|
|
34
|
+
} catch (error) {
|
|
35
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
36
|
+
return {
|
|
37
|
+
success: false,
|
|
38
|
+
message: `Error locating garden: ${errorMessage}`,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function gardensWhereHandler(args: string[]): Promise<void> {
|
|
44
|
+
const name = args[0];
|
|
45
|
+
const result = await whereGarden(name);
|
|
46
|
+
|
|
47
|
+
if (!result.success) {
|
|
48
|
+
console.error(result.message);
|
|
49
|
+
exit(1);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
console.log(result.path);
|
|
53
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { exec } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
|
|
4
|
+
const execAsync = promisify(exec);
|
|
5
|
+
|
|
6
|
+
export interface AddWorktreeOptions {
|
|
7
|
+
path: string;
|
|
8
|
+
branch: string;
|
|
9
|
+
commitish?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function addWorktree(options: AddWorktreeOptions): Promise<void> {
|
|
13
|
+
const { path, branch, commitish = "HEAD" } = options;
|
|
14
|
+
|
|
15
|
+
await execAsync(`git worktree add "${path}" -b "${branch}" ${commitish}`);
|
|
16
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { exec } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
|
|
4
|
+
const execAsync = promisify(exec);
|
|
5
|
+
|
|
6
|
+
export async function getCurrentBranch(): Promise<string> {
|
|
7
|
+
const { stdout } = await execAsync("git branch --show-current");
|
|
8
|
+
return stdout.trim();
|
|
9
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { exec } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
|
|
4
|
+
const execAsync = promisify(exec);
|
|
5
|
+
|
|
6
|
+
export async function getGitRoot(): Promise<string> {
|
|
7
|
+
const { stdout } = await execAsync("git rev-parse --show-toplevel");
|
|
8
|
+
return stdout.trim();
|
|
9
|
+
}
|