tdd-enforcer 0.2.6 → 0.2.7

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.
@@ -1,530 +1,552 @@
1
- import { describe, it, expect, beforeEach, vi } from "vitest";
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import type { GitDeps } from "./git.js";
2
3
  import {
3
- initGit,
4
- snapshot,
5
- modifiedFiles,
6
- untrackedFiles,
7
- changesSinceSnapshot,
8
- restoreFilesTo,
9
- headHash,
10
- headMessage,
11
- hasParent,
12
- resetHard,
13
- undoLastCommit,
14
- gitStashCreate,
15
- stageFiles,
4
+ changesSinceSnapshot,
5
+ gitStashCreate,
6
+ hasParent,
7
+ headHash,
8
+ headMessage,
9
+ initGit,
10
+ modifiedFiles,
11
+ resetHard,
12
+ restoreFilesTo,
13
+ snapshot,
14
+ stageFiles,
15
+ undoLastCommit,
16
+ untrackedFiles,
16
17
  } from "./git.js";
17
- import type { GitDeps } from "./git.js";
18
18
 
19
19
  describe("git operations", () => {
20
- let deps: GitDeps;
21
- let outputs: Record<string, string>;
22
-
23
- function makeDeps(): GitDeps {
24
- const mockExecSync = vi.fn((cmd: string) => {
25
- for (const [prefix, out] of Object.entries(outputs)) {
26
- if (cmd.includes(prefix)) return Buffer.from(out);
27
- }
28
- return Buffer.from("");
29
- });
30
- return {
31
- execSync: mockExecSync as any,
32
- existsSync: vi.fn().mockReturnValue(false),
33
- mkdirSync: vi.fn(),
34
- writeFileSync: vi.fn(),
35
- unlinkSync: vi.fn(),
36
- rmSync: vi.fn(),
37
- };
38
- }
39
-
40
- beforeEach(() => {
41
- outputs = {};
42
- deps = makeDeps();
43
- });
44
-
45
- it("initGit creates private git repo", () => {
46
- initGit("/test", deps);
47
- expect(deps.execSync).toHaveBeenCalledWith(
48
- expect.stringContaining("git init"),
49
- expect.any(Object),
50
- );
51
- expect(deps.execSync).toHaveBeenCalledWith(
52
- expect.stringContaining("git config core.worktree"),
53
- expect.any(Object),
54
- );
55
- expect(deps.mkdirSync).toHaveBeenCalled();
56
- expect(deps.writeFileSync).toHaveBeenCalled();
57
- // Every git command targets the private repo, not the main project .git
58
- for (const call of (deps.execSync as any).mock.calls) {
59
- expect(call[1].env.GIT_DIR).toBe("/test/.pi/tdd/.git");
60
- expect(call[1].env.GIT_WORK_TREE).toBe("/test");
61
- }
62
- });
63
-
64
- it("has initial commit after init", () => {
65
- outputs["rev-parse HEAD"] = "abc123def456\n";
66
- const hash = headHash("/test", deps);
67
- expect(hash).toBe("abc123def456");
68
- });
69
-
70
- it("snapshot captures changes", () => {
71
- outputs["rev-parse HEAD"] = "hash123\n";
72
- const hash = snapshot("/test", "green", deps);
73
- expect(hash).toBe("hash123");
74
- expect(deps.execSync).toHaveBeenCalledWith(
75
- expect.stringContaining('commit --allow-empty -m "tdd: green"'),
76
- expect.any(Object),
77
- );
78
- });
79
-
80
- it("modifiedFiles returns changed files", () => {
81
- outputs["diff --name-only HEAD"] = "src/main.ts\n";
82
- const modified = modifiedFiles("/test", deps);
83
- expect(modified).toContain("src/main.ts");
84
- });
85
-
86
- it("untrackedFiles returns new files", () => {
87
- outputs["ls-files --others --exclude-standard"] = "newfile.ts\n";
88
- const untracked = untrackedFiles("/test", deps);
89
- expect(untracked).toContain("newfile.ts");
90
- });
91
-
92
- it("changesSinceSnapshot combines modified + untracked", () => {
93
- outputs["diff --name-only HEAD"] = "src/main.ts\n";
94
- outputs["ls-files --others --exclude-standard"] = "newfile.ts\n";
95
- const changes = changesSinceSnapshot("/test", deps);
96
- expect(changes).toContain("src/main.ts");
97
- expect(changes).toContain("newfile.ts");
98
- });
99
-
100
- it("restoreFilesTo reverts specific files", () => {
101
- outputs["ls-files"] = "src/main.ts\n";
102
- restoreFilesTo("/test", ["src/main.ts"], undefined, deps);
103
- expect(deps.execSync).toHaveBeenCalledWith(
104
- expect.stringContaining("git restore"),
105
- expect.any(Object),
106
- );
107
- });
108
-
109
- it("modifiedFiles returns empty when HEAD matches working tree", () => {
110
- outputs["diff --name-only HEAD"] = "";
111
- const modified = modifiedFiles("/test", deps);
112
- expect(modified).not.toContain("src/main.ts");
113
- });
114
-
115
- it("untrackedFiles returns empty when no new files", () => {
116
- outputs["ls-files --others --exclude-standard"] = "";
117
- const untracked = untrackedFiles("/test", deps);
118
- expect(untracked).not.toContain("src/main.ts");
119
- });
120
-
121
- it("changesSinceSnapshot deduplicates when file is both modified and untracked", () => {
122
- outputs["diff --name-only HEAD"] = "file.txt\n";
123
- outputs["ls-files --others --exclude-standard"] = "file.txt\n";
124
- const changes = changesSinceSnapshot("/test", deps);
125
- const count = changes.filter((f) => f === "file.txt").length;
126
- expect(count).toBe(1);
127
- });
128
-
129
- it("restoreFilesTo does nothing when files list is empty", () => {
130
- expect(() => restoreFilesTo("/test", [], undefined, deps)).not.toThrow();
131
- expect(deps.execSync).not.toHaveBeenCalled();
132
- });
133
-
134
- it("initGit force-adds TDD config files to bypass worktree gitignore", () => {
135
- deps.existsSync = vi.fn().mockImplementation((p: string) =>
136
- p.includes(".pi/tdd/") && !p.endsWith(".git"),
137
- );
138
- initGit("/test", deps);
139
- const call = (deps.execSync as any).mock.calls.find(
140
- (c: any[]) => c[0].includes("git add -f"),
141
- );
142
- expect(call).toBeDefined();
143
- expect(call[0]).toContain(".pi/tdd/state.json");
144
- expect(call[0]).toContain(".pi/tdd/rules.json");
145
- expect(call[0]).toContain(".pi/tdd/.gitignore");
146
- expect(call[0]).not.toContain("tdd.log");
147
- // Isolation: all git commands target the private repo, not the main project .git
148
- expect(call[1].env.GIT_DIR).toBe("/test/.pi/tdd/.git");
149
- expect(call[1].env.GIT_WORK_TREE).toBe("/test");
150
- });
151
-
152
- it("initGit force-adds after add -A and before commit", () => {
153
- deps.existsSync = vi.fn().mockImplementation((p: string) =>
154
- p.includes(".pi/tdd/") && !p.endsWith(".git"),
155
- );
156
- initGit("/test", deps);
157
- const calls = (deps.execSync as any).mock.calls.map((c: any[]) => c[0]);
158
- const addAIndex = calls.findIndex((c: string) => c.includes("git add -A "));
159
- const forceAddIndex = calls.findIndex((c: string) => c.includes("git add -f"));
160
- const commitIndex = calls.findIndex((c: string) => c.includes("git commit"));
161
- expect(addAIndex).toBeLessThan(forceAddIndex);
162
- expect(forceAddIndex).toBeLessThan(commitIndex);
163
- });
164
-
165
- it("snapshot force-adds state.json, rules.json, .gitignore", () => {
166
- outputs["rev-parse HEAD"] = "hash123\n";
167
- deps.existsSync = vi.fn().mockImplementation((p: string) =>
168
- p.includes(".pi/tdd/state.json") ||
169
- p.includes(".pi/tdd/rules.json") ||
170
- p.includes(".pi/tdd/.gitignore"),
171
- );
172
- snapshot("/test", "green", deps);
173
- const call = (deps.execSync as any).mock.calls.find(
174
- (c: any[]) => c[0].includes("git add -f"),
175
- );
176
- expect(call).toBeDefined();
177
- expect(call[0]).toContain(".pi/tdd/state.json");
178
- expect(call[0]).toContain(".pi/tdd/rules.json");
179
- expect(call[0]).toContain(".pi/tdd/.gitignore");
180
- expect(call[0]).not.toContain("tdd.log");
181
- // Isolation: all git commands target the private repo, not the main project .git
182
- expect(call[1].env.GIT_DIR).toBe("/test/.pi/tdd/.git");
183
- expect(call[1].env.GIT_WORK_TREE).toBe("/test");
184
- });
185
-
186
- it("snapshot force-adds after add -A and before commit", () => {
187
- outputs["rev-parse HEAD"] = "hash456\n";
188
- deps.existsSync = vi.fn().mockImplementation((p: string) =>
189
- p.includes(".pi/tdd/state.json") ||
190
- p.includes(".pi/tdd/rules.json") ||
191
- p.includes(".pi/tdd/.gitignore"),
192
- );
193
- snapshot("/test", "red", deps);
194
- const calls = (deps.execSync as any).mock.calls.map((c: any[]) => c[0]);
195
- const addAIndex = calls.findIndex((c: string) => c.includes("git add -A "));
196
- const forceAddIndex = calls.findIndex((c: string) => c.includes("git add -f"));
197
- const commitIndex = calls.findIndex((c: string) => c.includes("git commit"));
198
- expect(addAIndex).toBeLessThan(forceAddIndex);
199
- expect(forceAddIndex).toBeLessThan(commitIndex);
200
- });
20
+ let deps: GitDeps;
21
+ let outputs: Record<string, string>;
22
+
23
+ function makeDeps(): GitDeps {
24
+ const mockExecSync = vi.fn((cmd: string) => {
25
+ for (const [prefix, out] of Object.entries(outputs)) {
26
+ if (cmd.includes(prefix)) return Buffer.from(out);
27
+ }
28
+ return Buffer.from("");
29
+ });
30
+ return {
31
+ execSync: mockExecSync as any,
32
+ existsSync: vi.fn().mockReturnValue(false),
33
+ mkdirSync: vi.fn(),
34
+ writeFileSync: vi.fn(),
35
+ unlinkSync: vi.fn(),
36
+ rmSync: vi.fn(),
37
+ };
38
+ }
39
+
40
+ beforeEach(() => {
41
+ outputs = {};
42
+ deps = makeDeps();
43
+ });
44
+
45
+ it("initGit creates private git repo", () => {
46
+ initGit("/test", deps);
47
+ expect(deps.execSync).toHaveBeenCalledWith(
48
+ expect.stringContaining("git init"),
49
+ expect.any(Object),
50
+ );
51
+ expect(deps.execSync).toHaveBeenCalledWith(
52
+ expect.stringContaining("git config core.worktree"),
53
+ expect.any(Object),
54
+ );
55
+ expect(deps.mkdirSync).toHaveBeenCalled();
56
+ expect(deps.writeFileSync).toHaveBeenCalled();
57
+ // Every git command targets the private repo, not the main project .git
58
+ for (const call of (deps.execSync as any).mock.calls) {
59
+ expect(call[1].env.GIT_DIR).toBe("/test/.pi/tdd/.git");
60
+ expect(call[1].env.GIT_WORK_TREE).toBe("/test");
61
+ }
62
+ });
63
+
64
+ it("has initial commit after init", () => {
65
+ outputs["rev-parse HEAD"] = "abc123def456\n";
66
+ const hash = headHash("/test", deps);
67
+ expect(hash).toBe("abc123def456");
68
+ });
69
+
70
+ it("snapshot captures changes", () => {
71
+ outputs["rev-parse HEAD"] = "hash123\n";
72
+ const hash = snapshot("/test", "green", deps);
73
+ expect(hash).toBe("hash123");
74
+ expect(deps.execSync).toHaveBeenCalledWith(
75
+ expect.stringContaining('commit --allow-empty -m "tdd: green"'),
76
+ expect.any(Object),
77
+ );
78
+ });
79
+
80
+ it("modifiedFiles returns changed files", () => {
81
+ outputs["diff --name-only HEAD"] = "src/main.ts\n";
82
+ const modified = modifiedFiles("/test", deps);
83
+ expect(modified).toContain("src/main.ts");
84
+ });
85
+
86
+ it("untrackedFiles returns new files", () => {
87
+ outputs["ls-files --others --exclude-standard"] = "newfile.ts\n";
88
+ const untracked = untrackedFiles("/test", deps);
89
+ expect(untracked).toContain("newfile.ts");
90
+ });
91
+
92
+ it("changesSinceSnapshot combines modified + untracked", () => {
93
+ outputs["diff --name-only HEAD"] = "src/main.ts\n";
94
+ outputs["ls-files --others --exclude-standard"] = "newfile.ts\n";
95
+ const changes = changesSinceSnapshot("/test", deps);
96
+ expect(changes).toContain("src/main.ts");
97
+ expect(changes).toContain("newfile.ts");
98
+ });
99
+
100
+ it("restoreFilesTo reverts specific files", () => {
101
+ outputs["ls-files"] = "src/main.ts\n";
102
+ restoreFilesTo("/test", ["src/main.ts"], undefined, deps);
103
+ expect(deps.execSync).toHaveBeenCalledWith(
104
+ expect.stringContaining("git restore"),
105
+ expect.any(Object),
106
+ );
107
+ });
108
+
109
+ it("modifiedFiles returns empty when HEAD matches working tree", () => {
110
+ outputs["diff --name-only HEAD"] = "";
111
+ const modified = modifiedFiles("/test", deps);
112
+ expect(modified).not.toContain("src/main.ts");
113
+ });
114
+
115
+ it("untrackedFiles returns empty when no new files", () => {
116
+ outputs["ls-files --others --exclude-standard"] = "";
117
+ const untracked = untrackedFiles("/test", deps);
118
+ expect(untracked).not.toContain("src/main.ts");
119
+ });
120
+
121
+ it("changesSinceSnapshot deduplicates when file is both modified and untracked", () => {
122
+ outputs["diff --name-only HEAD"] = "file.txt\n";
123
+ outputs["ls-files --others --exclude-standard"] = "file.txt\n";
124
+ const changes = changesSinceSnapshot("/test", deps);
125
+ const count = changes.filter((f) => f === "file.txt").length;
126
+ expect(count).toBe(1);
127
+ });
128
+
129
+ it("restoreFilesTo does nothing when files list is empty", () => {
130
+ expect(() => restoreFilesTo("/test", [], undefined, deps)).not.toThrow();
131
+ expect(deps.execSync).not.toHaveBeenCalled();
132
+ });
133
+
134
+ it("initGit force-adds TDD config files to bypass worktree gitignore", () => {
135
+ deps.existsSync = vi
136
+ .fn()
137
+ .mockImplementation(
138
+ (p: string) => p.includes(".pi/tdd/") && !p.endsWith(".git"),
139
+ );
140
+ initGit("/test", deps);
141
+ const call = (deps.execSync as any).mock.calls.find((c: any[]) =>
142
+ c[0].includes("git add -f"),
143
+ );
144
+ expect(call).toBeDefined();
145
+ expect(call[0]).toContain(".pi/tdd/state.json");
146
+ expect(call[0]).toContain(".pi/tdd/rules.json");
147
+ expect(call[0]).toContain(".pi/tdd/.gitignore");
148
+ expect(call[0]).not.toContain("tdd.log");
149
+ // Isolation: all git commands target the private repo, not the main project .git
150
+ expect(call[1].env.GIT_DIR).toBe("/test/.pi/tdd/.git");
151
+ expect(call[1].env.GIT_WORK_TREE).toBe("/test");
152
+ });
153
+
154
+ it("initGit force-adds after add -A and before commit", () => {
155
+ deps.existsSync = vi
156
+ .fn()
157
+ .mockImplementation(
158
+ (p: string) => p.includes(".pi/tdd/") && !p.endsWith(".git"),
159
+ );
160
+ initGit("/test", deps);
161
+ const calls = (deps.execSync as any).mock.calls.map((c: any[]) => c[0]);
162
+ const addAIndex = calls.findIndex((c: string) => c.includes("git add -A "));
163
+ const forceAddIndex = calls.findIndex((c: string) =>
164
+ c.includes("git add -f"),
165
+ );
166
+ const commitIndex = calls.findIndex((c: string) =>
167
+ c.includes("git commit"),
168
+ );
169
+ expect(addAIndex).toBeLessThan(forceAddIndex);
170
+ expect(forceAddIndex).toBeLessThan(commitIndex);
171
+ });
172
+
173
+ it("snapshot force-adds state.json, rules.json, .gitignore", () => {
174
+ outputs["rev-parse HEAD"] = "hash123\n";
175
+ deps.existsSync = vi
176
+ .fn()
177
+ .mockImplementation(
178
+ (p: string) =>
179
+ p.includes(".pi/tdd/state.json") ||
180
+ p.includes(".pi/tdd/rules.json") ||
181
+ p.includes(".pi/tdd/.gitignore"),
182
+ );
183
+ snapshot("/test", "green", deps);
184
+ const call = (deps.execSync as any).mock.calls.find((c: any[]) =>
185
+ c[0].includes("git add -f"),
186
+ );
187
+ expect(call).toBeDefined();
188
+ expect(call[0]).toContain(".pi/tdd/state.json");
189
+ expect(call[0]).toContain(".pi/tdd/rules.json");
190
+ expect(call[0]).toContain(".pi/tdd/.gitignore");
191
+ expect(call[0]).not.toContain("tdd.log");
192
+ // Isolation: all git commands target the private repo, not the main project .git
193
+ expect(call[1].env.GIT_DIR).toBe("/test/.pi/tdd/.git");
194
+ expect(call[1].env.GIT_WORK_TREE).toBe("/test");
195
+ });
196
+
197
+ it("snapshot force-adds after add -A and before commit", () => {
198
+ outputs["rev-parse HEAD"] = "hash456\n";
199
+ deps.existsSync = vi
200
+ .fn()
201
+ .mockImplementation(
202
+ (p: string) =>
203
+ p.includes(".pi/tdd/state.json") ||
204
+ p.includes(".pi/tdd/rules.json") ||
205
+ p.includes(".pi/tdd/.gitignore"),
206
+ );
207
+ snapshot("/test", "red", deps);
208
+ const calls = (deps.execSync as any).mock.calls.map((c: any[]) => c[0]);
209
+ const addAIndex = calls.findIndex((c: string) => c.includes("git add -A "));
210
+ const forceAddIndex = calls.findIndex((c: string) =>
211
+ c.includes("git add -f"),
212
+ );
213
+ const commitIndex = calls.findIndex((c: string) =>
214
+ c.includes("git commit"),
215
+ );
216
+ expect(addAIndex).toBeLessThan(forceAddIndex);
217
+ expect(forceAddIndex).toBeLessThan(commitIndex);
218
+ });
201
219
  });
202
220
 
203
221
  describe("headMessage", () => {
204
- let deps: GitDeps;
205
- let outputs: Record<string, string>;
206
-
207
- function makeDeps(): GitDeps {
208
- const mockExecSync = vi.fn((cmd: string) => {
209
- for (const [prefix, out] of Object.entries(outputs)) {
210
- if (cmd.includes(prefix)) return Buffer.from(out);
211
- }
212
- return Buffer.from("");
213
- });
214
- return {
215
- execSync: mockExecSync as any,
216
- existsSync: vi.fn().mockReturnValue(false),
217
- mkdirSync: vi.fn(),
218
- writeFileSync: vi.fn(),
219
- unlinkSync: vi.fn(),
220
- rmSync: vi.fn(),
221
- };
222
- }
223
-
224
- beforeEach(() => {
225
- outputs = {};
226
- deps = makeDeps();
227
- });
228
-
229
- it("returns init commit message after initGit", () => {
230
- outputs["log -1 --format=%s HEAD"] = "tdd: init\n";
231
- expect(headMessage("/test", deps)).toBe("tdd: init");
232
- });
233
-
234
- it("returns snapshot phase in commit message", () => {
235
- outputs["log -1 --format=%s HEAD"] = "tdd: green\n";
236
- expect(headMessage("/test", deps)).toBe("tdd: green");
237
- });
238
-
239
- it("updates after each snapshot", () => {
240
- outputs["rev-parse HEAD"] = "hash1\n";
241
- snapshot("/test", "red", deps);
242
- outputs["rev-parse HEAD"] = "hash2\n";
243
- snapshot("/test", "green", deps);
244
- outputs["log -1 --format=%s HEAD"] = "tdd: green\n";
245
- expect(headMessage("/test", deps)).toBe("tdd: green");
246
- });
222
+ let deps: GitDeps;
223
+ let outputs: Record<string, string>;
224
+
225
+ function makeDeps(): GitDeps {
226
+ const mockExecSync = vi.fn((cmd: string) => {
227
+ for (const [prefix, out] of Object.entries(outputs)) {
228
+ if (cmd.includes(prefix)) return Buffer.from(out);
229
+ }
230
+ return Buffer.from("");
231
+ });
232
+ return {
233
+ execSync: mockExecSync as any,
234
+ existsSync: vi.fn().mockReturnValue(false),
235
+ mkdirSync: vi.fn(),
236
+ writeFileSync: vi.fn(),
237
+ unlinkSync: vi.fn(),
238
+ rmSync: vi.fn(),
239
+ };
240
+ }
241
+
242
+ beforeEach(() => {
243
+ outputs = {};
244
+ deps = makeDeps();
245
+ });
246
+
247
+ it("returns init commit message after initGit", () => {
248
+ outputs["log -1 --format=%s HEAD"] = "tdd: init\n";
249
+ expect(headMessage("/test", deps)).toBe("tdd: init");
250
+ });
251
+
252
+ it("returns snapshot phase in commit message", () => {
253
+ outputs["log -1 --format=%s HEAD"] = "tdd: green\n";
254
+ expect(headMessage("/test", deps)).toBe("tdd: green");
255
+ });
256
+
257
+ it("updates after each snapshot", () => {
258
+ outputs["rev-parse HEAD"] = "hash1\n";
259
+ snapshot("/test", "red", deps);
260
+ outputs["rev-parse HEAD"] = "hash2\n";
261
+ snapshot("/test", "green", deps);
262
+ outputs["log -1 --format=%s HEAD"] = "tdd: green\n";
263
+ expect(headMessage("/test", deps)).toBe("tdd: green");
264
+ });
247
265
  });
248
266
 
249
267
  describe("hasParent", () => {
250
- let deps: GitDeps;
251
- let outputs: Record<string, string>;
252
-
253
- function makeDeps(): GitDeps {
254
- const mockExecSync = vi.fn((cmd: string) => {
255
- for (const [prefix, out] of Object.entries(outputs)) {
256
- if (cmd.includes(prefix)) return Buffer.from(out);
257
- }
258
- return Buffer.from("");
259
- });
260
- return {
261
- execSync: mockExecSync as any,
262
- existsSync: vi.fn().mockReturnValue(false),
263
- mkdirSync: vi.fn(),
264
- writeFileSync: vi.fn(),
265
- unlinkSync: vi.fn(),
266
- rmSync: vi.fn(),
267
- };
268
- }
269
-
270
- beforeEach(() => {
271
- outputs = {};
272
- deps = makeDeps();
273
- });
274
-
275
- it("returns false when only init commit exists", () => {
276
- const mockExecSync = vi.fn((cmd: string) => {
277
- if (cmd.includes("rev-parse HEAD~1")) throw new Error("unknown revision");
278
- return Buffer.from("");
279
- });
280
- deps = { ...deps, execSync: mockExecSync as any };
281
- expect(hasParent("/test", deps)).toBe(false);
282
- });
283
-
284
- it("returns true after first snapshot", () => {
285
- const mockExecSync = vi.fn((cmd: string) => {
286
- if (cmd.includes("rev-parse HEAD~1")) return Buffer.from("abc123\n");
287
- return Buffer.from("");
288
- });
289
- deps = { ...deps, execSync: mockExecSync as any };
290
- expect(hasParent("/test", deps)).toBe(true);
291
- });
292
-
293
- it("returns true after multiple snapshots", () => {
294
- const mockExecSync = vi.fn((cmd: string) => {
295
- if (cmd.includes("rev-parse HEAD~1")) return Buffer.from("abc123\n");
296
- return Buffer.from("");
297
- });
298
- deps = { ...deps, execSync: mockExecSync as any };
299
- expect(hasParent("/test", deps)).toBe(true);
300
- });
301
-
302
- it("returns false after popping all snapshots back to init", () => {
303
- let headExists = true;
304
- const mockExecSync = vi.fn((cmd: string) => {
305
- if (cmd.includes("rev-parse HEAD~1")) {
306
- if (!headExists) throw new Error("unknown revision");
307
- return Buffer.from("abc123\n");
308
- }
309
- if (cmd.includes("reset --soft HEAD~1")) {
310
- headExists = false;
311
- return Buffer.from("");
312
- }
313
- return Buffer.from("");
314
- });
315
- deps = { ...deps, execSync: mockExecSync as any };
316
-
317
- expect(hasParent("/test", deps)).toBe(true);
318
- undoLastCommit("/test", deps);
319
- expect(hasParent("/test", deps)).toBe(false);
320
- });
268
+ let deps: GitDeps;
269
+ let outputs: Record<string, string>;
270
+
271
+ function makeDeps(): GitDeps {
272
+ const mockExecSync = vi.fn((cmd: string) => {
273
+ for (const [prefix, out] of Object.entries(outputs)) {
274
+ if (cmd.includes(prefix)) return Buffer.from(out);
275
+ }
276
+ return Buffer.from("");
277
+ });
278
+ return {
279
+ execSync: mockExecSync as any,
280
+ existsSync: vi.fn().mockReturnValue(false),
281
+ mkdirSync: vi.fn(),
282
+ writeFileSync: vi.fn(),
283
+ unlinkSync: vi.fn(),
284
+ rmSync: vi.fn(),
285
+ };
286
+ }
287
+
288
+ beforeEach(() => {
289
+ outputs = {};
290
+ deps = makeDeps();
291
+ });
292
+
293
+ it("returns false when only init commit exists", () => {
294
+ const mockExecSync = vi.fn((cmd: string) => {
295
+ if (cmd.includes("rev-parse HEAD~1")) throw new Error("unknown revision");
296
+ return Buffer.from("");
297
+ });
298
+ deps = { ...deps, execSync: mockExecSync as any };
299
+ expect(hasParent("/test", deps)).toBe(false);
300
+ });
301
+
302
+ it("returns true after first snapshot", () => {
303
+ const mockExecSync = vi.fn((cmd: string) => {
304
+ if (cmd.includes("rev-parse HEAD~1")) return Buffer.from("abc123\n");
305
+ return Buffer.from("");
306
+ });
307
+ deps = { ...deps, execSync: mockExecSync as any };
308
+ expect(hasParent("/test", deps)).toBe(true);
309
+ });
310
+
311
+ it("returns true after multiple snapshots", () => {
312
+ const mockExecSync = vi.fn((cmd: string) => {
313
+ if (cmd.includes("rev-parse HEAD~1")) return Buffer.from("abc123\n");
314
+ return Buffer.from("");
315
+ });
316
+ deps = { ...deps, execSync: mockExecSync as any };
317
+ expect(hasParent("/test", deps)).toBe(true);
318
+ });
319
+
320
+ it("returns false after popping all snapshots back to init", () => {
321
+ let headExists = true;
322
+ const mockExecSync = vi.fn((cmd: string) => {
323
+ if (cmd.includes("rev-parse HEAD~1")) {
324
+ if (!headExists) throw new Error("unknown revision");
325
+ return Buffer.from("abc123\n");
326
+ }
327
+ if (cmd.includes("reset --soft HEAD~1")) {
328
+ headExists = false;
329
+ return Buffer.from("");
330
+ }
331
+ return Buffer.from("");
332
+ });
333
+ deps = { ...deps, execSync: mockExecSync as any };
334
+
335
+ expect(hasParent("/test", deps)).toBe(true);
336
+ undoLastCommit("/test", deps);
337
+ expect(hasParent("/test", deps)).toBe(false);
338
+ });
321
339
  });
322
340
 
323
341
  describe("resetHard", () => {
324
- let deps: GitDeps;
325
- let outputs: Record<string, string>;
326
-
327
- function makeDeps(): GitDeps {
328
- const mockExecSync = vi.fn((cmd: string) => {
329
- for (const [prefix, out] of Object.entries(outputs)) {
330
- if (cmd.includes(prefix)) return Buffer.from(out);
331
- }
332
- return Buffer.from("");
333
- });
334
- return {
335
- execSync: mockExecSync as any,
336
- existsSync: vi.fn().mockReturnValue(false),
337
- mkdirSync: vi.fn(),
338
- writeFileSync: vi.fn(),
339
- unlinkSync: vi.fn(),
340
- rmSync: vi.fn(),
341
- };
342
- }
343
-
344
- beforeEach(() => {
345
- outputs = {};
346
- deps = makeDeps();
347
- });
348
-
349
- it("discards uncommitted changes", () => {
350
- resetHard("/test", deps);
351
- expect(deps.execSync).toHaveBeenCalledWith(
352
- expect.stringContaining("git reset --hard"),
353
- expect.any(Object),
354
- );
355
- expect(deps.execSync).toHaveBeenCalledWith(
356
- expect.stringContaining("git clean -fd"),
357
- expect.any(Object),
358
- );
359
- });
360
-
361
- it("discards untracked files", () => {
362
- resetHard("/test", deps);
363
- expect(deps.execSync).toHaveBeenCalledWith(
364
- expect.stringContaining("git clean -fd"),
365
- expect.any(Object),
366
- );
367
- });
368
-
369
- it("leaves committed changes intact", () => {
370
- resetHard("/test", deps);
371
- expect(deps.execSync).toHaveBeenCalledWith(
372
- expect.stringContaining("git reset --hard"),
373
- expect.any(Object),
374
- );
375
- });
376
-
377
- it("does not throw when working tree matches HEAD", () => {
378
- expect(() => resetHard("/test", deps)).not.toThrow();
379
- });
342
+ let deps: GitDeps;
343
+ let outputs: Record<string, string>;
344
+
345
+ function makeDeps(): GitDeps {
346
+ const mockExecSync = vi.fn((cmd: string) => {
347
+ for (const [prefix, out] of Object.entries(outputs)) {
348
+ if (cmd.includes(prefix)) return Buffer.from(out);
349
+ }
350
+ return Buffer.from("");
351
+ });
352
+ return {
353
+ execSync: mockExecSync as any,
354
+ existsSync: vi.fn().mockReturnValue(false),
355
+ mkdirSync: vi.fn(),
356
+ writeFileSync: vi.fn(),
357
+ unlinkSync: vi.fn(),
358
+ rmSync: vi.fn(),
359
+ };
360
+ }
361
+
362
+ beforeEach(() => {
363
+ outputs = {};
364
+ deps = makeDeps();
365
+ });
366
+
367
+ it("discards uncommitted changes", () => {
368
+ resetHard("/test", deps);
369
+ expect(deps.execSync).toHaveBeenCalledWith(
370
+ expect.stringContaining("git reset --hard"),
371
+ expect.any(Object),
372
+ );
373
+ expect(deps.execSync).toHaveBeenCalledWith(
374
+ expect.stringContaining("git clean -fd"),
375
+ expect.any(Object),
376
+ );
377
+ });
378
+
379
+ it("discards untracked files", () => {
380
+ resetHard("/test", deps);
381
+ expect(deps.execSync).toHaveBeenCalledWith(
382
+ expect.stringContaining("git clean -fd"),
383
+ expect.any(Object),
384
+ );
385
+ });
386
+
387
+ it("leaves committed changes intact", () => {
388
+ resetHard("/test", deps);
389
+ expect(deps.execSync).toHaveBeenCalledWith(
390
+ expect.stringContaining("git reset --hard"),
391
+ expect.any(Object),
392
+ );
393
+ });
394
+
395
+ it("does not throw when working tree matches HEAD", () => {
396
+ expect(() => resetHard("/test", deps)).not.toThrow();
397
+ });
380
398
  });
381
399
 
382
400
  describe("undoLastCommit", () => {
383
- let deps: GitDeps;
384
- let outputs: Record<string, string>;
385
-
386
- function makeDeps(): GitDeps {
387
- const mockExecSync = vi.fn((cmd: string) => {
388
- for (const [prefix, out] of Object.entries(outputs)) {
389
- if (cmd.includes(prefix)) return Buffer.from(out);
390
- }
391
- return Buffer.from("");
392
- });
393
- return {
394
- execSync: mockExecSync as any,
395
- existsSync: vi.fn().mockReturnValue(false),
396
- mkdirSync: vi.fn(),
397
- writeFileSync: vi.fn(),
398
- unlinkSync: vi.fn(),
399
- rmSync: vi.fn(),
400
- };
401
- }
402
-
403
- beforeEach(() => {
404
- outputs = {};
405
- deps = makeDeps();
406
- });
407
-
408
- it("removes last commit and keeps its content as unstaged changes", () => {
409
- outputs["rev-parse HEAD"] = "hash1\n";
410
- snapshot("/test", "red", deps);
411
- outputs["rev-parse HEAD"] = "hash2\n";
412
- snapshot("/test", "green", deps);
413
-
414
- undoLastCommit("/test", deps);
415
- expect(deps.execSync).toHaveBeenCalledWith(
416
- expect.stringContaining("git reset --soft HEAD~1"),
417
- expect.any(Object),
418
- );
419
-
420
- outputs["diff --name-only HEAD"] = "file.txt\n";
421
- expect(modifiedFiles("/test", deps)).toContain("file.txt");
422
- });
423
-
424
- it("exposes popped content — new file stays in working tree and index", () => {
425
- outputs["rev-parse HEAD"] = "hash1\n";
426
- snapshot("/test", "red", deps);
427
- outputs["rev-parse HEAD"] = "hash2\n";
428
- snapshot("/test", "green", deps);
429
-
430
- undoLastCommit("/test", deps);
431
-
432
- outputs["diff --name-only HEAD"] = "new.txt\n";
433
- outputs["ls-files --others --exclude-standard"] = "";
434
- expect(changesSinceSnapshot("/test", deps)).toContain("new.txt");
435
- });
436
-
437
- it("errors when there is no parent commit", () => {
438
- const mockExecSync = vi.fn((cmd: string) => {
439
- if (cmd.includes("reset --soft HEAD~1")) throw new Error("unknown revision");
440
- return Buffer.from("");
441
- });
442
- deps = { ...deps, execSync: mockExecSync as any };
443
- expect(() => undoLastCommit("/test", deps)).toThrow();
444
- });
401
+ let deps: GitDeps;
402
+ let outputs: Record<string, string>;
403
+
404
+ function makeDeps(): GitDeps {
405
+ const mockExecSync = vi.fn((cmd: string) => {
406
+ for (const [prefix, out] of Object.entries(outputs)) {
407
+ if (cmd.includes(prefix)) return Buffer.from(out);
408
+ }
409
+ return Buffer.from("");
410
+ });
411
+ return {
412
+ execSync: mockExecSync as any,
413
+ existsSync: vi.fn().mockReturnValue(false),
414
+ mkdirSync: vi.fn(),
415
+ writeFileSync: vi.fn(),
416
+ unlinkSync: vi.fn(),
417
+ rmSync: vi.fn(),
418
+ };
419
+ }
420
+
421
+ beforeEach(() => {
422
+ outputs = {};
423
+ deps = makeDeps();
424
+ });
425
+
426
+ it("removes last commit and keeps its content as unstaged changes", () => {
427
+ outputs["rev-parse HEAD"] = "hash1\n";
428
+ snapshot("/test", "red", deps);
429
+ outputs["rev-parse HEAD"] = "hash2\n";
430
+ snapshot("/test", "green", deps);
431
+
432
+ undoLastCommit("/test", deps);
433
+ expect(deps.execSync).toHaveBeenCalledWith(
434
+ expect.stringContaining("git reset --soft HEAD~1"),
435
+ expect.any(Object),
436
+ );
437
+
438
+ outputs["diff --name-only HEAD"] = "file.txt\n";
439
+ expect(modifiedFiles("/test", deps)).toContain("file.txt");
440
+ });
441
+
442
+ it("exposes popped content — new file stays in working tree and index", () => {
443
+ outputs["rev-parse HEAD"] = "hash1\n";
444
+ snapshot("/test", "red", deps);
445
+ outputs["rev-parse HEAD"] = "hash2\n";
446
+ snapshot("/test", "green", deps);
447
+
448
+ undoLastCommit("/test", deps);
449
+
450
+ outputs["diff --name-only HEAD"] = "new.txt\n";
451
+ outputs["ls-files --others --exclude-standard"] = "";
452
+ expect(changesSinceSnapshot("/test", deps)).toContain("new.txt");
453
+ });
454
+
455
+ it("errors when there is no parent commit", () => {
456
+ const mockExecSync = vi.fn((cmd: string) => {
457
+ if (cmd.includes("reset --soft HEAD~1"))
458
+ throw new Error("unknown revision");
459
+ return Buffer.from("");
460
+ });
461
+ deps = { ...deps, execSync: mockExecSync as any };
462
+ expect(() => undoLastCommit("/test", deps)).toThrow();
463
+ });
445
464
  });
446
465
 
447
466
  describe("gitStashCreate", () => {
448
- let deps: GitDeps;
449
- let outputs: Record<string, string>;
450
-
451
- function makeDeps(): GitDeps {
452
- const mockExecSync = vi.fn((cmd: string) => {
453
- for (const [prefix, out] of Object.entries(outputs)) {
454
- if (cmd.includes(prefix)) return Buffer.from(out);
455
- }
456
- return Buffer.from("");
457
- });
458
- return {
459
- execSync: mockExecSync as any,
460
- existsSync: vi.fn().mockReturnValue(false),
461
- mkdirSync: vi.fn(),
462
- writeFileSync: vi.fn(),
463
- unlinkSync: vi.fn(),
464
- rmSync: vi.fn(),
465
- };
466
- }
467
-
468
- beforeEach(() => {
469
- outputs = {};
470
- deps = makeDeps();
471
- });
472
-
473
- it("returns a hash when tracked file is modified", () => {
474
- outputs["stash create --include-untracked"] = "abc123\n";
475
- const result = gitStashCreate("/test", deps);
476
- expect(result).toBe("abc123");
477
- });
478
-
479
- it("returns HEAD when working tree is clean", () => {
480
- outputs["stash create --include-untracked"] = "";
481
- const result = gitStashCreate("/test", deps);
482
- expect(result).toBe("HEAD");
483
- });
467
+ let deps: GitDeps;
468
+ let outputs: Record<string, string>;
469
+
470
+ function makeDeps(): GitDeps {
471
+ const mockExecSync = vi.fn((cmd: string) => {
472
+ for (const [prefix, out] of Object.entries(outputs)) {
473
+ if (cmd.includes(prefix)) return Buffer.from(out);
474
+ }
475
+ return Buffer.from("");
476
+ });
477
+ return {
478
+ execSync: mockExecSync as any,
479
+ existsSync: vi.fn().mockReturnValue(false),
480
+ mkdirSync: vi.fn(),
481
+ writeFileSync: vi.fn(),
482
+ unlinkSync: vi.fn(),
483
+ rmSync: vi.fn(),
484
+ };
485
+ }
486
+
487
+ beforeEach(() => {
488
+ outputs = {};
489
+ deps = makeDeps();
490
+ });
491
+
492
+ it("returns a hash when tracked file is modified", () => {
493
+ outputs["stash create --include-untracked"] = "abc123\n";
494
+ const result = gitStashCreate("/test", deps);
495
+ expect(result).toBe("abc123");
496
+ });
497
+
498
+ it("returns HEAD when working tree is clean", () => {
499
+ outputs["stash create --include-untracked"] = "";
500
+ const result = gitStashCreate("/test", deps);
501
+ expect(result).toBe("HEAD");
502
+ });
484
503
  });
485
504
 
486
505
  describe("stageFiles", () => {
487
- let deps: GitDeps;
488
-
489
- beforeEach(() => {
490
- deps = {
491
- execSync: vi.fn(() => "") as any,
492
- existsSync: vi.fn().mockReturnValue(false),
493
- mkdirSync: vi.fn(),
494
- writeFileSync: vi.fn(),
495
- unlinkSync: vi.fn(),
496
- rmSync: vi.fn(),
497
- };
498
- });
499
-
500
- it("calls git add -f with provided files", () => {
501
- deps.existsSync = vi.fn().mockImplementation((p: string) =>
502
- p.includes(".pi/tdd/state.json") || p.includes(".pi/tdd/rules.json"),
503
- );
504
- stageFiles("/test", [".pi/tdd/state.json", ".pi/tdd/rules.json"], deps);
505
- const call = (deps.execSync as any).mock.calls[0];
506
- const cmd = call[0] as string;
507
- expect(cmd).toContain("git add -f");
508
- expect(cmd).toContain(".pi/tdd/state.json");
509
- expect(cmd).toContain(".pi/tdd/rules.json");
510
- // Isolated from main project git
511
- expect(call[1].env.GIT_DIR).toBe("/test/.pi/tdd/.git");
512
- expect(call[1].env.GIT_WORK_TREE).toBe("/test");
513
- });
514
-
515
- it("skips files that don't exist", () => {
516
- deps.existsSync = vi.fn().mockReturnValue(false);
517
- stageFiles("/test", [".pi/tdd/state.json", ".pi/tdd/rules.json"], deps);
518
- expect(deps.execSync).not.toHaveBeenCalled();
519
- });
520
-
521
- it("handles a single existing file when others don't exist", () => {
522
- deps.existsSync = vi.fn().mockImplementation((p: string) =>
523
- p.includes(".pi/tdd/state.json"),
524
- );
525
- stageFiles("/test", [".pi/tdd/state.json", ".pi/tdd/rules.json"], deps);
526
- const call = (deps.execSync as any).mock.calls[0];
527
- expect(call[0]).toContain(".pi/tdd/state.json");
528
- expect(call[0]).not.toContain(".pi/tdd/rules.json");
529
- });
506
+ let deps: GitDeps;
507
+
508
+ beforeEach(() => {
509
+ deps = {
510
+ execSync: vi.fn(() => "") as any,
511
+ existsSync: vi.fn().mockReturnValue(false),
512
+ mkdirSync: vi.fn(),
513
+ writeFileSync: vi.fn(),
514
+ unlinkSync: vi.fn(),
515
+ rmSync: vi.fn(),
516
+ };
517
+ });
518
+
519
+ it("calls git add -f with provided files", () => {
520
+ deps.existsSync = vi
521
+ .fn()
522
+ .mockImplementation(
523
+ (p: string) =>
524
+ p.includes(".pi/tdd/state.json") || p.includes(".pi/tdd/rules.json"),
525
+ );
526
+ stageFiles("/test", [".pi/tdd/state.json", ".pi/tdd/rules.json"], deps);
527
+ const call = (deps.execSync as any).mock.calls[0];
528
+ const cmd = call[0] as string;
529
+ expect(cmd).toContain("git add -f");
530
+ expect(cmd).toContain(".pi/tdd/state.json");
531
+ expect(cmd).toContain(".pi/tdd/rules.json");
532
+ // Isolated from main project git
533
+ expect(call[1].env.GIT_DIR).toBe("/test/.pi/tdd/.git");
534
+ expect(call[1].env.GIT_WORK_TREE).toBe("/test");
535
+ });
536
+
537
+ it("skips files that don't exist", () => {
538
+ deps.existsSync = vi.fn().mockReturnValue(false);
539
+ stageFiles("/test", [".pi/tdd/state.json", ".pi/tdd/rules.json"], deps);
540
+ expect(deps.execSync).not.toHaveBeenCalled();
541
+ });
542
+
543
+ it("handles a single existing file when others don't exist", () => {
544
+ deps.existsSync = vi
545
+ .fn()
546
+ .mockImplementation((p: string) => p.includes(".pi/tdd/state.json"));
547
+ stageFiles("/test", [".pi/tdd/state.json", ".pi/tdd/rules.json"], deps);
548
+ const call = (deps.execSync as any).mock.calls[0];
549
+ expect(call[0]).toContain(".pi/tdd/state.json");
550
+ expect(call[0]).not.toContain(".pi/tdd/rules.json");
551
+ });
530
552
  });