1import * as NodeServices from "@effect/platform-node/NodeServices";
2import * as Cause from "effect/Cause";
3import * as Clock from "effect/Clock";
4import * as Duration from "effect/Duration";
5import * as Exit from "effect/Exit";
6import * as Deferred from "effect/Deferred";
7import * as Effect from "effect/Effect";
8import * as Fiber from "effect/Fiber";
9import * as TestClock from "effect/testing/TestClock";
10import * as FileSystem from "effect/FileSystem";
11import * as Layer from "effect/Layer";
12import * as Path from "effect/Path";
13import * as PlatformError from "effect/PlatformError";
14import { ChildProcessSpawner } from "effect/process";
15import { assert, it } from "@effect/vitest";
16
17import { CheckpointRef, GitCommandError, VcsProcessExitError } from "@t3tools/contracts";
18import * as ServerConfig from "../config.ts";
19import * as CheckpointStore from "../checkpointing/CheckpointStore.ts";
20import * as ProcessRunner from "../processRunner.ts";
21import * as GitVcsDriver from "./GitVcsDriver.ts";
22import type * as VcsDriver from "./VcsDriver.ts";
23import * as VcsDriverRegistry from "./VcsDriverRegistry.ts";
24import * as VcsProcess from "./VcsProcess.ts";
25import { runVcsDriverContractSuite } from "./testing/VcsDriverContractHarness.ts";
26
27const layerServerConfig = ServerConfig.layerTest(process.cwd(), {
28 prefix: "t3-git-vcs-contract-",
29});
30const layerGitContract = Layer.mergeAll(GitVcsDriver.layerVcs, GitVcsDriver.layer).pipe(
31 Layer.provide(layerServerConfig),
32 Layer.provideMerge(VcsProcess.layer),
33 Layer.provideMerge(NodeServices.layer),
34);
35const layerGitCaptureContract = Layer.merge(
36 layerGitContract,
37 ProcessRunner.layer.pipe(Layer.provide(NodeServices.layer)),
38);
39
40const runGit = (cwd: string, args: ReadonlyArray<string>) =>
41 Effect.gen(function* () {
42 const driver = yield* GitVcsDriver.GitVcsDriver;
43 yield* driver.execute({
44 operation: "GitVcsDriver.contract.git",
45 cwd,
46 args,
47 timeoutMs: 10_000,
48 });
49 });
50
51const makeCaptureStore = Effect.fn("test.makeCaptureStore")(function* (
52 driver: VcsDriver.VcsDriver["Service"],
53 cwd: string,
54) {
55 const repository = yield* driver.detectRepository(cwd);
56 if (repository === null) return yield* Effect.die("Expected a test Git repository");
57 const handle = { kind: repository.kind, repository, driver };
58 return yield* CheckpointStore.make.pipe(
59 Effect.provideService(VcsDriverRegistry.VcsDriverRegistry, {
60 get: () => Effect.succeed(driver),
61 detect: () => Effect.succeed(handle),
62 resolve: () => Effect.succeed(handle),
63 }),
64 );
65});
66
67type GitContractError = GitCommandError | PlatformError.PlatformError;
68
69runVcsDriverContractSuite<GitVcsDriver.GitVcsDriver, GitContractError>({
70 name: "Git",
71 kind: "git",
72 layer: layerGitContract,
73 fixture: {
74 createRepo: (cwd) =>
75 Effect.gen(function* () {
76 yield* runGit(cwd, ["init"]);
77 yield* runGit(cwd, ["config", "user.email", "test@test.com"]);
78 yield* runGit(cwd, ["config", "user.name", "Test"]);
79 }),
80 writeFile: (cwd, relativePath, contents) =>
81 Effect.gen(function* () {
82 const fileSystem = yield* FileSystem.FileSystem;
83 const path = yield* Path.Path;
84 const absolutePath = path.join(cwd, relativePath);
85 yield* fileSystem.makeDirectory(path.dirname(absolutePath), { recursive: true });
86 yield* fileSystem.writeFileString(absolutePath, contents);
87 }),
88 trackFile: (cwd, relativePath) => runGit(cwd, ["add", relativePath]),
89 commit: (cwd, message) => runGit(cwd, ["commit", "-m", message]),
90 ignorePath: (cwd, pattern) =>
91 Effect.gen(function* () {
92 const fileSystem = yield* FileSystem.FileSystem;
93 const path = yield* Path.Path;
94 yield* fileSystem.writeFileString(path.join(cwd, ".gitignore"), `${pattern}\n`);
95 }),
96 },
97});
98
99const makeCheckpointFixture = Effect.fn("makeCheckpointFixture")(function* (
100 driver: Effect.Success<ReturnType<typeof GitVcsDriver.makeVcsDriverShape>>,
101 cwd: string,
102) {
103 const fileSystem = yield* FileSystem.FileSystem;
104 const path = yield* Path.Path;
105 const git = (args: ReadonlyArray<string>) =>
106 driver.execute({ operation: "checkpoint-test", cwd, args });
107 yield* git(["init"]);
108 yield* git(["config", "user.name", "Test"]);
109 yield* git(["config", "user.email", "test@test.com"]);
110 yield* fileSystem.writeFileString(path.join(cwd, "file.txt"), "initial\n");
111 yield* git(["add", "."]);
112 yield* git(["commit", "-m", "initial"]);
113 const checkpointRef = CheckpointRef.make("refs/t3/checkpoints/test");
114 yield* fileSystem.writeFileString(path.join(cwd, "file.txt"), "staged\n");
115 yield* git(["add", "."]);
116 yield* fileSystem.writeFileString(path.join(cwd, "file.txt"), "unstaged\n");
117 return { git, checkpointRef };
118});
119
120it.effect("checkpoint capture skips untracked nested repositories without a commit", () =>
121 Effect.gen(function* () {
122 const fileSystem = yield* FileSystem.FileSystem;
123 const path = yield* Path.Path;
124 const driver = yield* GitVcsDriver.makeVcsDriverShape();
125 const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-checkpoint-unborn-" });
126 const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd);
127 const nested = "scratch/empty [repo]";
128 yield* git(["init", nested]);
129 yield* git(["init", "another empty"]);
130 yield* fileSystem.writeFileString(path.join(cwd, nested, "private.txt"), "nested\n");
131 yield* git(["init", "committed"]);
132 yield* git([
133 "-C",
134 "committed",
135 "-c",
136 "user.name=Test",
137 "-c",
138 "user.email=test@test.com",
139 "commit",
140 "--allow-empty",
141 "-m",
142 "initial",
143 ]);
144 const nestedHead = (yield* git(["-C", "committed", "rev-parse", "HEAD"])).stdout.trim();
145 yield* fileSystem.writeFileString(path.join(cwd, "untracked.txt"), "new\n");
146 const originalIndex = yield* fileSystem.readFile(path.join(cwd, ".git", "index"));
147
148 yield* driver.checkpoints.captureCheckpoint({ cwd, checkpointRef });
149
150 assert.strictEqual((yield* git(["show", `${checkpointRef}:file.txt`])).stdout, "unstaged\n");
151 assert.strictEqual((yield* git(["show", `${checkpointRef}:untracked.txt`])).stdout, "new\n");
152 assert.strictEqual((yield* git(["ls-tree", "-r", checkpointRef, "--", nested])).stdout, "");
153 assert.strictEqual((yield* git(["ls-tree", checkpointRef, "--", "another empty"])).stdout, "");
154 assert.strictEqual(
155 (yield* git(["ls-tree", checkpointRef, "--", "committed"])).stdout,
156 `160000 commit ${nestedHead}\tcommitted\n`,
157 );
158 assert.deepEqual(yield* fileSystem.readFile(path.join(cwd, ".git", "index")), originalIndex);
159 assert.strictEqual(
160 yield* fileSystem.readFileString(path.join(cwd, nested, "private.txt")),
161 "nested\n",
162 );
163 }).pipe(Effect.scoped, Effect.provide(layerGitContract)),
164);
165
166it.effect("checkpoint recovery discovers nested HEAD independently of inherited GIT_DIR", () =>
167 Effect.gen(function* () {
168 const fs = yield* FileSystem.FileSystem;
169 const path = yield* Path.Path;
170 const driver = yield* GitVcsDriver.makeVcsDriverShape();
171 const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-git-dir-" });
172 const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd);
173 yield* git(["init", "empty"]);
174 const originalIndex = yield* fs.readFile(path.join(cwd, ".git", "index"));
175 yield* Effect.acquireUseRelease(
176 Effect.sync(() => {
177 const previous = process.env.GIT_DIR;
178 process.env.GIT_DIR = path.join(cwd, ".git");
179 return previous;
180 }),
181 () => driver.checkpoints.captureCheckpoint({ cwd, checkpointRef }),
182 (previous) =>
183 Effect.sync(() => {
184 if (previous === undefined) delete process.env.GIT_DIR;
185 else process.env.GIT_DIR = previous;
186 }),
187 );
188 assert.strictEqual((yield* git(["show", `${checkpointRef}:file.txt`])).stdout, "unstaged\n");
189 assert.strictEqual((yield* git(["ls-tree", "-r", checkpointRef, "--", "empty"])).stdout, "");
190 assert.deepEqual(yield* fs.readFile(path.join(cwd, ".git", "index")), originalIndex);
191 }).pipe(Effect.scoped, Effect.provide(layerGitContract)),
192);
193
194it.effect("checkpoint capture still fails when a clean filter rejects a file", () =>
195 Effect.gen(function* () {
196 const fileSystem = yield* FileSystem.FileSystem;
197 const path = yield* Path.Path;
198 const driver = yield* GitVcsDriver.makeVcsDriverShape();
199 const cwd = yield* fileSystem.makeTempDirectoryScoped({
200 prefix: "t3-checkpoint-filter-failure-",
201 });
202 const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd);
203 yield* fileSystem.writeFileString(path.join(cwd, ".gitattributes"), "file.txt filter=reject\n");
204 yield* git(["config", "filter.reject.clean", "false"]);
205 yield* git(["config", "filter.reject.required", "true"]);
206 const originalIndex = yield* fileSystem.readFile(path.join(cwd, ".git", "index"));
207
208 const result = yield* Effect.result(
209 driver.checkpoints.captureCheckpoint({ cwd, checkpointRef }),
210 );
211
212 assert.strictEqual(result._tag, "Failure");
213 assert.deepEqual(yield* fileSystem.readFile(path.join(cwd, ".git", "index")), originalIndex);
214 assert.isFalse(yield* driver.checkpoints.hasCheckpointRef({ cwd, checkpointRef }));
215 }).pipe(Effect.scoped, Effect.provide(layerGitContract)),
216);
217
218it.effect("checkpoint capture refuses a truncated nested repository listing", () =>
219 Effect.gen(function* () {
220 const fs = yield* FileSystem.FileSystem;
221 const liveProcess = yield* VcsProcess.VcsProcess;
222 const driver = yield* GitVcsDriver.makeVcsDriverShape();
223 const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-truncated-" });
224 const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd);
225 yield* git(["init", "empty"]);
226 const captureDriver = yield* GitVcsDriver.makeVcsDriverShape().pipe(
227 Effect.provideService(VcsProcess.VcsProcess, {
228 run: (input) =>
229 liveProcess
230 .run(input)
231 .pipe(
232 Effect.map((result) =>
233 input.args.includes("--others") ? { ...result, stdoutTruncated: true } : result,
234 ),
235 ),
236 }),
237 );
238
239 const result = yield* Effect.result(
240 captureDriver.checkpoints.captureCheckpoint({ cwd, checkpointRef }),
241 );
242
243 assert.strictEqual(result._tag, "Failure");
244 assert.isFalse(yield* driver.checkpoints.hasCheckpointRef({ cwd, checkpointRef }));
245 }).pipe(Effect.scoped, Effect.provide(layerGitContract)),
246);
247
248it.effect("checkpoint recovery refuses excessive candidates before probing", () =>
249 Effect.gen(function* () {
250 const fs = yield* FileSystem.FileSystem;
251 const path = yield* Path.Path;
252 const liveProcess = yield* VcsProcess.VcsProcess;
253 const driver = yield* GitVcsDriver.makeVcsDriverShape();
254 const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-recovery-cap-" });
255 const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd);
256 yield* git(["init", "empty0"]);
257 for (let i = 1; i < 65; i++)
258 yield* fs.copy(path.join(cwd, "empty0"), path.join(cwd, `empty${i}`));
259 const originalIndex = yield* fs.readFile(path.join(cwd, ".git", "index"));
260 let stageError: VcsProcessExitError | undefined;
261 let nestedProbes = 0;
262 let stageAttempts = 0;
263 const captureDriver = yield* GitVcsDriver.makeVcsDriverShape().pipe(
264 Effect.provideService(VcsProcess.VcsProcess, {
265 run: (input) => {
266 if (input.cwd !== cwd && input.args.includes("rev-parse")) nestedProbes++;
267 if (input.args.includes("add") && input.args.includes("-A")) stageAttempts++;
268 return liveProcess.run(input).pipe(
269 Effect.tapError((error) => {
270 if (error._tag === "VcsProcessExitError") stageError = error;
271 return Effect.void;
272 }),
273 );
274 },
275 }),
276 );
277 const result = yield* Effect.result(
278 captureDriver.checkpoints.captureCheckpoint({ cwd, checkpointRef }),
279 );
280 assert.strictEqual(nestedProbes, 0);
281 assert.strictEqual(stageAttempts, 1);
282 assert.strictEqual(result._tag, "Failure");
283 if (result._tag === "Failure") assert.strictEqual(result.failure, stageError);
284 assert.isFalse(yield* driver.checkpoints.hasCheckpointRef({ cwd, checkpointRef }));
285 assert.deepEqual(yield* fs.readFile(path.join(cwd, ".git", "index")), originalIndex);
286 }).pipe(Effect.scoped, Effect.provide(layerGitContract)),
287);
288
289it.effect.each([
290 { phase: "add", nestedRecovery: false, expireRecovery: false },
291 { phase: "update-ref", nestedRecovery: false, expireRecovery: false },
292 { phase: "update-ref", nestedRecovery: true, expireRecovery: false },
293 { phase: "add", nestedRecovery: true, expireRecovery: false },
294 { phase: "add", nestedRecovery: true, expireRecovery: true },
295])(
296 "checkpoint handles a $phase lock with nestedRecovery=$nestedRecovery, expireRecovery=$expireRecovery",
297 ({ phase, nestedRecovery, expireRecovery }) =>
298 Effect.gen(function* () {
299 const fs = yield* FileSystem.FileSystem;
300 const path = yield* Path.Path;
301 const liveRunner = yield* ProcessRunner.ProcessRunner;
302 const driver = yield* GitVcsDriver.makeVcsDriverShape();
303 const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-ref-race-" });
304 const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd);
305 if (nestedRecovery) yield* git(["init", "empty"]);
306 const originalIndex = yield* fs.readFile(path.join(cwd, ".git", "index"));
307 const refLockPath = path.join(cwd, ".git", `${checkpointRef}.lock`);
308 const failed = yield* Deferred.make<void>();
309 const retryReached = yield* Deferred.make<void>();
310 const allowRetry = yield* Deferred.make<void>();
311 const clock = yield* Clock.Clock;
312 const privateIndexes = new Set<string>();
313 let racedAttempts = 0;
314 let discoveries = 0;
315 let stageError: VcsProcessExitError | undefined;
316 const captureProcess = yield* VcsProcess.make.pipe(
317 Effect.provideService(ProcessRunner.ProcessRunner, {
318 run: (input) => {
319 if (input.args.includes("--others")) discoveries += 1;
320 if (input.env?.GIT_INDEX_FILE) privateIndexes.add(input.env.GIT_INDEX_FILE);
321 const initialStage =
322 phase === "add" &&
323 nestedRecovery &&
324 !input.args.some((arg) => arg.startsWith(":(exclude,literal)"));
325 if (!input.args.includes(phase) || initialStage || ++racedAttempts !== 1) {
326 return liveRunner.run(input);
327 }
328 const lockPath = phase === "add" ? `${input.env!.GIT_INDEX_FILE!}.lock` : refLockPath;
329 return Effect.gen(function* () {
330 yield* fs
331 .makeDirectory(path.dirname(lockPath), { recursive: true })
332 .pipe(Effect.orDie);
333 yield* fs.writeFileString(lockPath, "concurrent ref writer").pipe(Effect.orDie);
334 return yield* liveRunner.run(input).pipe(
335 Effect.ensuring(fs.remove(lockPath).pipe(Effect.orDie)),
336 Effect.tap(() => Deferred.succeed(failed, undefined)),
337 );
338 });
339 },
340 }),
341 );
342 const captureDriver = yield* GitVcsDriver.makeVcsDriverShape().pipe(
343 Effect.provideService(VcsProcess.VcsProcess, {
344 run: (input) =>
345 captureProcess.run(input).pipe(
346 Effect.tapError((error) => {
347 if (input.args.includes("add") && error._tag === "VcsProcessExitError")
348 stageError = error;
349 return Effect.void;
350 }),
351 ),
352 }),
353 );
354 const captureStore = yield* makeCaptureStore(captureDriver, cwd);
355 const fiber = yield* captureStore.captureCheckpoint({ cwd, checkpointRef }).pipe(
356 Effect.provideService(Clock.Clock, {
357 ...clock,
358 sleep: (duration) =>
359 Duration.toMillis(duration) === 75
360 ? Deferred.succeed(retryReached, undefined).pipe(
361 Effect.andThen(Deferred.await(allowRetry)),
362 )
363 : clock.sleep(duration),
364 }),
365 Effect.exit,
366 Effect.forkScoped,
367 );
368 yield* Deferred.await(failed);
369 yield* Deferred.await(retryReached);
370 if (expireRecovery) yield* TestClock.adjust("5 seconds");
371 else yield* Deferred.succeed(allowRetry, undefined);
372 const result = yield* Fiber.join(fiber);
373 if (expireRecovery) {
374 if (Exit.isSuccess(result))
375 return yield* Effect.die("Expected the recovery deadline to expire");
376 const error = Cause.findErrorOption(result.cause);
377 assert.isTrue(error._tag === "Some");
378 if (error._tag === "Some") assert.strictEqual(error.value, stageError);
379 } else assert.isTrue(Exit.isSuccess(result));
380 assert.strictEqual(racedAttempts, expireRecovery ? 1 : 2);
381 assert.strictEqual(discoveries, nestedRecovery ? 1 : 0);
382 assert.strictEqual(privateIndexes.size, 1);
383 assert.strictEqual(
384 yield* driver.checkpoints.hasCheckpointRef({ cwd, checkpointRef }),
385 !expireRecovery,
386 );
387 for (const index of privateIndexes) {
388 assert.isFalse(yield* fs.exists(index));
389 assert.isFalse(yield* fs.exists(`${index}.lock`));
390 }
391 assert.deepEqual(yield* fs.readFile(path.join(cwd, ".git", "index")), originalIndex);
392 }).pipe(Effect.scoped, Effect.provide(layerGitCaptureContract)),
393);
394
395it.effect.each(["discovery", "probe", "retry"] as const)(
396 "checkpoint recovery has one deadline including %s",
397 (blockedPhase) =>
398 Effect.gen(function* () {
399 const fs = yield* FileSystem.FileSystem;
400 const path = yield* Path.Path;
401 const liveProcess = yield* VcsProcess.VcsProcess;
402 const driver = yield* GitVcsDriver.makeVcsDriverShape();
403 const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-recovery-timeout-" });
404 const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd);
405 yield* git(["init", "empty"]);
406 const originalIndex = yield* fs.readFile(path.join(cwd, ".git", "index"));
407 const entered = yield* Deferred.make<void>();
408 const discovered = yield* Deferred.make<void>();
409 let stageError: VcsProcessExitError | undefined;
410 let privateIndex: string | undefined;
411 let interrupted = false;
412 let stagingAttempts = 0;
413 const captureDriver = yield* GitVcsDriver.makeVcsDriverShape().pipe(
414 Effect.provideService(VcsProcess.VcsProcess, {
415 run: (input) => {
416 const staging = input.args.includes("add") && input.args.includes("-A");
417 if (staging) {
418 privateIndex = input.env?.GIT_INDEX_FILE;
419 stagingAttempts += 1;
420 }
421 const block =
422 (blockedPhase === "discovery" && input.args.includes("--others")) ||
423 (blockedPhase === "probe" && input.cwd !== cwd && input.args.includes("rev-parse")) ||
424 (blockedPhase === "retry" &&
425 staging &&
426 input.args.some((arg) => arg.startsWith(":(exclude,literal)")));
427 if (block)
428 return (
429 blockedPhase === "retry"
430 ? fs
431 .writeFileString(
432 `${input.env!.GIT_INDEX_FILE!}.lock`,
433 "interrupted index write",
434 )
435 .pipe(Effect.orDie)
436 : Effect.void
437 ).pipe(
438 Effect.andThen(Deferred.succeed(entered, undefined)),
439 Effect.andThen(Effect.never),
440 Effect.onInterrupt(() =>
441 Effect.sync(() => {
442 interrupted = true;
443 }),
444 ),
445 );
446 return liveProcess.run(input).pipe(
447 Effect.tap(() =>
448 blockedPhase === "probe" && input.args.includes("--others")
449 ? Deferred.succeed(discovered, undefined).pipe(
450 Effect.andThen(Effect.sleep("3 seconds")),
451 )
452 : Effect.void,
453 ),
454 Effect.tapError((error) => {
455 if (staging && error._tag === "VcsProcessExitError") stageError = error;
456 return Effect.void;
457 }),
458 );
459 },
460 }),
461 );
462 const captureStore = yield* makeCaptureStore(captureDriver, cwd);
463 const fiber = yield* captureStore
464 .captureCheckpoint({ cwd, checkpointRef })
465 .pipe(Effect.flip, Effect.forkScoped);
466 if (blockedPhase === "probe") {
467 yield* Deferred.await(discovered);
468 yield* TestClock.adjust("3 seconds");
469 }
470 yield* Deferred.await(entered);
471 yield* TestClock.adjust(blockedPhase === "probe" ? "2 seconds" : "5 seconds");
472 const error = yield* Fiber.join(fiber);
473 assert.strictEqual(error, stageError);
474 assert.strictEqual(stagingAttempts, blockedPhase === "retry" ? 2 : 1);
475 assert.isTrue(interrupted);
476 assert.isDefined(privateIndex);
477 assert.isFalse(yield* fs.exists(privateIndex!));
478 assert.isFalse(yield* fs.exists(`${privateIndex!}.lock`));
479 assert.isFalse(yield* driver.checkpoints.hasCheckpointRef({ cwd, checkpointRef }));
480 assert.deepEqual(yield* fs.readFile(path.join(cwd, ".git", "index")), originalIndex);
481 }).pipe(Effect.scoped, Effect.provide(layerGitContract)),
482);
483
484it.effect("checkpoint recovery preserves interruption and removes the private index", () =>
485 Effect.gen(function* () {
486 const fs = yield* FileSystem.FileSystem;
487 const liveProcess = yield* VcsProcess.VcsProcess;
488 const driver = yield* GitVcsDriver.makeVcsDriverShape();
489 const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-recovery-interrupt-" });
490 const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd);
491 yield* git(["init", "empty"]);
492 const entered = yield* Deferred.make<void>();
493 let privateIndex: string | undefined;
494 const captureDriver = yield* GitVcsDriver.makeVcsDriverShape().pipe(
495 Effect.provideService(VcsProcess.VcsProcess, {
496 run: (input) => {
497 if (input.args.includes("add") && input.args.includes("-A"))
498 privateIndex = input.env?.GIT_INDEX_FILE;
499 return input.args.includes("--others")
500 ? Deferred.succeed(entered, undefined).pipe(Effect.andThen(Effect.never))
501 : liveProcess.run(input);
502 },
503 }),
504 );
505 const fiber = yield* captureDriver.checkpoints
506 .captureCheckpoint({ cwd, checkpointRef })
507 .pipe(Effect.forkScoped);
508 yield* Deferred.await(entered);
509 yield* Fiber.interrupt(fiber);
510 const exit = yield* Fiber.await(fiber);
511 assert.isTrue(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause));
512 assert.isDefined(privateIndex);
513 assert.isFalse(yield* fs.exists(privateIndex!));
514 assert.isFalse(yield* driver.checkpoints.hasCheckpointRef({ cwd, checkpointRef }));
515 }).pipe(Effect.scoped, Effect.provide(layerGitContract)),
516);
517
518it.effect("checkpoint capture does not rerun clean filters for unchanged indexed files", () =>
519 Effect.gen(function* () {
520 const fileSystem = yield* FileSystem.FileSystem;
521 const path = yield* Path.Path;
522 const driver = yield* GitVcsDriver.makeVcsDriverShape();
523 const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-checkpoint-cache-" });
524 const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd);
525 yield* fileSystem.writeFileString(
526 path.join(cwd, ".gitattributes"),
527 "stable.txt filter=probe\n",
528 );
529 yield* fileSystem.writeFileString(path.join(cwd, "stable.txt"), "unchanged\n");
530 yield* fileSystem.writeFileString(
531 path.join(cwd, ".git", "filter.cjs"),
532 'require("node:fs").appendFileSync(".git/filter-runs", "read\\n"); process.stdin.pipe(process.stdout);',
533 );
534 yield* git(["config", "filter.probe.clean", "node .git/filter.cjs"]);
535 yield* fileSystem.utimes(path.join(cwd, "stable.txt"), 1_700_000_000, 1_700_000_000);
536 yield* git(["add", "."]);
537 yield* git(["commit", "-m", "record stable file"]);
538 yield* fileSystem.writeFileString(path.join(cwd, ".git", "filter-runs"), "");
539 yield* fileSystem.writeFileString(path.join(cwd, "file.txt"), "changed\n");
540 const originalIndex = yield* fileSystem.readFile(path.join(cwd, ".git", "index"));
541
542 yield* driver.checkpoints.captureCheckpoint({ cwd, checkpointRef });
543
544 assert.strictEqual(yield* fileSystem.readFileString(path.join(cwd, ".git", "filter-runs")), "");
545 assert.strictEqual((yield* git(["show", `${checkpointRef}:file.txt`])).stdout, "changed\n");
546 assert.strictEqual((yield* git(["show", `${checkpointRef}:stable.txt`])).stdout, "unchanged\n");
547 assert.deepEqual(yield* fileSystem.readFile(path.join(cwd, ".git", "index")), originalIndex);
548 }).pipe(Effect.scoped, Effect.provide(layerGitContract)),
549);
550
551it.effect.each(
552 [false, true].flatMap((nested) =>
553 (["sparse", "flags", "manual-skip", "missing", "non-cone-missing"] as const).map(
554 (indexState) => ({ nested, indexState }),
555 ),
556 ),
557)(
558 "sparse checkpoint preserves two captures (nested=$nested, index=$indexState)",
559 ({ nested, indexState }) =>
560 Effect.gen(function* () {
561 const fs = yield* FileSystem.FileSystem;
562 const path = yield* Path.Path;
563 const driver = yield* GitVcsDriver.makeVcsDriverShape();
564 const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-sparse-" });
565 const { git } = yield* makeCheckpointFixture(driver, cwd);
566 const write = Effect.fn(function* (name: string, contents: string) {
567 yield* fs.makeDirectory(path.dirname(path.join(cwd, name)), { recursive: true });
568 yield* fs.writeFileString(path.join(cwd, name), contents);
569 });
570 for (const name of [
571 "scope/in/edit",
572 "scope/in/delete",
573 "scope/out/deep/absent",
574 "scope/out/present",
575 "elsewhere/file",
576 ]) {
577 yield* write(name, "original\n");
578 }
579 yield* git(["add", "."]);
580 yield* git(["commit", "-m", "sparse fixture"]);
581 yield* git(["sparse-checkout", "set", "--cone", "--sparse-index", "scope/in", "elsewhere"]);
582 if (indexState === "non-cone-missing")
583 yield* git(["sparse-checkout", "set", "--no-cone", "/scope/in/", "/elsewhere/"]);
584 yield* write("scope/in/edit", "staged\n");
585 yield* write("elsewhere/file", "staged outside\n");
586 yield* git(["add", "."]);
587 if (indexState === "flags")
588 yield* git(["update-index", "--assume-unchanged", "scope/in/delete"]);
589 if (indexState === "manual-skip")
590 yield* git(["update-index", "--skip-worktree", "scope/in/delete"]);
591 yield* git(["config", "sparse.expectFilesOutsideOfPatterns", "true"]);
592 yield* write("scope/in/edit", "working\n");
593 yield* write("scope/out/present", "modified skipped\n");
594 yield* write("scope/out/new file", "new outside cone\n");
595 yield* write("elsewhere/file", "working outside\n");
596 yield* fs.remove(path.join(cwd, "scope/in/delete"));
597 const indexPath = path.join(cwd, ".git/index");
598 if (indexState.endsWith("missing")) yield* fs.remove(indexPath);
599 const originalIndex = yield* fs.readFile(indexPath).pipe(Effect.orElseSucceed(() => null));
600 const captureCwd = nested ? path.join(cwd, "scope") : cwd;
601 for (const turn of [1, 2]) {
602 const ref = CheckpointRef.make(`refs/t3/checkpoints/sparse/${turn}`);
603 if (turn === 2) {
604 yield* write("scope/in/edit", "second\n");
605 yield* fs.remove(path.join(cwd, "scope/out/new file"));
606 yield* write("scope/out/second", "second addition\n");
607 }
608 const capture = driver.checkpoints.captureCheckpoint({
609 cwd: captureCwd,
610 checkpointRef: ref,
611 });
612 if (indexState === "non-cone-missing") {
613 assert.strictEqual((yield* capture.pipe(Effect.flip))._tag, "VcsProcessExitError");
614 assert.isFalse(
615 yield* driver.checkpoints.hasCheckpointRef({ cwd: captureCwd, checkpointRef: ref }),
616 );
617 assert.isFalse(yield* fs.exists(indexPath));
618 break;
619 }
620 yield* capture;
621 for (const [name, content] of [
622 ["scope/out/deep/absent", "original\n"],
623 ["scope/out/present", "modified skipped\n"],
624 ["scope/in/edit", turn === 1 ? "working\n" : "second\n"],
625 ["elsewhere/file", nested ? "original\n" : "working outside\n"],
626 [
627 turn === 1 ? "scope/out/new file" : "scope/out/second",
628 turn === 1 ? "new outside cone\n" : "second addition\n",
629 ],
630 ]) {
631 assert.strictEqual((yield* git(["show", `${ref}:${name}`])).stdout, content);
632 }
633 const files = (yield* git(["ls-tree", "-rz", "--name-only", ref])).stdout.split("\0");
634 assert.notInclude(files, "scope/in/delete");
635 if (turn === 2) assert.notInclude(files, "scope/out/new file");
636 assert.deepEqual(
637 yield* fs.readFile(indexPath).pipe(Effect.orElseSucceed(() => null)),
638 originalIndex,
639 );
640 assert.isFalse(yield* fs.exists(path.join(cwd, "scope/out/deep/absent")));
641 assert.strictEqual(
642 yield* fs.readFileString(path.join(cwd, "elsewhere/file")),
643 "working outside\n",
644 );
645 }
646 }).pipe(Effect.scoped, Effect.provide(layerGitContract)),
647);
648
649it.effect("checkpoint capture keeps the legacy path when Git lacks add --sparse", () =>
650 Effect.gen(function* () {
651 const fs = yield* FileSystem.FileSystem;
652 const path = yield* Path.Path;
653 const liveProcess = yield* VcsProcess.VcsProcess;
654 const driver = yield* GitVcsDriver.makeVcsDriverShape();
655 const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-legacy-" });
656 const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd);
657 yield* git(["sparse-checkout", "set", "--cone", "included"]);
658 const originalIndex = yield* fs.readFile(path.join(cwd, ".git/index"));
659 const captureDriver = yield* GitVcsDriver.makeVcsDriverShape().pipe(
660 Effect.provideService(VcsProcess.VcsProcess, {
661 run: (input) => {
662 if (input.args.includes("-h"))
663 return Effect.succeed({
664 exitCode: ChildProcessSpawner.ExitCode(129),
665 stdout: "usage: git add",
666 stderr: "",
667 stdoutTruncated: false,
668 stderrTruncated: false,
669 });
670 return liveProcess.run(
671 input.args.includes("--sparse")
672 ? {
673 ...input,
674 args: input.args.map((arg) =>
675 arg === "--sparse" ? "--unsupported-sparse" : arg,
676 ),
677 }
678 : input,
679 );
680 },
681 }),
682 );
683 yield* captureDriver.checkpoints.captureCheckpoint({ cwd, checkpointRef });
684 assert.strictEqual((yield* git(["show", `${checkpointRef}:file.txt`])).stdout, "unstaged\n");
685 assert.deepEqual(yield* fs.readFile(path.join(cwd, ".git/index")), originalIndex);
686 }).pipe(Effect.scoped, Effect.provide(layerGitContract)),
687);
688
689it.effect.each(["normal", "flags", "sparse"] as const)(
690 "checkpoint index inspection handles entries beyond the output cap (index=%s)",
691 (indexMode) =>
692 Effect.gen(function* () {
693 const fs = yield* FileSystem.FileSystem;
694 const path = yield* Path.Path;
695 const liveProcess = yield* VcsProcess.VcsProcess;
696 const driver = yield* GitVcsDriver.makeVcsDriverShape();
697 const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-inspection-" });
698 const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd);
699 yield* fs.writeFileString(path.join(cwd, ".gitattributes"), "stable filter=probe\n");
700 yield* fs.writeFileString(path.join(cwd, "stable"), "unchanged\n");
701 yield* fs.writeFileString(path.join(cwd, "z-skipped"), "original\n");
702 yield* fs.makeDirectory(path.join(cwd, "excluded"));
703 yield* fs.writeFileString(path.join(cwd, "excluded/file"), "absent\n");
704 yield* fs.writeFileString(
705 path.join(cwd, ".git/filter.cjs"),
706 'require("node:fs").appendFileSync(".git/reads", "read\\n"); process.stdin.pipe(process.stdout);',
707 );
708 yield* git(["config", "filter.probe.clean", "node .git/filter.cjs"]);
709 yield* fs.utimes(path.join(cwd, "stable"), 1_700_000_000, 1_700_000_000);
710 yield* git(["add", "."]);
711 yield* git(["commit", "-m", "inspection fixture"]);
712 if (indexMode === "flags") yield* git(["update-index", "--skip-worktree", "z-skipped"]);
713 if (indexMode === "sparse")
714 yield* git(["sparse-checkout", "set", "--cone", "--sparse-index", "included"]);
715 yield* fs.writeFileString(path.join(cwd, "z-skipped"), "modified\n");
716 yield* fs.writeFileString(path.join(cwd, ".git/reads"), "");
717 const originalIndex = yield* fs.readFile(path.join(cwd, ".git/index"));
718 const captureDriver = yield* GitVcsDriver.makeVcsDriverShape().pipe(
719 Effect.provideService(VcsProcess.VcsProcess, {
720 run: (input) =>
721 liveProcess.run(
722 input.args.includes("ls-files")
723 ? {
724 ...input,
725 maxOutputBytes: 8,
726 onStdoutChunk: (chunk) => {
727 for (let i = 0; i < chunk.length; i++)
728 input.onStdoutChunk?.(chunk.subarray(i, i + 1));
729 },
730 }
731 : input,
732 ),
733 }),
734 );
735 yield* captureDriver.checkpoints.captureCheckpoint({ cwd, checkpointRef });
736 assert.strictEqual((yield* git(["show", `${checkpointRef}:z-skipped`])).stdout, "modified\n");
737 if (indexMode !== "flags")
738 assert.strictEqual(yield* fs.readFileString(path.join(cwd, ".git/reads")), "");
739 assert.deepEqual(yield* fs.readFile(path.join(cwd, ".git/index")), originalIndex);
740 }).pipe(Effect.scoped, Effect.provide(layerGitContract)),
741);
742
743it.effect.each([1_700_000_000, 1_700_000_000.9999])(
744 "checkpoint capture preserves same-size edits with racy index timestamps (%s)",
745 (timestamp) =>
746 Effect.gen(function* () {
747 const fileSystem = yield* FileSystem.FileSystem;
748 const path = yield* Path.Path;
749 const driver = yield* GitVcsDriver.makeVcsDriverShape();
750 const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-checkpoint-racy-" });
751 const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd);
752 const filePath = path.join(cwd, "file.txt");
753 const indexPath = path.join(cwd, ".git", "index");
754 yield* git(["config", "core.trustctime", "false"]);
755 yield* fileSystem.writeFileString(filePath, "before\n");
756 yield* fileSystem.utimes(filePath, timestamp, timestamp);
757 yield* git(["add", "file.txt"]);
758 yield* git(["commit", "-m", "record racy file"]);
759 yield* fileSystem.utimes(indexPath, timestamp, timestamp);
760 const originalIndex = yield* fileSystem.readFile(indexPath);
761 const originalIndexMtime = (yield* fileSystem.stat(indexPath)).mtime;
762 yield* fileSystem.writeFileString(filePath, "after!\n");
763 yield* fileSystem.utimes(filePath, timestamp, timestamp);
764
765 yield* driver.checkpoints.captureCheckpoint({ cwd, checkpointRef });
766
767 assert.strictEqual((yield* git(["show", `${checkpointRef}:file.txt`])).stdout, "after!\n");
768 assert.deepEqual(yield* fileSystem.readFile(indexPath), originalIndex);
769 assert.deepEqual((yield* fileSystem.stat(indexPath)).mtime, originalIndexMtime);
770 }).pipe(Effect.scoped, Effect.provide(layerGitContract)),
771);
772it.effect.each(
773 [false, true].flatMap((nested) =>
774 (["sparse", "flags", "manual-skip", "missing", "non-cone-missing"] as const).map(
775 (indexState) => ({ nested, indexState }),
776 ),
777 ),
778)(
779 "sparse checkpoint preserves two captures (nested=$nested, index=$indexState)",
780 ({ nested, indexState }) =>
781 Effect.gen(function* () {
782 const fs = yield* FileSystem.FileSystem;
783 const path = yield* Path.Path;
784 const driver = yield* GitVcsDriver.makeVcsDriverShape();
785 const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-sparse-" });
786 const { git } = yield* makeCheckpointFixture(driver, cwd);
787 const write = Effect.fn(function* (name: string, contents: string) {
788 yield* fs.makeDirectory(path.dirname(path.join(cwd, name)), { recursive: true });
789 yield* fs.writeFileString(path.join(cwd, name), contents);
790 });
791 for (const name of [
792 "scope/in/edit",
793 "scope/in/delete",
794 "scope/out/deep/absent",
795 "scope/out/present",
796 "elsewhere/file",
797 ]) {
798 yield* write(name, "original\n");
799 }
800 yield* git(["add", "."]);
801 yield* git(["commit", "-m", "sparse fixture"]);
802 yield* git(["sparse-checkout", "set", "--cone", "--sparse-index", "scope/in", "elsewhere"]);
803 if (indexState === "non-cone-missing")
804 yield* git(["sparse-checkout", "set", "--no-cone", "/scope/in/", "/elsewhere/"]);
805 yield* write("scope/in/edit", "staged\n");
806 yield* write("elsewhere/file", "staged outside\n");
807 yield* git(["add", "."]);
808 if (indexState === "flags")
809 yield* git(["update-index", "--assume-unchanged", "scope/in/delete"]);
810 if (indexState === "manual-skip")
811 yield* git(["update-index", "--skip-worktree", "scope/in/delete"]);
812 yield* git(["config", "sparse.expectFilesOutsideOfPatterns", "true"]);
813 yield* write("scope/in/edit", "working\n");
814 yield* write("scope/out/present", "modified skipped\n");
815 yield* write("scope/out/new file", "new outside cone\n");
816 yield* write("elsewhere/file", "working outside\n");
817 yield* fs.remove(path.join(cwd, "scope/in/delete"));
818 const indexPath = path.join(cwd, ".git/index");
819 if (indexState.endsWith("missing")) yield* fs.remove(indexPath);
820 const originalIndex = yield* fs.readFile(indexPath).pipe(Effect.orElseSucceed(() => null));
821 const captureCwd = nested ? path.join(cwd, "scope") : cwd;
822 for (const turn of [1, 2]) {
823 const ref = CheckpointRef.make(`refs/t3/checkpoints/sparse/${turn}`);
824 if (turn === 2) {
825 yield* write("scope/in/edit", "second\n");
826 yield* fs.remove(path.join(cwd, "scope/out/new file"));
827 yield* write("scope/out/second", "second addition\n");
828 }
829 const capture = driver.checkpoints.captureCheckpoint({
830 cwd: captureCwd,
831 checkpointRef: ref,
832 });
833 if (indexState === "non-cone-missing") {
834 assert.strictEqual((yield* capture.pipe(Effect.flip))._tag, "VcsProcessExitError");
835 assert.isFalse(
836 yield* driver.checkpoints.hasCheckpointRef({ cwd: captureCwd, checkpointRef: ref }),
837 );
838 assert.isFalse(yield* fs.exists(indexPath));
839 break;
840 }
841 yield* capture;
842 for (const [name, content] of [
843 ["scope/out/deep/absent", "original\n"],
844 ["scope/out/present", "modified skipped\n"],
845 ["scope/in/edit", turn === 1 ? "working\n" : "second\n"],
846 ["elsewhere/file", nested ? "original\n" : "working outside\n"],
847 [
848 turn === 1 ? "scope/out/new file" : "scope/out/second",
849 turn === 1 ? "new outside cone\n" : "second addition\n",
850 ],
851 ]) {
852 assert.strictEqual((yield* git(["show", `${ref}:${name}`])).stdout, content);
853 }
854 const files = (yield* git(["ls-tree", "-rz", "--name-only", ref])).stdout.split("\0");
855 assert.notInclude(files, "scope/in/delete");
856 if (turn === 2) assert.notInclude(files, "scope/out/new file");
857 assert.deepEqual(
858 yield* fs.readFile(indexPath).pipe(Effect.orElseSucceed(() => null)),
859 originalIndex,
860 );
861 assert.isFalse(yield* fs.exists(path.join(cwd, "scope/out/deep/absent")));
862 assert.strictEqual(
863 yield* fs.readFileString(path.join(cwd, "elsewhere/file")),
864 "working outside\n",
865 );
866 }
867 }).pipe(Effect.scoped, Effect.provide(layerGitContract)),
868);
869
870it.effect("checkpoint capture preserves racy edits made after resetting the index", () =>
871 Effect.gen(function* () {
872 const fileSystem = yield* FileSystem.FileSystem;
873 const path = yield* Path.Path;
874 const liveProcess = yield* VcsProcess.VcsProcess;
875 const driver = yield* GitVcsDriver.makeVcsDriverShape();
876 const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-checkpoint-racy-reset-" });
877 const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd);
878 const racyPath = path.join(cwd, "racy.txt");
879 const indexPath = path.join(cwd, ".git", "index");
880 const timestamp = 1_700_000_000;
881 yield* git(["config", "core.trustctime", "false"]);
882 yield* fileSystem.writeFileString(racyPath, "before\n");
883 yield* fileSystem.utimes(racyPath, timestamp, timestamp);
884 yield* git(["add", "."]);
885 yield* git(["commit", "-m", "record racy file"]);
886 yield* fileSystem.writeFileString(path.join(cwd, "file.txt"), "staged\n");
887 yield* git(["add", "file.txt"]);
888 yield* fileSystem.utimes(indexPath, timestamp, timestamp);
889 const originalIndex = yield* fileSystem.readFile(indexPath);
890 const originalIndexMtime = (yield* fileSystem.stat(indexPath)).mtime;
891 const captureDriver = yield* GitVcsDriver.makeVcsDriverShape().pipe(
892 Effect.provideService(VcsProcess.VcsProcess, {
893 run: Effect.fn(function* (input: VcsProcess.VcsProcessInput) {
894 const result = yield* liveProcess.run(input);
895 if (input.args.includes("read-tree") && input.args.includes("--reset")) {
896 yield* fileSystem.writeFileString(racyPath, "after!\n").pipe(Effect.orDie);
897 yield* fileSystem.utimes(racyPath, timestamp, timestamp).pipe(Effect.orDie);
898 }
899 return result;
900 }),
901 }),
902 );
903
904 yield* captureDriver.checkpoints.captureCheckpoint({ cwd, checkpointRef });
905
906 assert.strictEqual((yield* git(["show", `${checkpointRef}:racy.txt`])).stdout, "after!\n");
907 assert.strictEqual((yield* git(["show", `${checkpointRef}:file.txt`])).stdout, "staged\n");
908 assert.deepEqual(yield* fileSystem.readFile(indexPath), originalIndex);
909 assert.deepEqual((yield* fileSystem.stat(indexPath)).mtime, originalIndexMtime);
910 }).pipe(Effect.scoped, Effect.provide(layerGitContract)),
911);
912
913it.effect.each(
914 [false, true].flatMap((nested) =>
915 (["normal", "flags", "split"] as const).map((indexMode) => ({ nested, indexMode })),
916 ),
917)(
918 "checkpoint index reuse preserves two turns (nested=$nested, index=$indexMode)",
919 ({ nested, indexMode }) =>
920 Effect.gen(function* () {
921 const fileSystem = yield* FileSystem.FileSystem;
922 const path = yield* Path.Path;
923 const driver = yield* GitVcsDriver.makeVcsDriverShape();
924 const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-checkpoint-turns-" });
925 const { git } = yield* makeCheckpointFixture(driver, cwd);
926 const write = (name: string, contents: string) =>
927 fileSystem.writeFileString(path.join(cwd, name), contents);
928 yield* fileSystem.makeDirectory(path.join(cwd, "scope"));
929 for (const name of [
930 "scope/staged",
931 "scope/deleted",
932 "scope/assumed",
933 "scope/skipped",
934 "outside",
935 ]) {
936 yield* write(name, "original\n");
937 }
938 yield* git(["add", "."]);
939 yield* git(["commit", "-m", "initial scoped files"]);
940 yield* write("scope/staged", "staged\n");
941 yield* write("scope/new-deleted", "staged then deleted\n");
942 yield* write("outside", "staged outside\n");
943 yield* git(["add", "."]);
944 if (indexMode === "flags") {
945 yield* git(["update-index", "--assume-unchanged", "scope/assumed"]);
946 yield* git(["update-index", "--skip-worktree", "scope/skipped"]);
947 }
948 if (indexMode === "split") {
949 yield* git(["update-index", "--split-index"]);
950 }
951 const originalIndex = yield* fileSystem.readFile(path.join(cwd, ".git", "index"));
952 for (const name of ["scope/staged", "scope/assumed", "scope/skipped", "outside"]) {
953 yield* write(name, "working\n");
954 }
955 yield* write("scope/new", "first\n");
956 yield* fileSystem.remove(path.join(cwd, "scope/deleted"));
957 yield* fileSystem.remove(path.join(cwd, "scope/new-deleted"));
958 const captureCwd = nested ? path.join(cwd, "scope") : cwd;
959 const first = CheckpointRef.make("refs/t3/checkpoints/turns/1");
960 const second = CheckpointRef.make("refs/t3/checkpoints/turns/2");
961 yield* driver.checkpoints.captureCheckpoint({ cwd: captureCwd, checkpointRef: first });
962 for (const name of ["scope/staged", "scope/assumed", "scope/skipped"]) {
963 assert.strictEqual((yield* git(["show", `${first}:${name}`])).stdout, "working\n");
964 }
965 assert.strictEqual(
966 (yield* git(["show", `${first}:outside`])).stdout,
967 nested ? "original\n" : "working\n",
968 );
969 const files = (yield* git(["ls-tree", "-r", "--name-only", first])).stdout.split("\n");
970 assert.notInclude(files, "scope/deleted");
971 assert.notInclude(files, "scope/new-deleted");
972 assert.include(files, "scope/new");
973
974 yield* write("scope/staged", "second\n");
975 yield* fileSystem.remove(path.join(cwd, "scope/new"));
976 yield* write("scope/second", "added in second turn\n");
977 yield* driver.checkpoints.captureCheckpoint({ cwd: captureCwd, checkpointRef: second });
978 assert.strictEqual(
979 (yield* git(["diff", "--name-only", first, second])).stdout,
980 "scope/new\nscope/second\nscope/staged\n",
981 );
982 assert.strictEqual((yield* git(["show", `${second}:scope/staged`])).stdout, "second\n");
983 assert.strictEqual(
984 (yield* git(["show", `${second}:scope/second`])).stdout,
985 "added in second turn\n",
986 );
987 assert.deepEqual(yield* fileSystem.readFile(path.join(cwd, ".git", "index")), originalIndex);
988 }).pipe(Effect.scoped, Effect.provide(layerGitContract)),
989);
990
991it.effect.each(["missing", "invalid"] as const)(
992 "checkpoint capture falls back when the user index is %s",
993 (indexState) =>
994 Effect.gen(function* () {
995 const fileSystem = yield* FileSystem.FileSystem;
996 const path = yield* Path.Path;
997 const driver = yield* GitVcsDriver.makeVcsDriverShape();
998 const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-checkpoint-index-" });
999 const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd);
1000 const indexPath = path.join(cwd, ".git", "index");
1001 if (indexState === "missing") {
1002 yield* fileSystem.remove(indexPath);
1003 } else {
1004 yield* fileSystem.writeFileString(indexPath, "invalid index");
1005 }
1006
1007 yield* driver.checkpoints.captureCheckpoint({ cwd, checkpointRef });
1008
1009 assert.strictEqual((yield* git(["show", `${checkpointRef}:file.txt`])).stdout, "unstaged\n");
1010 if (indexState === "missing") {
1011 assert.isFalse(yield* fileSystem.exists(indexPath));
1012 } else {
1013 assert.strictEqual(yield* fileSystem.readFileString(indexPath), "invalid index");
1014 }
1015 }).pipe(Effect.scoped, Effect.provide(layerGitContract)),
1016);
1017
1018it.effect("restores empty checkpoints without changing paths outside the workspace", () =>
1019 Effect.gen(function* () {
1020 const fileSystem = yield* FileSystem.FileSystem;
1021 const path = yield* Path.Path;
1022 const driver = yield* GitVcsDriver.makeVcsDriverShape();
1023 for (const nested of [false, true]) {
1024 const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-empty-checkpoint-" });
1025 yield* runGit(root, ["init"]);
1026 yield* runGit(root, ["config", "user.email", "test@test.com"]);
1027 yield* runGit(root, ["config", "user.name", "Test"]);
1028 if (nested) {
1029 yield* fileSystem.writeFileString(path.join(root, "outside.txt"), "original\n");
1030 yield* runGit(root, ["add", "."]);
1031 }
1032 yield* runGit(root, ["commit", "--allow-empty", "-m", "initial"]);
1033 const cwd = nested ? path.join(root, "nested") : root;
1034 yield* fileSystem.makeDirectory(cwd, { recursive: true });
1035 const checkpointRef = CheckpointRef.make("refs/t3/checkpoints/empty");
1036 yield* driver.checkpoints.captureCheckpoint({ cwd, checkpointRef });
1037 if (nested) {
1038 yield* fileSystem.writeFileString(path.join(root, "outside.txt"), "changed\n");
1039 yield* runGit(root, ["add", "outside.txt"]);
1040 }
1041 for (const staged of [false, true]) {
1042 const addedPath = path.join(cwd, "added.txt");
1043 yield* fileSystem.writeFileString(addedPath, "new\n");
1044 if (staged) yield* runGit(cwd, ["add", "added.txt"]);
1045 assert.isTrue(
1046 yield* driver.checkpoints.restoreCheckpoint({
1047 cwd,
1048 checkpointRef,
1049 fallbackToHead: false,
1050 }),
1051 );
1052 assert.isTrue(yield* fileSystem.exists(cwd));
1053 assert.isFalse(yield* fileSystem.exists(addedPath));
1054 }
1055 yield* fileSystem.writeFileString(
1056 path.join(root, ".git", "info", "exclude"),
1057 "ignored.txt\n",
1058 );
1059 yield* fileSystem.writeFileString(path.join(cwd, "ignored.txt"), "keep\n");
1060 yield* fileSystem.makeDirectory(path.join(cwd, "untracked"));
1061 yield* fileSystem.writeFileString(path.join(cwd, "untracked", "file.txt"), "remove\n");
1062 assert.isTrue(
1063 yield* driver.checkpoints.restoreCheckpoint({ cwd, checkpointRef, fallbackToHead: false }),
1064 );
1065 assert.strictEqual(yield* fileSystem.readFileString(path.join(cwd, "ignored.txt")), "keep\n");
1066 assert.isFalse(yield* fileSystem.exists(path.join(cwd, "untracked")));
1067 if (nested) {
1068 assert.strictEqual(
1069 yield* fileSystem.readFileString(path.join(root, "outside.txt")),
1070 "changed\n",
1071 );
1072 const staged = yield* driver.execute({
1073 operation: "test",
1074 cwd: root,
1075 args: ["diff", "--cached", "--name-only"],
1076 });
1077 assert.strictEqual(staged.stdout.trim(), "outside.txt");
1078 }
1079 }
1080 }).pipe(Effect.scoped, Effect.provide(layerGitContract)),
1081);
1082
1083it.effect("GitVcsDriver forwards execute env to the VCS process", () => {
1084 let observedEnv: NodeJS.ProcessEnv | undefined;
1085 let observedAppendTruncationMarker: boolean | undefined;
1086 let observedOutputMode: VcsProcess.VcsProcessInput["outputMode"];
1087
1088 return Effect.gen(function* () {
1089 const driver = yield* GitVcsDriver.makeVcsDriverShape();
1090
1091 yield* driver.execute({
1092 operation: "GitVcsDriver.test.env",
1093 cwd: "/repo",
1094 args: ["status"],
1095 env: {
1096 GIT_INDEX_FILE: "/tmp/t3-index",
1097 },
1098 appendTruncationMarker: true,
1099 outputMode: "error",
1100 });
1101
1102 assert.deepStrictEqual(observedEnv, {
1103 GIT_INDEX_FILE: "/tmp/t3-index",
1104 });
1105 assert.strictEqual(observedAppendTruncationMarker, true);
1106 assert.strictEqual(observedOutputMode, "error");
1107 }).pipe(
1108 Effect.provide(
1109 Layer.mergeAll(
1110 NodeServices.layer,
1111 Layer.mock(VcsProcess.VcsProcess)({
1112 run: (input) =>
1113 Effect.sync(() => {
1114 observedEnv = input.env;
1115 observedAppendTruncationMarker = input.appendTruncationMarker;
1116 observedOutputMode = input.outputMode;
1117 return {
1118 exitCode: ChildProcessSpawner.ExitCode(0),
1119 stdout: "",
1120 stderr: "",
1121 stdoutTruncated: false,
1122 stderrTruncated: false,
1123 };
1124 }),
1125 }),
1126 ),
1127 ),
1128 );
1129});
1130
1131it.effect("GitVcsDriver flushes checkpoint objects and refs to disk before publishing them", () => {
1132 const observedArgs: ReadonlyArray<string>[] = [];
1133
1134 return Effect.gen(function* () {
1135 const driver = yield* GitVcsDriver.makeVcsDriverShape();
1136
1137 yield* driver.checkpoints.captureCheckpoint({
1138 cwd: "/repo",
1139 checkpointRef: CheckpointRef.make("refs/t3/checkpoints/thread/turn/1"),
1140 });
1141
1142 const writeCommands = ["add", "write-tree", "commit-tree", "update-ref"];
1143 const writes = observedArgs.filter((args) =>
1144 writeCommands.some((command) => args.includes(command)),
1145 );
1146 assert.strictEqual(writes.length, 4);
1147 for (const args of writes) {
1148 const command = args.findIndex((arg) => writeCommands.includes(arg));
1149 for (const setting of ["core.fsync=objects,reference", "core.fsyncMethod=fsync"]) {
1150 const index = args.indexOf(setting);
1151 assert.strictEqual(args[index - 1], "-c", args.join(" "));
1152 assert.isBelow(index, command);
1153 }
1154 }
1155 assert.deepStrictEqual(observedArgs.at(-1), [
1156 "-C",
1157 "/repo",
1158 "-c",
1159 "core.fsync=objects,reference",
1160 "-c",
1161 "core.fsyncMethod=fsync",
1162 "update-ref",
1163 "refs/t3/checkpoints/thread/turn/1",
1164 "commit0000",
1165 ]);
1166 }).pipe(
1167 Effect.provide(
1168 Layer.mergeAll(
1169 NodeServices.layer,
1170 Layer.mock(VcsProcess.VcsProcess)({
1171 run: (input) =>
1172 Effect.sync(() => {
1173 observedArgs.push(input.args);
1174 const stdout = input.args.includes("write-tree")
1175 ? "tree0000\n"
1176 : input.args.includes("commit-tree")
1177 ? "commit0000\n"
1178 : input.args.includes("--git-common-dir")
1179 ? ".git\n"
1180 : "";
1181 return {
1182 exitCode: ChildProcessSpawner.ExitCode(0),
1183 stdout,
1184 stderr: "",
1185 stdoutTruncated: false,
1186 stderrTruncated: false,
1187 };
1188 }),
1189 }),
1190 ),
1191 ),
1192 );
1193});