tdd-enforcer 0.2.2 → 0.2.5

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,271 +1,530 @@
1
- import { describe, it, expect, beforeAll, afterAll } from "vitest";
2
- import { mkdirSync, writeFileSync, rmSync, existsSync, readFileSync } from "node:fs";
3
- import { join } from "node:path";
4
- import { tmpdir } from "node:os";
5
- import { initGit, snapshot, changesSinceSnapshot, modifiedFiles, untrackedFiles, restoreFilesTo, headHash, headMessage, hasParent, resetHard, undoLastCommit } from "./git.js";
6
-
7
- let testDir: string;
8
-
9
- beforeAll(() => {
10
- testDir = join(tmpdir(), `tdd-git-test-${Date.now()}`);
11
- mkdirSync(join(testDir, ".pi", "tdd"), { recursive: true });
12
- mkdirSync(join(testDir, "src"), { recursive: true });
13
- writeFileSync(join(testDir, "src", "main.ts"), "// initial", "utf-8");
14
- });
15
-
16
- afterAll(() => {
17
- rmSync(testDir, { recursive: true, force: true });
18
- });
1
+ import { describe, it, expect, beforeEach, vi } from "vitest";
2
+ 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,
16
+ } from "./git.js";
17
+ import type { GitDeps } from "./git.js";
19
18
 
20
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
+
21
45
  it("initGit creates private git repo", () => {
22
- initGit(testDir);
23
- expect(existsSync(join(testDir, ".pi", "tdd", ".git"))).toBe(true);
24
- expect(existsSync(join(testDir, ".pi", "tdd", ".gitignore"))).toBe(true);
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
+ }
25
62
  });
26
63
 
27
64
  it("has initial commit after init", () => {
28
- const hash = headHash(testDir);
29
- expect(hash).toBeTruthy();
30
- expect(hash.length).toBeGreaterThan(10);
65
+ outputs["rev-parse HEAD"] = "abc123def456\n";
66
+ const hash = headHash("/test", deps);
67
+ expect(hash).toBe("abc123def456");
31
68
  });
32
69
 
33
70
  it("snapshot captures changes", () => {
34
- writeFileSync(join(testDir, "src", "main.ts"), "// modified", "utf-8");
35
- const hash = snapshot(testDir, "green");
36
- expect(hash).toBeTruthy();
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
+ );
37
78
  });
38
79
 
39
80
  it("modifiedFiles returns changed files", () => {
40
- writeFileSync(join(testDir, "src", "main.ts"), "// changed again", "utf-8");
41
- const modified = modifiedFiles(testDir);
81
+ outputs["diff --name-only HEAD"] = "src/main.ts\n";
82
+ const modified = modifiedFiles("/test", deps);
42
83
  expect(modified).toContain("src/main.ts");
43
84
  });
44
85
 
45
86
  it("untrackedFiles returns new files", () => {
46
- writeFileSync(join(testDir, "newfile.ts"), "// new", "utf-8");
47
- const untracked = untrackedFiles(testDir);
87
+ outputs["ls-files --others --exclude-standard"] = "newfile.ts\n";
88
+ const untracked = untrackedFiles("/test", deps);
48
89
  expect(untracked).toContain("newfile.ts");
49
90
  });
50
91
 
51
92
  it("changesSinceSnapshot combines modified + untracked", () => {
52
- writeFileSync(join(testDir, "src", "main.ts"), "// yet another change", "utf-8");
53
- writeFileSync(join(testDir, "another.ts"), "// also new", "utf-8");
54
- const changes = changesSinceSnapshot(testDir);
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);
55
96
  expect(changes).toContain("src/main.ts");
56
- expect(changes).toContain("another.ts");
97
+ expect(changes).toContain("newfile.ts");
57
98
  });
58
99
 
59
100
  it("restoreFilesTo reverts specific files", () => {
60
- writeFileSync(join(testDir, "src", "main.ts"), "// to be reverted", "utf-8");
61
- expect(modifiedFiles(testDir)).toContain("src/main.ts");
62
-
63
- restoreFilesTo(testDir, ["src/main.ts"]);
64
- expect(modifiedFiles(testDir)).not.toContain("src/main.ts");
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
+ );
65
107
  });
66
108
 
67
109
  it("modifiedFiles returns empty when HEAD matches working tree", () => {
68
- expect(modifiedFiles(testDir)).not.toContain("src/main.ts");
110
+ outputs["diff --name-only HEAD"] = "";
111
+ const modified = modifiedFiles("/test", deps);
112
+ expect(modified).not.toContain("src/main.ts");
69
113
  });
70
114
 
71
115
  it("untrackedFiles returns empty when no new files", () => {
72
- const untracked = untrackedFiles(testDir);
116
+ outputs["ls-files --others --exclude-standard"] = "";
117
+ const untracked = untrackedFiles("/test", deps);
73
118
  expect(untracked).not.toContain("src/main.ts");
74
119
  });
75
120
 
76
121
  it("changesSinceSnapshot deduplicates when file is both modified and untracked", () => {
77
- // Write a file, snapshot it, then delete and recreate as different type
78
- const tmp = join(tmpdir(), `tdd-dedup-${Date.now()}`);
79
- mkdirSync(tmp, { recursive: true });
80
- try {
81
- initGit(tmp);
82
- writeFileSync(join(tmp, "file.txt"), "original", "utf-8");
83
- snapshot(tmp, "red");
84
- // Delete tracked file — it's now a deletion (modified)
85
- rmSync(join(tmp, "file.txt"));
86
- // Recreate as untracked with same name
87
- writeFileSync(join(tmp, "file.txt"), "new content", "utf-8");
88
- const changes = changesSinceSnapshot(tmp);
89
- // Should appear exactly once despite matching both conditions
90
- const count = changes.filter((f) => f === "file.txt").length;
91
- expect(count).toBe(1);
92
- } finally {
93
- rmSync(tmp, { recursive: true, force: true });
94
- }
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);
95
127
  });
96
128
 
97
129
  it("restoreFilesTo does nothing when files list is empty", () => {
98
- // Should not throw
99
- expect(() => restoreFilesTo(testDir, [])).not.toThrow();
130
+ expect(() => restoreFilesTo("/test", [], undefined, deps)).not.toThrow();
131
+ expect(deps.execSync).not.toHaveBeenCalled();
100
132
  });
101
- });
102
133
 
103
- // ── Isolated tests for untested git functions ────────────────────────────────
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
+ });
104
151
 
105
- function withTempDir(fn: (dir: string) => void) {
106
- const dir = join(tmpdir(), `tdd-git-extra-${Date.now()}`);
107
- mkdirSync(dir, { recursive: true });
108
- try {
109
- fn(dir);
110
- } finally {
111
- rmSync(dir, { recursive: true, force: true });
112
- }
113
- }
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
+ });
201
+ });
114
202
 
115
203
  describe("headMessage", () => {
116
- it("returns init commit message after initGit", () => {
117
- withTempDir((dir) => {
118
- initGit(dir);
119
- expect(headMessage(dir)).toBe("tdd: init");
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("");
120
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");
121
232
  });
122
233
 
123
234
  it("returns snapshot phase in commit message", () => {
124
- withTempDir((dir) => {
125
- initGit(dir);
126
- snapshot(dir, "green");
127
- expect(headMessage(dir)).toBe("tdd: green");
128
- });
235
+ outputs["log -1 --format=%s HEAD"] = "tdd: green\n";
236
+ expect(headMessage("/test", deps)).toBe("tdd: green");
129
237
  });
130
238
 
131
239
  it("updates after each snapshot", () => {
132
- withTempDir((dir) => {
133
- initGit(dir);
134
- snapshot(dir, "red");
135
- expect(headMessage(dir)).toBe("tdd: red");
136
- snapshot(dir, "green");
137
- expect(headMessage(dir)).toBe("tdd: green");
138
- snapshot(dir, "refactor");
139
- expect(headMessage(dir)).toBe("tdd: refactor");
140
- });
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");
141
246
  });
142
247
  });
143
248
 
144
249
  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
+
145
275
  it("returns false when only init commit exists", () => {
146
- withTempDir((dir) => {
147
- initGit(dir);
148
- expect(hasParent(dir)).toBe(false);
276
+ const mockExecSync = vi.fn((cmd: string) => {
277
+ if (cmd.includes("rev-parse HEAD~1")) throw new Error("unknown revision");
278
+ return Buffer.from("");
149
279
  });
280
+ deps = { ...deps, execSync: mockExecSync as any };
281
+ expect(hasParent("/test", deps)).toBe(false);
150
282
  });
151
283
 
152
284
  it("returns true after first snapshot", () => {
153
- withTempDir((dir) => {
154
- initGit(dir);
155
- snapshot(dir, "red");
156
- expect(hasParent(dir)).toBe(true);
285
+ const mockExecSync = vi.fn((cmd: string) => {
286
+ if (cmd.includes("rev-parse HEAD~1")) return Buffer.from("abc123\n");
287
+ return Buffer.from("");
157
288
  });
289
+ deps = { ...deps, execSync: mockExecSync as any };
290
+ expect(hasParent("/test", deps)).toBe(true);
158
291
  });
159
292
 
160
293
  it("returns true after multiple snapshots", () => {
161
- withTempDir((dir) => {
162
- initGit(dir);
163
- snapshot(dir, "red");
164
- snapshot(dir, "green");
165
- snapshot(dir, "refactor");
166
- expect(hasParent(dir)).toBe(true);
294
+ const mockExecSync = vi.fn((cmd: string) => {
295
+ if (cmd.includes("rev-parse HEAD~1")) return Buffer.from("abc123\n");
296
+ return Buffer.from("");
167
297
  });
298
+ deps = { ...deps, execSync: mockExecSync as any };
299
+ expect(hasParent("/test", deps)).toBe(true);
168
300
  });
169
301
 
170
302
  it("returns false after popping all snapshots back to init", () => {
171
- withTempDir((dir) => {
172
- initGit(dir);
173
- snapshot(dir, "red");
174
- expect(hasParent(dir)).toBe(true);
175
- undoLastCommit(dir);
176
- expect(hasParent(dir)).toBe(false);
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("");
177
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);
178
320
  });
179
321
  });
180
322
 
181
323
  describe("resetHard", () => {
182
- it("discards uncommitted changes", () => {
183
- withTempDir((dir) => {
184
- initGit(dir);
185
- writeFileSync(join(dir, "file.txt"), "original", "utf-8");
186
- snapshot(dir, "red");
187
- writeFileSync(join(dir, "file.txt"), "dirty", "utf-8");
188
- resetHard(dir);
189
- expect(readFileSync(join(dir, "file.txt"), "utf-8")).toBe("original");
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("");
190
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
+ );
191
359
  });
192
360
 
193
361
  it("discards untracked files", () => {
194
- withTempDir((dir) => {
195
- initGit(dir);
196
- snapshot(dir, "red");
197
- writeFileSync(join(dir, "scratch.txt"), "should vanish", "utf-8");
198
- resetHard(dir);
199
- expect(existsSync(join(dir, "scratch.txt"))).toBe(false);
200
- });
362
+ resetHard("/test", deps);
363
+ expect(deps.execSync).toHaveBeenCalledWith(
364
+ expect.stringContaining("git clean -fd"),
365
+ expect.any(Object),
366
+ );
201
367
  });
202
368
 
203
369
  it("leaves committed changes intact", () => {
204
- withTempDir((dir) => {
205
- initGit(dir);
206
- writeFileSync(join(dir, "stays.txt"), "persists", "utf-8");
207
- snapshot(dir, "red");
208
- resetHard(dir);
209
- expect(existsSync(join(dir, "stays.txt"))).toBe(true);
210
- expect(readFileSync(join(dir, "stays.txt"), "utf-8")).toBe("persists");
211
- });
370
+ resetHard("/test", deps);
371
+ expect(deps.execSync).toHaveBeenCalledWith(
372
+ expect.stringContaining("git reset --hard"),
373
+ expect.any(Object),
374
+ );
212
375
  });
213
376
 
214
377
  it("does not throw when working tree matches HEAD", () => {
215
- withTempDir((dir) => {
216
- initGit(dir);
217
- writeFileSync(join(dir, "file.txt"), "content", "utf-8");
218
- snapshot(dir, "red");
219
- expect(() => resetHard(dir)).not.toThrow();
220
- });
378
+ expect(() => resetHard("/test", deps)).not.toThrow();
221
379
  });
222
380
  });
223
381
 
224
382
  describe("undoLastCommit", () => {
225
- it("removes last commit and keeps its content as unstaged changes", () => {
226
- withTempDir((dir) => {
227
- initGit(dir);
228
- writeFileSync(join(dir, "file.txt"), "v1", "utf-8");
229
- snapshot(dir, "red");
230
- writeFileSync(join(dir, "file.txt"), "v2", "utf-8");
231
- snapshot(dir, "green");
232
-
233
- // No uncommitted changes before undo
234
- const before = changesSinceSnapshot(dir);
235
- expect(before).toHaveLength(0);
236
-
237
- undoLastCommit(dir);
238
-
239
- // HEAD is now red snapshot (v1), but WT still has v2
240
- expect(headMessage(dir)).toBe("tdd: red");
241
- expect(modifiedFiles(dir)).toContain("file.txt");
242
- expect(readFileSync(join(dir, "file.txt"), "utf-8")).toBe("v2");
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("");
243
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");
244
422
  });
245
423
 
246
424
  it("exposes popped content — new file stays in working tree and index", () => {
247
- withTempDir((dir) => {
248
- initGit(dir);
249
- snapshot(dir, "red");
250
- writeFileSync(join(dir, "new.txt"), "added in green", "utf-8");
251
- snapshot(dir, "green");
425
+ outputs["rev-parse HEAD"] = "hash1\n";
426
+ snapshot("/test", "red", deps);
427
+ outputs["rev-parse HEAD"] = "hash2\n";
428
+ snapshot("/test", "green", deps);
252
429
 
253
- expect(changesSinceSnapshot(dir)).toHaveLength(0);
430
+ undoLastCommit("/test", deps);
254
431
 
255
- undoLastCommit(dir);
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
+ });
256
436
 
257
- // git reset --soft preserves the index, so new.txt is still staged
258
- // It shows as modified (added) against HEAD, not as untracked
259
- expect(headMessage(dir)).toBe("tdd: red");
260
- expect(changesSinceSnapshot(dir)).toContain("new.txt");
261
- expect(readFileSync(join(dir, "new.txt"), "utf-8")).toBe("added in green");
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("");
262
441
  });
442
+ deps = { ...deps, execSync: mockExecSync as any };
443
+ expect(() => undoLastCommit("/test", deps)).toThrow();
263
444
  });
445
+ });
264
446
 
265
- it("errors when there is no parent commit", () => {
266
- withTempDir((dir) => {
267
- initGit(dir);
268
- expect(() => undoLastCommit(dir)).toThrow();
447
+ 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("");
269
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
+ });
484
+ });
485
+
486
+ 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");
270
529
  });
271
530
  });