forked from CopilotKit/CopilotKit
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtest-cleanup.test.ts
More file actions
649 lines (562 loc) · 23.3 KB
/
Copy pathtest-cleanup.test.ts
File metadata and controls
649 lines (562 loc) · 23.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import fs from "fs";
import os from "os";
import path from "path";
import { execFileSync } from "child_process";
import {
FileSnapshotRestorer,
SAFE_EXEC_OPTS,
execOptsFor,
restoreFromGitHead,
} from "./test-cleanup";
// Unit tests for the shared test-cleanup harness. Covers:
// - FileSnapshotRestorer round trip (mutate + restore)
// - FileSnapshotRestorer ENOENT read handling (file deleted after snapshot)
// - FileSnapshotRestorer ENOENT write handling (parent dir deleted)
// - FileSnapshotRestorer re-invocation guard (snapshot twice throws)
// - FileSnapshotRestorer byte-exact round trip (non-utf8 bytes)
// - FileSnapshotRestorer sweeps atomic-write tmp stragglers on snapshot()
// - restoreFromGitHead narrow catch (benign pathspec vs fatal errors)
// - restoreFromGitHead accepts the allowlisted truthy CI values
// - restoreFromGitHead tracked/untracked partitioning (mixed path list)
// - restoreFromGitHead off-CI guard propagates stderr on re-raise
/** Env with all `GIT_*` vars stripped — pre-commit hooks (lefthook) run with
* GIT_DIR / GIT_INDEX_FILE / GIT_WORK_TREE set on process.env, which cause
* child `git commit` calls to ignore `cwd` and write to the HOST repo. Every
* test-owned subprocess must use this env so tmp-repo commits stay confined.
* Without this scrub, a developer running `git commit` (which triggers
* test-and-check-packages -> `pnpm run test` -> this file) would silently
* accumulate "initial" / "init" commits on the real working-tree HEAD. */
function cleanGitEnv(): NodeJS.ProcessEnv {
const out: NodeJS.ProcessEnv = {};
for (const [k, v] of Object.entries(process.env)) {
if (!k.startsWith("GIT_")) out[k] = v;
}
return out;
}
/** Exec options shared by every `git` subprocess spawned from this test file.
* Stdio is explicitly piped (not inherited) so child stdout/stderr can't
* interleave with the vitest worker's stdio streams — inherited stdio on a
* thread/fork vitest worker disrupts the worker→parent RPC channel on Node
* 20 and surfaces as "Timeout calling onTaskUpdate" during teardown. */
const TEST_GIT_STDIO = ["ignore", "pipe", "pipe"] as const;
function mkTmpRepo(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "test-cleanup-"));
const env = cleanGitEnv();
const opts = { cwd: dir, env, stdio: TEST_GIT_STDIO } as const;
execFileSync("git", ["init", "-q"], opts);
execFileSync("git", ["config", "user.email", "t@t"], opts);
execFileSync("git", ["config", "user.name", "t"], opts);
execFileSync("git", ["config", "commit.gpgsign", "false"], opts);
return dir;
}
function commitAll(repo: string, msg: string): void {
const env = cleanGitEnv();
const opts = { cwd: repo, env, stdio: TEST_GIT_STDIO } as const;
execFileSync("git", ["add", "-A"], opts);
execFileSync("git", ["commit", "-q", "-m", msg], opts);
}
describe("FileSnapshotRestorer", () => {
let tmp: string;
beforeEach(() => {
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "fsr-"));
});
afterEach(() => {
fs.rmSync(tmp, { recursive: true, force: true });
});
it("round trip: mutate then restore returns original content", () => {
const f = path.join(tmp, "a.txt");
fs.writeFileSync(f, "original");
const r = new FileSnapshotRestorer([f]);
r.snapshot();
fs.writeFileSync(f, "mutated");
expect(fs.readFileSync(f, "utf-8")).toBe("mutated");
r.restore();
expect(fs.readFileSync(f, "utf-8")).toBe("original");
});
it("byte-exact round trip preserves non-utf8 bytes", () => {
const f = path.join(tmp, "bin");
// 0xC3 followed by 0x28 is an invalid utf-8 sequence. A utf-8 string
// round-trip would replace it with U+FFFD; Buffer round-trip preserves it.
const bytes = Buffer.from([0x00, 0xc3, 0x28, 0xff]);
fs.writeFileSync(f, bytes);
const r = new FileSnapshotRestorer([f]);
r.snapshot();
fs.writeFileSync(f, Buffer.from([0x01, 0x02]));
r.restore();
const got = fs.readFileSync(f);
expect(got.equals(bytes)).toBe(true);
});
it("is a no-op on a clean run (no mtime churn)", () => {
const f = path.join(tmp, "a.txt");
fs.writeFileSync(f, "unchanged");
const r = new FileSnapshotRestorer([f]);
r.snapshot();
const before = fs.statSync(f).mtimeMs;
r.restore();
const after = fs.statSync(f).mtimeMs;
expect(after).toBe(before);
});
it("re-creates a snapshotted file that was deleted after snapshot", () => {
const f = path.join(tmp, "a.txt");
fs.writeFileSync(f, "gone");
const r = new FileSnapshotRestorer([f]);
r.snapshot();
fs.rmSync(f);
expect(fs.existsSync(f)).toBe(false);
r.restore();
expect(fs.readFileSync(f, "utf-8")).toBe("gone");
});
it("re-creates parent directory on write ENOENT", () => {
const f = path.join(tmp, "sub", "a.txt");
fs.mkdirSync(path.dirname(f), { recursive: true });
fs.writeFileSync(f, "deep");
const r = new FileSnapshotRestorer([f]);
r.snapshot();
fs.rmSync(path.dirname(f), { recursive: true });
expect(fs.existsSync(f)).toBe(false);
r.restore();
expect(fs.readFileSync(f, "utf-8")).toBe("deep");
});
it("ignores paths that don't exist at snapshot time", () => {
const r = new FileSnapshotRestorer([path.join(tmp, "nonexistent.txt")]);
r.snapshot();
expect(r.snapshotMap.size).toBe(0);
r.restore(); // no-op, shouldn't throw
});
it("throws when snapshot() is called twice on the same instance", () => {
const f = path.join(tmp, "a.txt");
fs.writeFileSync(f, "first");
const r = new FileSnapshotRestorer([f]);
r.snapshot();
expect(() => r.snapshot()).toThrow(
/called on a restorer that already has a snapshot/,
);
});
it("sweeps leftover atomic-write tmp stragglers on snapshot()", () => {
const f = path.join(tmp, "a.txt");
fs.writeFileSync(f, "content");
// Simulate a straggler matching the atomic-write naming convention
// (`.{basename}.{16-hex}.tmp`). SIGKILL between writeFileSync +
// renameSync would leave one of these behind.
const straggler = path.join(tmp, ".a.txt.0123456789abcdef.tmp");
fs.writeFileSync(straggler, "leftover");
expect(fs.existsSync(straggler)).toBe(true);
// An unrelated dot-tmp file that MUST be preserved (not our pattern).
const unrelated = path.join(tmp, ".editor-swap.tmp");
fs.writeFileSync(unrelated, "keep me");
const r = new FileSnapshotRestorer([f]);
r.snapshot();
expect(fs.existsSync(straggler)).toBe(false);
expect(fs.existsSync(unrelated)).toBe(true);
});
});
describe("restoreFromGitHead", () => {
let repo: string;
let savedCI: string | undefined;
beforeEach(() => {
repo = mkTmpRepo();
savedCI = process.env.CI;
// Default to CI=true; individual tests that need the off-CI guard
// override this inside the test body.
process.env.CI = "true";
});
afterEach(() => {
if (savedCI === undefined) delete process.env.CI;
else process.env.CI = savedCI;
fs.rmSync(repo, { recursive: true, force: true });
});
it("restores a tracked file from HEAD (on CI)", () => {
fs.writeFileSync(path.join(repo, "a.txt"), "committed");
commitAll(repo, "initial");
fs.writeFileSync(path.join(repo, "a.txt"), "drift");
restoreFromGitHead(repo, ["a.txt"]);
expect(fs.readFileSync(path.join(repo, "a.txt"), "utf-8")).toBe(
"committed",
);
});
it("accepts CI=1 as truthy", () => {
process.env.CI = "1";
fs.writeFileSync(path.join(repo, "a.txt"), "committed");
commitAll(repo, "initial");
fs.writeFileSync(path.join(repo, "a.txt"), "drift");
expect(() => restoreFromGitHead(repo, ["a.txt"])).not.toThrow();
expect(fs.readFileSync(path.join(repo, "a.txt"), "utf-8")).toBe(
"committed",
);
});
it("accepts CI=yes as truthy (case-insensitive)", () => {
process.env.CI = "YES";
fs.writeFileSync(path.join(repo, "a.txt"), "committed");
commitAll(repo, "initial");
fs.writeFileSync(path.join(repo, "a.txt"), "drift");
expect(() => restoreFromGitHead(repo, ["a.txt"])).not.toThrow();
});
it("treats CI='false', CI='0', and arbitrary strings as off", () => {
fs.writeFileSync(path.join(repo, "a.txt"), "committed");
commitAll(repo, "initial");
fs.writeFileSync(path.join(repo, "a.txt"), "wip");
process.env.CI = "false";
expect(() => restoreFromGitHead(repo, ["a.txt"])).toThrow(
/refusing to overwrite/,
);
process.env.CI = "0";
expect(() => restoreFromGitHead(repo, ["a.txt"])).toThrow(
/refusing to overwrite/,
);
// Allowlist strictness: a random value is off, not on.
process.env.CI = "on";
expect(() => restoreFromGitHead(repo, ["a.txt"])).toThrow(
/refusing to overwrite/,
);
});
it("skips untracked paths when mixed with tracked peers (benign pathspec)", () => {
// Mixed lists must succeed: partitionTrackedPaths filters out the
// untracked entry and the tracked entry is healed normally. (An
// all-untracked call is a separate case — covered by the
// "drifted baseline guard" block.)
fs.writeFileSync(path.join(repo, "a.txt"), "committed");
commitAll(repo, "initial");
fs.writeFileSync(path.join(repo, "a.txt"), "drift");
expect(() => restoreFromGitHead(repo, ["a.txt", "nope.txt"])).not.toThrow();
expect(fs.readFileSync(path.join(repo, "a.txt"), "utf-8")).toBe(
"committed",
);
});
it("handles mixed tracked+untracked lists without masking dirty tracked files", () => {
// Regression guard: a mixed tracked/untracked list previously caused
// `git diff --quiet` to exit 128 (pathspec mismatch from untracked),
// which the guard treated as "nothing to clobber" and silently
// overwrote the dirty tracked file.
delete process.env.CI;
fs.writeFileSync(path.join(repo, "tracked.txt"), "committed");
commitAll(repo, "initial");
// Dirty tracked file + one untracked path in the same call.
fs.writeFileSync(path.join(repo, "tracked.txt"), "wip");
expect(() =>
restoreFromGitHead(repo, ["tracked.txt", "untracked.txt"]),
).toThrow(/refusing to overwrite uncommitted changes/);
// Critically: the dirty tracked file must NOT have been clobbered.
expect(fs.readFileSync(path.join(repo, "tracked.txt"), "utf-8")).toBe(
"wip",
);
});
it("off-CI, refuses to clobber uncommitted tracked-file changes", () => {
delete process.env.CI;
fs.writeFileSync(path.join(repo, "a.txt"), "committed");
commitAll(repo, "initial");
// Create dev-style uncommitted edit
fs.writeFileSync(path.join(repo, "a.txt"), "wip");
expect(() => restoreFromGitHead(repo, ["a.txt"])).toThrow(
/refusing to overwrite uncommitted changes/,
);
// File must remain unchanged
expect(fs.readFileSync(path.join(repo, "a.txt"), "utf-8")).toBe("wip");
});
it("off-CI error message mentions the discard alternative", () => {
delete process.env.CI;
fs.writeFileSync(path.join(repo, "a.txt"), "committed");
commitAll(repo, "initial");
fs.writeFileSync(path.join(repo, "a.txt"), "wip");
try {
restoreFromGitHead(repo, ["a.txt"]);
throw new Error("should have thrown");
} catch (err) {
expect((err as Error).message).toMatch(/git checkout HEAD --/);
}
});
it("off-CI, heals when tree is clean wrt the target paths", () => {
delete process.env.CI;
fs.writeFileSync(path.join(repo, "a.txt"), "committed");
commitAll(repo, "initial");
// clean tree -> heal is a no-op but must not throw
expect(() => restoreFromGitHead(repo, ["a.txt"])).not.toThrow();
expect(fs.readFileSync(path.join(repo, "a.txt"), "utf-8")).toBe(
"committed",
);
});
});
describe("SAFE_EXEC_OPTS", () => {
it("exposes stdio ignore/pipe/pipe and a bounded timeout", () => {
expect(SAFE_EXEC_OPTS.stdio).toEqual(["ignore", "pipe", "pipe"]);
expect(SAFE_EXEC_OPTS.timeout).toBe(30000);
expect(SAFE_EXEC_OPTS.maxBuffer).toBe(10 * 1024 * 1024);
});
it("freezes the inner stdio array (not just the outer object)", () => {
expect(Object.isFrozen(SAFE_EXEC_OPTS)).toBe(true);
expect(Object.isFrozen(SAFE_EXEC_OPTS.stdio)).toBe(true);
});
});
describe("execOptsFor", () => {
it("returns a frozen object with cwd and the SAFE_EXEC_OPTS defaults", () => {
const opts = execOptsFor("/some/path");
expect(opts.cwd).toBe("/some/path");
expect(opts.stdio).toEqual(["ignore", "pipe", "pipe"]);
expect(opts.timeout).toBe(30000);
expect(Object.isFrozen(opts)).toBe(true);
});
});
// --- Regression guards: narrow-catch + per-basename sweep + drift guard ---
describe("restoreFromGitHead: narrow catch in partitionTrackedPaths", () => {
let savedCI: string | undefined;
beforeEach(() => {
savedCI = process.env.CI;
process.env.CI = "true";
});
afterEach(() => {
if (savedCI === undefined) delete process.env.CI;
else process.env.CI = savedCI;
});
it("fails loudly when the git binary is missing (PATH empty)", () => {
const repo = fs.mkdtempSync(path.join(os.tmpdir(), "fsr-nogit-"));
try {
const env = cleanGitEnv();
const opts = { cwd: repo, env, stdio: TEST_GIT_STDIO } as const;
execFileSync("git", ["init", "-q"], opts);
fs.writeFileSync(path.join(repo, "a.txt"), "x");
execFileSync("git", ["config", "user.email", "t@t"], opts);
execFileSync("git", ["config", "user.name", "t"], opts);
execFileSync("git", ["config", "commit.gpgsign", "false"], opts);
execFileSync("git", ["add", "-A"], opts);
execFileSync("git", ["commit", "-q", "-m", "init"], opts);
// Force PATH to an empty dir so the spawned `git` fails with ENOENT.
// Prior to the narrow-catch fix, `partitionTrackedPaths` swallowed
// ENOENT and treated the path as "untracked", causing
// `restoreFromGitHead` to silently no-op and lock in the drifted
// baseline.
const emptyDir = fs.mkdtempSync(path.join(os.tmpdir(), "fsr-empty-"));
const savedPath = process.env.PATH;
process.env.PATH = emptyDir;
try {
expect(() => restoreFromGitHead(repo, ["a.txt"])).toThrow(
/partitionTrackedPaths|git ls-files/,
);
} finally {
process.env.PATH = savedPath;
fs.rmSync(emptyDir, { recursive: true, force: true });
}
} finally {
fs.rmSync(repo, { recursive: true, force: true });
}
});
});
describe("FileSnapshotRestorer: sweepTmpStragglers basename scope", () => {
let tmp: string;
beforeEach(() => {
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "fsr-sweep-"));
});
afterEach(() => {
fs.rmSync(tmp, { recursive: true, force: true });
});
it("does NOT sweep same-shaped tmp files for unrelated basenames", () => {
// Only `a.txt` is in the snapshot scope. A `.b.txt.<hex>.tmp` straggler
// must survive the sweep — it belongs to a different snapshot target
// (possibly run by a different tool in the same directory). Prior to
// the per-basename tightening, the generic regex
// `/^\..+\.[0-9a-f]{16}\.tmp$/` matched and deleted any file of this
// shape.
const a = path.join(tmp, "a.txt");
fs.writeFileSync(a, "x");
const ourStraggler = path.join(tmp, ".a.txt.0123456789abcdef.tmp");
fs.writeFileSync(ourStraggler, "ours");
const foreignStraggler = path.join(tmp, ".b.txt.0123456789abcdef.tmp");
fs.writeFileSync(foreignStraggler, "foreign");
const r = new FileSnapshotRestorer([a]);
r.snapshot();
expect(fs.existsSync(ourStraggler)).toBe(false);
expect(fs.existsSync(foreignStraggler)).toBe(true);
});
});
describe("FileSnapshotRestorer: double-snapshot guard (flag-based)", () => {
let tmp: string;
beforeEach(() => {
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "fsr-dbl-"));
});
afterEach(() => {
fs.rmSync(tmp, { recursive: true, force: true });
});
it("throws on second snapshot() even when the path list matched nothing", () => {
// Prior to the flag-based guard, the check was size-based
// (`snapshots.size > 0`); with zero matching paths the size stayed 0
// forever and a second snapshot() would silently succeed.
const r = new FileSnapshotRestorer([path.join(tmp, "never.txt")]);
r.snapshot();
expect(r.snapshotMap.size).toBe(0);
expect(() => r.snapshot()).toThrow(
/called on a restorer that already has a snapshot/,
);
});
});
// Note: we intentionally do NOT test the `GIT_*` scrub by setting
// `process.env.GIT_DIR` in the test body — a polluted process.env has
// catastrophic blast-radius (any other git call in ANY parallel vitest
// suite or pre-commit hook would misroute to our decoy repo, and if our
// afterEach is skipped for any reason we'd silently corrupt the real
// working tree). The unit under test is `gitEnv()`, which we cover via its
// observable behavior: the existing "restores a tracked file from HEAD (on
// CI)" / mixed-list tests exercise the git-subprocess path with a real
// repo and would fail immediately if `gitEnv` stopped forwarding PATH,
// HOME, etc. The scrub itself is a simple `!k.startsWith("GIT_")` loop.
describe("restoreFromGitHead: drifted baseline guard", () => {
let repo: string;
let savedCI: string | undefined;
beforeEach(() => {
repo = mkTmpRepo();
savedCI = process.env.CI;
process.env.CI = "true";
});
afterEach(() => {
if (savedCI === undefined) delete process.env.CI;
else process.env.CI = savedCI;
fs.rmSync(repo, { recursive: true, force: true });
});
it("throws on CI when the input has paths but none are tracked", () => {
// Prior to the drifted-baseline guard, this silently early-returned
// and the caller would snapshot whatever drifted content was on disk.
expect(() => restoreFromGitHead(repo, ["totally-untracked.txt"])).toThrow(
/no input path is tracked by git/,
);
});
it("warns (does not throw) off-CI when nothing is tracked", () => {
delete process.env.CI;
// Off-CI we don't want to disrupt a developer running tests against a
// tree that may not yet have committed these files. Warn and return.
const warnings: string[] = [];
const origWarn = console.warn;
console.warn = (msg: unknown) => {
warnings.push(String(msg));
};
try {
expect(() =>
restoreFromGitHead(repo, ["totally-untracked.txt"]),
).not.toThrow();
expect(
warnings.some((w) => /no input path is tracked by git/.test(w)),
).toBe(true);
} finally {
console.warn = origWarn;
}
});
// --- Post-heal drift guard: the `git checkout HEAD --` above must leave the
// tracked paths byte-identical to HEAD. We simulate a hostile layer by
// stubbing execFileSync at the module level? No — simpler: we stub
// `checkout` indirectly by preloading the file with drift AFTER checkout
// would have run. We can't intercept the real checkout, so instead we
// cover the guard by replacing the `git` binary with a wrapper that
// rewrites the file to drifted content. Too invasive. Simplest direct
// cover: make `git diff --quiet` exit non-zero by monkey-patching PATH
// to a shim that reports drift. Skipped as over-engineered.
//
// Instead, verify the structural invariant: on CI, after a successful
// path-partition + checkout, a repo whose tracked file genuinely matches
// HEAD must NOT trigger the guard. Red-green: an earlier revision of
// this module lacked the post-heal diff and this test would have
// silently passed; the drift-scenario coverage is exercised by the
// integration test suites (create-integration, generate-registry,
// bundle-demo-content) which all run under CI=true.
it("does not false-positive on a clean tracked path (CI)", () => {
const a = path.join(repo, "a.txt");
fs.writeFileSync(a, "baseline\n");
commitAll(repo, "baseline");
// Tree is clean; checkout is a no-op; post-heal diff must be clean.
expect(() => restoreFromGitHead(repo, ["a.txt"])).not.toThrow();
// File still matches HEAD.
expect(fs.readFileSync(a, "utf-8")).toBe("baseline\n");
});
/** Build a shim `git` wrapper that fails the N-th `diff --quiet` invocation
* (1-indexed). Earlier diff calls pass through to the real git. Used to
* distinguish the off-CI pre-checkout dirty-tracked-file check (1st diff)
* from the post-heal drift guard (2nd diff) without false positives.
*
* State is persisted in a counter file in the shim dir so the same shim
* can be used across multiple restoreFromGitHead calls in one test. */
function mkGitShim(failNthDiff: number): {
shimDir: string;
cleanup: () => void;
activate: () => string | undefined;
deactivate: (saved: string | undefined) => void;
} {
const shimDir = fs.mkdtempSync(path.join(os.tmpdir(), "git-shim-"));
const counterFile = path.join(shimDir, "counter");
fs.writeFileSync(counterFile, "0");
const realGit = execFileSync("which", ["git"], {
encoding: "utf-8" as const,
})
.toString()
.trim();
const shim = path.join(shimDir, "git");
fs.writeFileSync(
shim,
`#!/usr/bin/env bash\n` +
`COUNTER_FILE=${JSON.stringify(counterFile)}\n` +
`FAIL_N=${failNthDiff}\n` +
`if [ "$1" = "diff" ] && [ "$2" = "--quiet" ]; then\n` +
` n=$(cat "$COUNTER_FILE")\n` +
` n=$((n+1))\n` +
` echo "$n" > "$COUNTER_FILE"\n` +
` if [ "$n" = "$FAIL_N" ]; then\n` +
` exit 1\n` +
` fi\n` +
`fi\n` +
`exec ${JSON.stringify(realGit)} "$@"\n`,
{ mode: 0o755 },
);
return {
shimDir,
cleanup: () => fs.rmSync(shimDir, { recursive: true, force: true }),
activate: () => {
const saved = process.env.PATH;
process.env.PATH = `${shimDir}:${saved ?? ""}`;
return saved;
},
deactivate: (saved) => {
process.env.PATH = saved;
},
};
}
// Direct cover of the guard's throw path: use a git shim that makes the
// POST-HEAL diff (the 2nd `diff --quiet`) exit 1. On CI there's only one
// diff call (the post-heal) so `failNthDiff: 1` is correct.
it("throws on CI when a post-heal diff reports drift", () => {
const a = path.join(repo, "a.txt");
fs.writeFileSync(a, "baseline\n");
commitAll(repo, "baseline");
const shim = mkGitShim(1);
const saved = shim.activate();
try {
expect(() => restoreFromGitHead(repo, ["a.txt"])).toThrow(
/drifted-baseline guard: post-heal diff failed/,
);
} finally {
shim.deactivate(saved);
shim.cleanup();
}
});
it("warns (does not throw) off-CI when a post-heal diff reports drift", () => {
delete process.env.CI;
const a = path.join(repo, "a.txt");
fs.writeFileSync(a, "baseline\n");
commitAll(repo, "baseline");
// Off-CI there are TWO diff calls: (1) the pre-checkout dirty-tracked
// check, (2) the post-heal drift guard. We want to fail only the 2nd.
const shim = mkGitShim(2);
const saved = shim.activate();
const warnings: string[] = [];
const origWarn = console.warn;
console.warn = (msg: unknown) => {
warnings.push(String(msg));
};
try {
expect(() => restoreFromGitHead(repo, ["a.txt"])).not.toThrow();
expect(
warnings.some((w) =>
/drifted-baseline guard: post-heal diff failed/.test(w),
),
).toBe(true);
} finally {
console.warn = origWarn;
shim.deactivate(saved);
shim.cleanup();
}
});
});