@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,176 @@
|
|
|
1
|
+
import { deepStrictEqual, strictEqual } from "node:assert";
|
|
2
|
+
import { before, describe, it, mock } from "node:test";
|
|
3
|
+
|
|
4
|
+
describe("createGarden", () => {
|
|
5
|
+
let accessMock: ReturnType<typeof mock.fn>;
|
|
6
|
+
let mkdirMock: ReturnType<typeof mock.fn>;
|
|
7
|
+
let execMock: ReturnType<typeof mock.fn>;
|
|
8
|
+
let createGarden: typeof import("./create.ts").createGarden;
|
|
9
|
+
|
|
10
|
+
before(async () => {
|
|
11
|
+
accessMock = mock.fn();
|
|
12
|
+
mkdirMock = mock.fn();
|
|
13
|
+
execMock = mock.fn((cmd: string) => {
|
|
14
|
+
if (cmd === "git rev-parse --show-toplevel") {
|
|
15
|
+
return Promise.resolve({ stdout: "/test/repo\n", stderr: "" });
|
|
16
|
+
}
|
|
17
|
+
if (cmd.startsWith("git worktree add")) {
|
|
18
|
+
return Promise.resolve({ stdout: "", stderr: "" });
|
|
19
|
+
}
|
|
20
|
+
return Promise.resolve({ stdout: "", stderr: "" });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
mock.module("node:fs/promises", {
|
|
24
|
+
namedExports: {
|
|
25
|
+
access: accessMock,
|
|
26
|
+
mkdir: mkdirMock,
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
mock.module("node:child_process", {
|
|
31
|
+
namedExports: {
|
|
32
|
+
exec: execMock,
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
mock.module("node:util", {
|
|
37
|
+
namedExports: {
|
|
38
|
+
promisify: (fn: unknown) => fn,
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
({ createGarden } = await import("./create.ts"));
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("should return error when name is not provided", async () => {
|
|
46
|
+
const result = await createGarden("");
|
|
47
|
+
strictEqual(result.success, false);
|
|
48
|
+
strictEqual(result.message, "Error: garden name required");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("should create garden directory when it does not exist", async () => {
|
|
52
|
+
accessMock.mock.resetCalls();
|
|
53
|
+
mkdirMock.mock.resetCalls();
|
|
54
|
+
execMock.mock.resetCalls();
|
|
55
|
+
|
|
56
|
+
accessMock.mock.mockImplementation((path: string) => {
|
|
57
|
+
if (path === "/test/repo/.git/phantom/gardens") {
|
|
58
|
+
return Promise.reject(new Error("ENOENT"));
|
|
59
|
+
}
|
|
60
|
+
if (path === "/test/repo/.git/phantom/gardens/test-garden") {
|
|
61
|
+
return Promise.reject(new Error("ENOENT"));
|
|
62
|
+
}
|
|
63
|
+
return Promise.resolve();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
execMock.mock.mockImplementation((cmd: string) => {
|
|
67
|
+
if (cmd === "git rev-parse --show-toplevel") {
|
|
68
|
+
return Promise.resolve({ stdout: "/test/repo\n", stderr: "" });
|
|
69
|
+
}
|
|
70
|
+
if (cmd.startsWith("git worktree add")) {
|
|
71
|
+
return Promise.resolve({ stdout: "", stderr: "" });
|
|
72
|
+
}
|
|
73
|
+
return Promise.resolve({ stdout: "", stderr: "" });
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const result = await createGarden("test-garden");
|
|
77
|
+
|
|
78
|
+
strictEqual(result.success, true);
|
|
79
|
+
strictEqual(
|
|
80
|
+
result.message,
|
|
81
|
+
"Created garden 'test-garden' at /test/repo/.git/phantom/gardens/test-garden",
|
|
82
|
+
);
|
|
83
|
+
strictEqual(result.path, "/test/repo/.git/phantom/gardens/test-garden");
|
|
84
|
+
|
|
85
|
+
strictEqual(mkdirMock.mock.calls.length, 1);
|
|
86
|
+
deepStrictEqual(mkdirMock.mock.calls[0].arguments, [
|
|
87
|
+
"/test/repo/.git/phantom/gardens",
|
|
88
|
+
{ recursive: true },
|
|
89
|
+
]);
|
|
90
|
+
|
|
91
|
+
strictEqual(execMock.mock.calls.length, 2);
|
|
92
|
+
strictEqual(
|
|
93
|
+
execMock.mock.calls[0].arguments[0],
|
|
94
|
+
"git rev-parse --show-toplevel",
|
|
95
|
+
);
|
|
96
|
+
strictEqual(
|
|
97
|
+
execMock.mock.calls[1].arguments[0],
|
|
98
|
+
'git worktree add "/test/repo/.git/phantom/gardens/test-garden" -b "phantom/gardens/test-garden" HEAD',
|
|
99
|
+
);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("should return error when garden already exists", async () => {
|
|
103
|
+
accessMock.mock.resetCalls();
|
|
104
|
+
mkdirMock.mock.resetCalls();
|
|
105
|
+
execMock.mock.resetCalls();
|
|
106
|
+
|
|
107
|
+
accessMock.mock.mockImplementation((path: string) => {
|
|
108
|
+
if (path === "/test/repo/.git/phantom/gardens") {
|
|
109
|
+
return Promise.resolve();
|
|
110
|
+
}
|
|
111
|
+
if (path === "/test/repo/.git/phantom/gardens/existing-garden") {
|
|
112
|
+
return Promise.resolve();
|
|
113
|
+
}
|
|
114
|
+
return Promise.reject(new Error("ENOENT"));
|
|
115
|
+
});
|
|
116
|
+
execMock.mock.mockImplementation((cmd: string) => {
|
|
117
|
+
if (cmd === "git rev-parse --show-toplevel") {
|
|
118
|
+
return Promise.resolve({ stdout: "/test/repo\n", stderr: "" });
|
|
119
|
+
}
|
|
120
|
+
return Promise.resolve({ stdout: "", stderr: "" });
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const result = await createGarden("existing-garden");
|
|
124
|
+
|
|
125
|
+
strictEqual(result.success, false);
|
|
126
|
+
strictEqual(
|
|
127
|
+
result.message,
|
|
128
|
+
"Error: garden 'existing-garden' already exists",
|
|
129
|
+
);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("should handle git command errors", async () => {
|
|
133
|
+
accessMock.mock.resetCalls();
|
|
134
|
+
mkdirMock.mock.resetCalls();
|
|
135
|
+
execMock.mock.resetCalls();
|
|
136
|
+
|
|
137
|
+
execMock.mock.mockImplementation(() => {
|
|
138
|
+
return Promise.reject(new Error("Not a git repository"));
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const result = await createGarden("test-garden");
|
|
142
|
+
|
|
143
|
+
strictEqual(result.success, false);
|
|
144
|
+
strictEqual(result.message, "Error creating garden: Not a git repository");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("should not create gardens directory if it already exists", async () => {
|
|
148
|
+
accessMock.mock.resetCalls();
|
|
149
|
+
mkdirMock.mock.resetCalls();
|
|
150
|
+
execMock.mock.resetCalls();
|
|
151
|
+
|
|
152
|
+
accessMock.mock.mockImplementation((path: string) => {
|
|
153
|
+
if (path === "/test/repo/.git/phantom/gardens") {
|
|
154
|
+
return Promise.resolve();
|
|
155
|
+
}
|
|
156
|
+
if (path === "/test/repo/.git/phantom/gardens/test-garden") {
|
|
157
|
+
return Promise.reject(new Error("ENOENT"));
|
|
158
|
+
}
|
|
159
|
+
return Promise.reject(new Error("ENOENT"));
|
|
160
|
+
});
|
|
161
|
+
execMock.mock.mockImplementation((cmd: string) => {
|
|
162
|
+
if (cmd === "git rev-parse --show-toplevel") {
|
|
163
|
+
return Promise.resolve({ stdout: "/test/repo\n", stderr: "" });
|
|
164
|
+
}
|
|
165
|
+
if (cmd.startsWith("git worktree add")) {
|
|
166
|
+
return Promise.resolve({ stdout: "", stderr: "" });
|
|
167
|
+
}
|
|
168
|
+
return Promise.resolve({ stdout: "", stderr: "" });
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
const result = await createGarden("test-garden");
|
|
172
|
+
|
|
173
|
+
strictEqual(result.success, true);
|
|
174
|
+
strictEqual(mkdirMock.mock.calls.length, 0);
|
|
175
|
+
});
|
|
176
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { access, mkdir } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { exit } from "node:process";
|
|
4
|
+
import { addWorktree } from "../../git/libs/add-worktree.ts";
|
|
5
|
+
import { getGitRoot } from "../../git/libs/get-git-root.ts";
|
|
6
|
+
|
|
7
|
+
export async function createGarden(name: string): Promise<{
|
|
8
|
+
success: boolean;
|
|
9
|
+
message: string;
|
|
10
|
+
path?: string;
|
|
11
|
+
}> {
|
|
12
|
+
if (!name) {
|
|
13
|
+
return { success: false, message: "Error: garden name required" };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
const gitRoot = await getGitRoot();
|
|
18
|
+
const gardensPath = join(gitRoot, ".git", "phantom", "gardens");
|
|
19
|
+
const worktreePath = join(gardensPath, name);
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
await access(gardensPath);
|
|
23
|
+
} catch {
|
|
24
|
+
await mkdir(gardensPath, { recursive: true });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
await access(worktreePath);
|
|
29
|
+
return {
|
|
30
|
+
success: false,
|
|
31
|
+
message: `Error: garden '${name}' already exists`,
|
|
32
|
+
};
|
|
33
|
+
} catch {
|
|
34
|
+
// Path doesn't exist, which is what we want
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
await addWorktree({
|
|
38
|
+
path: worktreePath,
|
|
39
|
+
branch: `phantom/gardens/${name}`,
|
|
40
|
+
commitish: "HEAD",
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
success: true,
|
|
45
|
+
message: `Created garden '${name}' at ${worktreePath}`,
|
|
46
|
+
path: worktreePath,
|
|
47
|
+
};
|
|
48
|
+
} catch (error) {
|
|
49
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
50
|
+
return {
|
|
51
|
+
success: false,
|
|
52
|
+
message: `Error creating garden: ${errorMessage}`,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function gardensCreateHandler(args: string[]): Promise<void> {
|
|
58
|
+
const name = args[0];
|
|
59
|
+
const result = await createGarden(name);
|
|
60
|
+
|
|
61
|
+
if (!result.success) {
|
|
62
|
+
console.error(result.message);
|
|
63
|
+
exit(1);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
console.log(result.message);
|
|
67
|
+
}
|
|
@@ -0,0 +1,316 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,134 @@
|
|
|
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
|
+
}
|