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.
- package/adapters/pi/helpers.test.ts +220 -162
- package/adapters/pi/helpers.ts +50 -17
- package/adapters/pi/hooks.test.ts +472 -0
- package/adapters/pi/hooks.ts +215 -157
- package/adapters/pi/index.test.ts +302 -0
- package/adapters/pi/index.ts +192 -135
- package/adapters/pi/log.test.ts +63 -41
- package/adapters/pi/log.ts +13 -4
- package/adapters/pi/prompts.test.ts +35 -0
- package/adapters/pi/prompts.ts +13 -12
- package/adapters/pi/tools.test.ts +351 -0
- package/adapters/pi/tools.ts +322 -219
- package/behaviour.md +26 -11
- package/engine/config.test.ts +21 -21
- package/engine/config.ts +6 -6
- package/engine/enforce.test.ts +93 -27
- package/engine/enforce.ts +34 -10
- package/engine/git.test.ts +432 -173
- package/engine/git.ts +80 -49
- package/engine/index.ts +1 -1
- package/engine/transition.test.ts +63 -61
- package/engine/transition.ts +9 -2
- package/engine/types.ts +2 -2
- package/package.json +4 -1
- package/skills/tdd-enforcer/SKILL.md +22 -21
- package/tsconfig.json +12 -0
package/engine/git.test.ts
CHANGED
|
@@ -1,271 +1,530 @@
|
|
|
1
|
-
import { describe, it, expect,
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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(
|
|
23
|
-
expect(
|
|
24
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
expect(hash
|
|
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
|
-
|
|
35
|
-
const hash = snapshot(
|
|
36
|
-
expect(hash).
|
|
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
|
-
|
|
41
|
-
const modified = modifiedFiles(
|
|
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
|
-
|
|
47
|
-
const untracked = untrackedFiles(
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
const changes = changesSinceSnapshot(
|
|
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("
|
|
97
|
+
expect(changes).toContain("newfile.ts");
|
|
57
98
|
});
|
|
58
99
|
|
|
59
100
|
it("restoreFilesTo reverts specific files", () => {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
99
|
-
expect(
|
|
130
|
+
expect(() => restoreFilesTo("/test", [], undefined, deps)).not.toThrow();
|
|
131
|
+
expect(deps.execSync).not.toHaveBeenCalled();
|
|
100
132
|
});
|
|
101
|
-
});
|
|
102
133
|
|
|
103
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
125
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
430
|
+
undoLastCommit("/test", deps);
|
|
254
431
|
|
|
255
|
-
|
|
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
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
});
|