@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.
@@ -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
+ }