1// @effect-diagnostics nodeBuiltinImport:off - realpathSync.native resolves Windows 8.3 short names, which the Effect realPath does not.
2import * as NodeFS from "node:fs";
3import * as NodeServices from "@effect/platform-node/NodeServices";
4import * as HostProcess from "@t3tools/shared/HostProcess";
5import { assert, it, describe } from "@effect/vitest";
6import * as Deferred from "effect/Deferred";
7import * as Effect from "effect/Effect";
8import * as FileSystem from "effect/FileSystem";
9import * as Fiber from "effect/Fiber";
10import * as Layer from "effect/Layer";
11import * as Logger from "effect/Logger";
12import * as Metric from "effect/Metric";
13import * as Path from "effect/Path";
14import * as PlatformError from "effect/PlatformError";
15import * as Queue from "effect/Queue";
16import * as Ref from "effect/Ref";
17import * as Result from "effect/Result";
18import * as Scope from "effect/Scope";
19import * as Schema from "effect/Schema";
20import * as Sink from "effect/Sink";
21import * as Stream from "effect/Stream";
22import * as TestClock from "effect/testing/TestClock";
23import { ChildProcess, ChildProcessSpawner } from "effect/process";
24
25import {
26 GitCommandError,
27 ReviewDiffPreviewInput,
28 type ReviewDiffFileContentsInput,
29 type ReviewDiffPreviewResult,
30 type WorktreeSubmodules,
31} from "@t3tools/contracts";
32import * as ServerConfig from "../config.ts";
33import { gitCommandDuration } from "../observability/Metrics.ts";
34import {
35 makeGitVcsDriverCore,
36 parseGitCheckoutProgressLine,
37 splitNullSeparatedGitStdoutPaths,
38 windowsLongPathConfigEnv,
39} from "./GitVcsDriverCore.ts";
40import * as GitVcsDriver from "./GitVcsDriver.ts";
41
42const encodeGitCommandError = Schema.encodeEffect(Schema.fromJsonString(GitCommandError));
43
44const layerServerConfig = ServerConfig.layerTest(process.cwd(), {
45 prefix: "t3-git-vcs-driver-test-",
46});
47const layerTest = GitVcsDriver.layer.pipe(
48 Layer.provide(layerServerConfig),
49 Layer.provideMerge(NodeServices.layer),
50);
51
52const makeNonRepositoryHandle = () =>
53 ChildProcessSpawner.makeHandle({
54 pid: ChildProcessSpawner.ProcessId(1),
55 exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(128)),
56 isRunning: Effect.succeed(false),
57 kill: () => Effect.void,
58 unref: Effect.succeed(Effect.void),
59 stdin: Sink.drain,
60 stdout: Stream.empty,
61 stderr: Stream.encodeText(Stream.make("fatal: not a git repository")),
62 all: Stream.empty,
63 getInputFd: () => Sink.drain,
64 getOutputFd: () => Stream.empty,
65 });
66
67const makeSuccessfulHandle = (stdout: string) =>
68 ChildProcessSpawner.makeHandle({
69 pid: ChildProcessSpawner.ProcessId(1),
70 exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)),
71 isRunning: Effect.succeed(false),
72 kill: () => Effect.void,
73 unref: Effect.succeed(Effect.void),
74 stdin: Sink.drain,
75 stdout: Stream.encodeText(Stream.make(stdout)),
76 stderr: Stream.empty,
77 all: Stream.empty,
78 getInputFd: () => Sink.drain,
79 getOutputFd: () => Stream.empty,
80 });
81
82const makeTmpDir = (
83 prefix = "git-vcs-driver-test-",
84): Effect.Effect<string, PlatformError.PlatformError, FileSystem.FileSystem | Scope.Scope> =>
85 Effect.gen(function* () {
86 const fileSystem = yield* FileSystem.FileSystem;
87 return yield* fileSystem.makeTempDirectoryScoped({ prefix });
88 });
89
90const writeTextFile = (
91 cwd: string,
92 relativePath: string,
93 contents: string,
94): Effect.Effect<void, PlatformError.PlatformError, FileSystem.FileSystem | Path.Path> =>
95 Effect.gen(function* () {
96 const fileSystem = yield* FileSystem.FileSystem;
97 const pathService = yield* Path.Path;
98 const filePath = pathService.join(cwd, relativePath);
99 yield* fileSystem.makeDirectory(pathService.dirname(filePath), { recursive: true });
100 yield* fileSystem.writeFileString(filePath, contents);
101 });
102
103const makeReviewDiffFileContentsInput = (
104 cwd: string,
105 overrides: Partial<Omit<ReviewDiffFileContentsInput, "cwd">> = {},
106): ReviewDiffFileContentsInput => ({
107 cwd,
108 sourceKind: "working-tree",
109 changeType: "change",
110 baseRef: "HEAD",
111 headRef: null,
112 oldPath: "README.md",
113 newPath: "README.md",
114 ...overrides,
115});
116
117const git = (
118 cwd: string,
119 args: ReadonlyArray<string>,
120 env?: NodeJS.ProcessEnv,
121): Effect.Effect<string, GitCommandError, GitVcsDriver.GitVcsDriver> =>
122 Effect.gen(function* () {
123 const driver = yield* GitVcsDriver.GitVcsDriver;
124 const result = yield* driver.execute({
125 operation: "GitVcsDriver.test.git",
126 cwd,
127 args,
128 ...(env ? { env } : {}),
129 timeoutMs: 10_000,
130 });
131 return result.stdout.trim();
132 });
133
134const initRepoWithCommit = (
135 cwd: string,
136): Effect.Effect<
137 { readonly initialBranch: string },
138 GitCommandError | PlatformError.PlatformError,
139 GitVcsDriver.GitVcsDriver | FileSystem.FileSystem | Path.Path
140> =>
141 Effect.gen(function* () {
142 const driver = yield* GitVcsDriver.GitVcsDriver;
143 yield* driver.initRepo({ cwd });
144 yield* git(cwd, ["config", "user.email", "test@test.com"]);
145 yield* git(cwd, ["config", "user.name", "Test"]);
146 yield* writeTextFile(cwd, "README.md", "# test\n");
147 yield* git(cwd, ["add", "."]);
148 yield* git(cwd, ["commit", "-m", "initial commit"]);
149 const initialBranch = yield* git(cwd, ["branch", "--show-current"]);
150 return { initialBranch };
151 });
152
153it.effect("bounds Git bursts across drivers without timing out queued commands", () =>
154 Effect.gen(function* () {
155 const gate = yield* Deferred.make<void>();
156 const starts = yield* Queue.unbounded<number>();
157 let active = 0;
158 let peak = 0;
159 const spawner = ChildProcessSpawner.make(() =>
160 Effect.acquireRelease(
161 Effect.gen(function* () {
162 peak = Math.max(peak, ++active);
163 yield* Queue.offer(starts, active);
164 return ChildProcessSpawner.makeHandle({
165 ...makeSuccessfulHandle("ok"),
166 exitCode: Deferred.await(gate).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))),
167 });
168 }),
169 () => Effect.sync(() => active--),
170 ),
171 );
172 const drivers = yield* Effect.all(
173 Array.from({ length: 16 }, () =>
174 makeGitVcsDriverCore().pipe(
175 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
176 ),
177 ),
178 );
179 const burst = yield* Effect.forEach(
180 drivers,
181 (driver, index) =>
182 driver.execute({
183 operation: "test.gitBurst",
184 cwd: "/repo",
185 args: ["rev-parse", "HEAD"],
186 ...(index < 4 ? {} : { timeoutMs: index < 8 ? 30_000 : 1_000 }),
187 }),
188 { concurrency: "unbounded" },
189 ).pipe(Effect.forkChild);
190
191 yield* TestClock.adjust("2 seconds");
192 assert.equal(yield* Queue.size(starts), 8);
193 assert.equal(peak, 8);
194 yield* Deferred.succeed(gate, undefined);
195 const results = yield* Fiber.join(burst);
196 assert.equal(results.length, 16);
197 assert.isTrue(results.every((result) => result.stdout === "ok" && result.exitCode === 0));
198 assert.equal(peak, 8);
199 assert.equal(active, 0);
200 const duration = yield* Metric.value(
201 Metric.withAttributes(gitCommandDuration, [["operation", "test.gitBurst"]]),
202 );
203 assert.equal(duration.count, 16);
204 assert.equal(duration.sum, 16_000);
205 }).pipe(Effect.provide(layerServerConfig.pipe(Layer.provideMerge(NodeServices.layer)))),
206);
207
208it.effect.each([{ timeoutMs: null }, { timeoutMs: 30_001 }])(
209 "keeps all Git slots available with a pending command whose timeout is $timeoutMs",
210 ({ timeoutMs }) =>
211 Effect.gen(function* () {
212 const slowGate = yield* Deferred.make<void>();
213 const fastGate = yield* Deferred.make<void>();
214 const starts = yield* Queue.unbounded<void>();
215 let active = 0;
216 const spawner = ChildProcessSpawner.make((command) =>
217 Effect.acquireRelease(
218 Effect.gen(function* () {
219 active++;
220 yield* Queue.offer(starts, undefined);
221 const gate =
222 ChildProcess.isStandardCommand(command) && command.args[0] === "push"
223 ? slowGate
224 : fastGate;
225 return ChildProcessSpawner.makeHandle({
226 ...makeSuccessfulHandle("ok"),
227 exitCode: Deferred.await(gate).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))),
228 });
229 }),
230 () => Effect.sync(() => active--),
231 ),
232 );
233 const driver = yield* makeGitVcsDriverCore().pipe(
234 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
235 );
236 const slow = yield* driver
237 .execute({ operation: "test.slowGit", cwd: "/repo", args: ["push"], timeoutMs })
238 .pipe(Effect.forkChild);
239 yield* Queue.take(starts);
240 const burst = yield* Effect.all(
241 Array.from({ length: 8 }, () =>
242 driver.execute({ operation: "test.fastGit", cwd: "/repo", args: ["status"] }),
243 ),
244 { concurrency: "unbounded" },
245 ).pipe(Effect.forkChild);
246
247 yield* TestClock.adjust("0 seconds");
248 assert.equal(yield* Queue.size(starts), 8);
249 assert.equal(active, 9);
250 yield* Deferred.succeed(fastGate, undefined);
251 assert.equal((yield* Fiber.join(burst)).length, 8);
252 assert.equal(active, 1);
253 yield* Deferred.succeed(slowGate, undefined);
254 assert.equal((yield* Fiber.join(slow)).stdout, "ok");
255 assert.equal(active, 0);
256 }).pipe(Effect.provide(layerServerConfig.pipe(Layer.provideMerge(NodeServices.layer)))),
257);
258
259it.effect.each(["root", "nested", "worktree"] as const)(
260 "skips clean filters while the %s index is locked and resumes after unlock",
261 (location) =>
262 Effect.gen(function* () {
263 const driver = yield* GitVcsDriver.GitVcsDriver;
264 const fs = yield* FileSystem.FileSystem;
265 const path = yield* Path.Path;
266 const repository = yield* makeTmpDir();
267 yield* initRepoWithCommit(repository);
268 const cwd = location === "worktree" ? yield* makeTmpDir() : repository;
269 if (location === "worktree") {
270 yield* git(repository, ["worktree", "add", "--detach", cwd]);
271 }
272 yield* git(cwd, ["config", "filter.probe.clean", "echo clean >> .filter-runs; cat"]);
273 yield* writeTextFile(cwd, ".gitattributes", "asset.bin filter=probe\n");
274 yield* writeTextFile(cwd, ".gitignore", ".filter-runs\n");
275 yield* writeTextFile(cwd, "asset.bin", "original\n");
276 yield* git(cwd, ["add", "."]);
277 yield* git(cwd, ["commit", "-m", "filtered asset"]);
278 NodeFS.utimesSync(path.join(cwd, "asset.bin"), 1, 1);
279 const runsPath = path.join(cwd, ".filter-runs");
280 yield* fs.remove(runsPath, { force: true });
281 const indexPath = yield* git(cwd, ["rev-parse", "--git-path", "index"]);
282 const lockPath = `${path.resolve(cwd, indexPath)}.lock`;
283 yield* fs.writeFileString(lockPath, "");
284 const statusCwd = location === "nested" ? path.join(cwd, "nested") : cwd;
285 yield* fs.makeDirectory(statusCwd, { recursive: true });
286
287 for (let poll = 0; poll < 3; poll++) {
288 const result = yield* driver.statusDetailsLocal(statusCwd).pipe(Effect.result);
289 assert.isTrue(Result.isFailure(result));
290 if (Result.isFailure(result)) assert.include(result.failure.detail, "index is locked");
291 }
292 assert.isFalse(yield* fs.exists(runsPath));
293 assert.isTrue(yield* fs.exists(lockPath));
294
295 yield* fs.remove(lockPath);
296 const status = yield* driver.statusDetailsLocal(statusCwd);
297 assert.isFalse(status.hasWorkingTreeChanges);
298 assert.include(yield* fs.readFileString(runsPath), "clean");
299 }).pipe(Effect.provide(layerTest)),
300);
301
302it.effect("uses stable diagnostics for every parsed non-repository command", () => {
303 const commands: Array<{ readonly args: ReadonlyArray<string>; readonly lcAll?: string }> = [];
304 const spawner = ChildProcessSpawner.make((command) =>
305 Effect.sync(() => {
306 if (!ChildProcess.isStandardCommand(command)) {
307 return assert.fail("expected a standard Git command");
308 }
309 commands.push({
310 args: command.args,
311 ...(command.options.env?.LC_ALL ? { lcAll: command.options.env.LC_ALL } : {}),
312 });
313 return makeNonRepositoryHandle();
314 }),
315 );
316 const layerNodeServices = Layer.merge(
317 NodeServices.layer,
318 Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner),
319 );
320 const layer = GitVcsDriver.layer.pipe(
321 Layer.provide(layerServerConfig),
322 Layer.provideMerge(layerNodeServices),
323 );
324
325 return Effect.gen(function* () {
326 const driver = yield* GitVcsDriver.GitVcsDriver;
327 const cwd = "/repo";
328
329 yield* driver.statusDetailsLocal(cwd);
330 yield* driver.statusDetailsRemote(cwd, { refreshUpstream: false });
331 yield* driver.listRefs({ cwd });
332
333 assert.deepStrictEqual(commands, [
334 { args: ["rev-parse", "--git-path", "index"], lcAll: "C" },
335 { args: ["status", "--porcelain=2", "--branch"], lcAll: "C" },
336 { args: ["rev-parse", "--abbrev-ref", "HEAD"], lcAll: "C" },
337 { args: ["rev-parse", "--git-common-dir"], lcAll: "C" },
338 ]);
339 }).pipe(Effect.provide(layer));
340});
341
342it.effect("invalidates origin remote cache when a driver mutation adds origin", () =>
343 Effect.gen(function* () {
344 const driver = yield* GitVcsDriver.GitVcsDriver;
345 const cwd = yield* makeTmpDir();
346 const remote = yield* makeTmpDir("git-vcs-driver-remote-");
347 yield* initRepoWithCommit(cwd);
348 yield* git(remote, ["init", "--bare"]);
349
350 const before = yield* driver.statusDetailsLocal(cwd);
351 assert.equal(before.hasOriginRemote, false);
352
353 yield* driver.ensureRemote({ cwd, preferredName: "origin", url: remote });
354
355 const after = yield* driver.statusDetailsLocal(cwd);
356 assert.equal(after.hasOriginRemote, true);
357 }).pipe(Effect.provide(layerTest)),
358);
359
360it.effect("re-reads origin remote status after cache TTL expiry and bypassed invalidation", () =>
361 Effect.gen(function* () {
362 const driver = yield* GitVcsDriver.GitVcsDriver;
363 const cwd = yield* makeTmpDir();
364 const remote = yield* makeTmpDir("git-vcs-driver-remote-");
365 yield* initRepoWithCommit(cwd);
366 yield* git(remote, ["init", "--bare"]);
367
368 // First call caches hasOriginRemote = false (5-min TTL)
369 assert.equal((yield* driver.statusDetailsLocal(cwd)).hasOriginRemote, false);
370
371 // Add origin via raw git (bypasses invalidation hook)
372 yield* git(cwd, ["remote", "add", "origin", remote]);
373
374 // Cache still has the stale false (TTL not yet expired)
375 const stillCached = yield* driver.statusDetailsLocal(cwd);
376 assert.equal(stillCached.hasOriginRemote, false);
377
378 // Advance past the 5-minute TTL so the cache entry expires
379 yield* TestClock.adjust("6 minutes");
380
381 // After expiry, the next call re-executes and picks up the remote
382 const afterExpiry = yield* driver.statusDetailsLocal(cwd);
383 assert.equal(afterExpiry.hasOriginRemote, true);
384 }).pipe(Effect.provide(layerTest)),
385);
386
387it.effect("coalesces concurrent ref pages into one repository snapshot", () =>
388 Effect.scoped(
389 Effect.gen(function* () {
390 const delegate = yield* ChildProcessSpawner.ChildProcessSpawner;
391 const spawnedArgs = yield* Ref.make<ReadonlyArray<ReadonlyArray<string>>>([]);
392 const firstWorktreeScanStarted = yield* Deferred.make<void>();
393 const remoteNamesScanCompleted = yield* Deferred.make<void>();
394 const delayFirstWorktreeScan = yield* Ref.make(true);
395 const countingSpawner = ChildProcessSpawner.make((command) =>
396 Effect.gen(function* () {
397 if (!ChildProcess.isStandardCommand(command)) {
398 return yield* Effect.die("expected a standard Git command");
399 }
400 yield* Ref.update(spawnedArgs, (current) => [...current, command.args]);
401 const isWorktreeScan =
402 command.args.includes("worktree") && command.args.includes("--porcelain");
403 const shouldDelay =
404 isWorktreeScan && (yield* Ref.getAndSet(delayFirstWorktreeScan, false));
405 if (shouldDelay) {
406 yield* Deferred.succeed(firstWorktreeScanStarted, undefined);
407 yield* Effect.sleep("8 seconds");
408 }
409 const handle = yield* delegate.spawn(command);
410 const isRemoteNamesScan =
411 command.args.length === 3 &&
412 command.args[0] === "--git-dir" &&
413 command.args[2] === "remote";
414 return isRemoteNamesScan
415 ? ChildProcessSpawner.makeHandle({
416 ...handle,
417 exitCode: handle.exitCode.pipe(
418 Effect.tap(() => Deferred.succeed(remoteNamesScanCompleted, undefined)),
419 ),
420 })
421 : handle;
422 }),
423 );
424 const driver = yield* makeGitVcsDriverCore().pipe(
425 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, countingSpawner),
426 );
427 const cwd = yield* makeTmpDir();
428 const runGit = (args: ReadonlyArray<string>) =>
429 driver.execute({
430 operation: "GitVcsDriver.test.coalescedListRefs",
431 cwd,
432 args,
433 timeoutMs: 10_000,
434 });
435
436 yield* driver.initRepo({ cwd });
437 yield* runGit(["config", "user.email", "test@test.com"]);
438 yield* runGit(["config", "user.name", "Test"]);
439 yield* writeTextFile(cwd, "README.md", "# test\n");
440 yield* runGit(["add", "."]);
441 yield* runGit(["commit", "-m", "initial commit"]);
442 yield* Ref.set(spawnedArgs, []);
443
444 const initialRequest = yield* driver
445 .listRefs({ cwd, refresh: true, limit: 100 })
446 .pipe(Effect.forkChild({ startImmediately: true }));
447 yield* Deferred.await(firstWorktreeScanStarted);
448 yield* Deferred.await(remoteNamesScanCompleted);
449 yield* TestClock.adjust("6 seconds");
450 const laterRequests = yield* Effect.all(
451 Array.from({ length: 30 }, (_, index) =>
452 driver.listRefs({
453 cwd,
454 refresh: true,
455 query: `missing-${index}`,
456 limit: 100,
457 }),
458 ),
459 { concurrency: "unbounded" },
460 ).pipe(Effect.forkChild({ startImmediately: true }));
461 yield* TestClock.adjust("2 seconds");
462 yield* Fiber.join(initialRequest);
463 yield* Fiber.join(laterRequests);
464 yield* driver.listRefs({ cwd, cursor: 1, limit: 100 });
465
466 const firstSnapshotCommands = yield* Ref.get(spawnedArgs);
467 const snapshotRefScans = firstSnapshotCommands.filter(
468 (args) =>
469 args.includes("for-each-ref") &&
470 args.includes("refs/heads") &&
471 args.includes("refs/remotes"),
472 );
473 const worktreeScans = firstSnapshotCommands.filter(
474 (args) => args.includes("worktree") && args.includes("--porcelain"),
475 );
476 assert.equal(snapshotRefScans.length, 1);
477 assert.equal(worktreeScans.length, 1);
478
479 yield* driver.createRef({ cwd, refName: "feature/cache-invalidation" });
480 const refreshed = yield* driver.listRefs({ cwd, limit: 100 });
481 assert.equal(
482 refreshed.refs.some((ref) => ref.name === "feature/cache-invalidation"),
483 true,
484 );
485 const allCommands = yield* Ref.get(spawnedArgs);
486 assert.equal(
487 allCommands.filter(
488 (args) =>
489 args.includes("for-each-ref") &&
490 args.includes("refs/heads") &&
491 args.includes("refs/remotes"),
492 ).length,
493 2,
494 );
495 }),
496 ).pipe(Effect.provide(layerServerConfig.pipe(Layer.provideMerge(NodeServices.layer)))),
497);
498
499describe("pull output beyond the output cap", () => {
500 // A fast-forward that prints over 1 MB takes thousands of files, so the real pull's output is
501 // padded past the cap instead.
502 const withPaddedPull = (stream: "stdout" | "stderr") =>
503 Effect.gen(function* () {
504 const delegate = yield* ChildProcessSpawner.ChildProcessSpawner;
505 const padding = new Uint8Array(1_000_001).fill(0x20);
506 const paddingSpawner = ChildProcessSpawner.make((command) =>
507 Effect.gen(function* () {
508 const handle = yield* delegate.spawn(command);
509 const isPull =
510 ChildProcess.isStandardCommand(command) &&
511 command.args.includes("pull") &&
512 command.args.includes("--ff-only");
513 return isPull
514 ? ChildProcessSpawner.makeHandle({
515 ...handle,
516 [stream]: Stream.concat(handle[stream], Stream.make(padding)),
517 })
518 : handle;
519 }),
520 );
521 return yield* makeGitVcsDriverCore().pipe(
522 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, paddingSpawner),
523 );
524 });
525
526 const cloneBehindRemote = Effect.gen(function* () {
527 const cwd = yield* makeTmpDir();
528 const remote = yield* makeTmpDir("git-vcs-driver-remote-");
529 const updater = yield* makeTmpDir("git-vcs-driver-updater-");
530 const { initialBranch } = yield* initRepoWithCommit(cwd);
531 yield* git(remote, ["init", "--bare"]);
532 yield* git(cwd, ["remote", "add", "origin", remote]);
533 yield* git(cwd, ["push", "-u", "origin", initialBranch]);
534 yield* git(updater, ["clone", remote, "."]);
535 yield* git(updater, ["config", "user.email", "test@test.com"]);
536 yield* git(updater, ["config", "user.name", "Test"]);
537 yield* writeTextFile(updater, "remote.txt", "remote\n");
538 yield* git(updater, ["add", "remote.txt"]);
539 yield* git(updater, ["commit", "-m", "remote commit"]);
540 yield* git(updater, ["push", "origin", initialBranch]);
541 return { cwd, remoteHead: yield* git(updater, ["rev-parse", "HEAD"]) };
542 });
543
544 it.effect.each(["stdout", "stderr"] as const)(
545 "reports a fast-forward whose %s passes the cap as pulled",
546 (stream) =>
547 Effect.scoped(
548 Effect.gen(function* () {
549 const driver = yield* withPaddedPull(stream);
550 yield* Effect.gen(function* () {
551 const { cwd, remoteHead } = yield* cloneBehindRemote;
552
553 const result = yield* driver.pullCurrentBranch(cwd);
554
555 assert.equal(result.status, "pulled");
556 assert.equal(yield* git(cwd, ["rev-parse", "HEAD"]), remoteHead);
557 }).pipe(Effect.provideService(GitVcsDriver.GitVcsDriver, driver));
558 }),
559 ).pipe(Effect.provide(layerServerConfig.pipe(Layer.provideMerge(NodeServices.layer)))),
560 );
561
562 it.effect("still fails a pull that Git rejects, whatever its output size", () =>
563 Effect.scoped(
564 Effect.gen(function* () {
565 const driver = yield* withPaddedPull("stderr");
566 yield* Effect.gen(function* () {
567 const { cwd } = yield* cloneBehindRemote;
568 // A local commit the remote lacks: `pull --ff-only` cannot fast-forward and exits 128.
569 yield* writeTextFile(cwd, "local.txt", "local\n");
570 yield* git(cwd, ["add", "local.txt"]);
571 yield* git(cwd, ["commit", "-m", "local commit"]);
572
573 const error = yield* Effect.flip(driver.pullCurrentBranch(cwd));
574
575 assert.equal(error.operation, "GitVcsDriver.pullCurrentBranch.pull");
576 assert.equal(error.exitCode, 128);
577 assert.notInclude(error.detail, "exceeded");
578 }).pipe(Effect.provideService(GitVcsDriver.GitVcsDriver, driver));
579 }),
580 ).pipe(Effect.provide(layerServerConfig.pipe(Layer.provideMerge(NodeServices.layer)))),
581 );
582});
583
584it.effect("retries an in-flight ref snapshot invalidated by a mutation", () =>
585 Effect.scoped(
586 Effect.gen(function* () {
587 const delegate = yield* ChildProcessSpawner.ChildProcessSpawner;
588 const firstWorktreeScanStarted = yield* Deferred.make<void>();
589 const firstRefScanCompleted = yield* Deferred.make<void>();
590 const releaseFirstWorktreeScan = yield* Deferred.make<void>();
591 const delayFirstWorktreeScan = yield* Ref.make(true);
592 const refScans = yield* Ref.make(0);
593 const coordinatingSpawner = ChildProcessSpawner.make((command) =>
594 Effect.gen(function* () {
595 if (!ChildProcess.isStandardCommand(command)) {
596 return yield* Effect.die("expected a standard Git command");
597 }
598 const isWorktreeScan =
599 command.args.includes("worktree") && command.args.includes("--porcelain");
600 if (isWorktreeScan && (yield* Ref.getAndSet(delayFirstWorktreeScan, false))) {
601 yield* Deferred.succeed(firstWorktreeScanStarted, undefined);
602 yield* Deferred.await(releaseFirstWorktreeScan);
603 }
604 const handle = yield* delegate.spawn(command);
605 const isRefScan =
606 command.args.includes("for-each-ref") &&
607 command.args.includes("refs/heads") &&
608 command.args.includes("refs/remotes");
609 if (!isRefScan) return handle;
610 const scan = yield* Ref.updateAndGet(refScans, (count) => count + 1);
611 return scan === 1
612 ? ChildProcessSpawner.makeHandle({
613 ...handle,
614 exitCode: handle.exitCode.pipe(
615 Effect.tap(() => Deferred.succeed(firstRefScanCompleted, undefined)),
616 ),
617 })
618 : handle;
619 }),
620 );
621 const driver = yield* makeGitVcsDriverCore().pipe(
622 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, coordinatingSpawner),
623 );
624 const cwd = yield* makeTmpDir();
625 yield* initRepoWithCommit(cwd).pipe(Effect.provideService(GitVcsDriver.GitVcsDriver, driver));
626
627 const inFlight = yield* driver
628 .listRefs({ cwd, refresh: true, limit: 100 })
629 .pipe(Effect.forkChild({ startImmediately: true }));
630 yield* Deferred.await(firstWorktreeScanStarted);
631 yield* Deferred.await(firstRefScanCompleted);
632
633 yield* driver.createRef({ cwd, refName: "feature/during-refresh" });
634 yield* Deferred.succeed(releaseFirstWorktreeScan, undefined);
635
636 const refs = yield* Fiber.join(inFlight);
637 assert.isTrue(refs.refs.some((ref) => ref.name === "feature/during-refresh"));
638 assert.equal(yield* Ref.get(refScans), 2);
639 }),
640 ).pipe(Effect.provide(layerServerConfig.pipe(Layer.provideMerge(NodeServices.layer)))),
641);
642
643it.effect("invalidates a ref snapshot when a mutation fails after changing Git", () =>
644 Effect.scoped(
645 Effect.gen(function* () {
646 const delegate = yield* ChildProcessSpawner.ChildProcessSpawner;
647 const partiallyFailingSpawner = ChildProcessSpawner.make((command) =>
648 Effect.gen(function* () {
649 if (!ChildProcess.isStandardCommand(command)) {
650 return yield* Effect.die("expected a standard Git command");
651 }
652 if (command.args[0] === "branch" && command.args[1] === "feature/partial-failure") {
653 const handle = yield* delegate.spawn(command);
654 yield* handle.exitCode;
655 return makeNonRepositoryHandle();
656 }
657 return yield* delegate.spawn(command);
658 }),
659 );
660 const driver = yield* makeGitVcsDriverCore().pipe(
661 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, partiallyFailingSpawner),
662 );
663 const cwd = yield* makeTmpDir();
664 yield* initRepoWithCommit(cwd).pipe(Effect.provideService(GitVcsDriver.GitVcsDriver, driver));
665 yield* driver.listRefs({ cwd, refresh: true });
666
667 yield* driver.createRef({ cwd, refName: "feature/partial-failure" }).pipe(Effect.flip);
668
669 const refs = yield* driver.listRefs({ cwd });
670 assert.isTrue(refs.refs.some((ref) => ref.name === "feature/partial-failure"));
671 }),
672 ).pipe(Effect.provide(layerServerConfig.pipe(Layer.provideMerge(NodeServices.layer)))),
673);
674
675it.effect("fails a ref snapshot when for-each-ref exits unsuccessfully", () =>
676 Effect.scoped(
677 Effect.gen(function* () {
678 const delegate = yield* ChildProcessSpawner.ChildProcessSpawner;
679 const snapshotAttempts = yield* Ref.make(0);
680 const failingSnapshotSpawner = ChildProcessSpawner.make((command) =>
681 Effect.gen(function* () {
682 if (!ChildProcess.isStandardCommand(command)) {
683 return yield* Effect.die("expected a standard Git command");
684 }
685 if (command.args.includes("for-each-ref")) {
686 yield* Ref.update(snapshotAttempts, (count) => count + 1);
687 return makeNonRepositoryHandle();
688 }
689 return yield* delegate.spawn(command);
690 }),
691 );
692 const driver = yield* makeGitVcsDriverCore().pipe(
693 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, failingSnapshotSpawner),
694 );
695 const cwd = yield* makeTmpDir();
696 yield* initRepoWithCommit(cwd).pipe(Effect.provideService(GitVcsDriver.GitVcsDriver, driver));
697
698 const error = yield* driver.listRefs({ cwd, refresh: true }).pipe(Effect.flip);
699
700 assert.deepInclude(error, {
701 _tag: "GitCommandError",
702 operation: "GitVcsDriver.listRefs.snapshotRefs",
703 detail: "Git ref snapshot enumeration failed.",
704 exitCode: 128,
705 });
706 assert.equal(yield* Ref.get(snapshotAttempts), 1);
707 }),
708 ).pipe(Effect.provide(layerServerConfig.pipe(Layer.provideMerge(NodeServices.layer)))),
709);
710
711it.effect("marks the current branch when worktree metadata is unavailable", () =>
712 Effect.scoped(
713 Effect.gen(function* () {
714 const delegate = yield* ChildProcessSpawner.ChildProcessSpawner;
715 const incompleteMetadataSpawner = ChildProcessSpawner.make((command) =>
716 Effect.gen(function* () {
717 if (!ChildProcess.isStandardCommand(command)) {
718 return yield* Effect.die("expected a standard Git command");
719 }
720 const isWorktreeRoot =
721 command.args.includes("rev-parse") && command.args.includes("--show-toplevel");
722 const isWorktreeList =
723 command.args.includes("worktree") && command.args.includes("--porcelain");
724 if (isWorktreeRoot || isWorktreeList) {
725 return makeNonRepositoryHandle();
726 }
727 return yield* delegate.spawn(command);
728 }),
729 );
730 const driver = yield* makeGitVcsDriverCore().pipe(
731 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, incompleteMetadataSpawner),
732 );
733 const cwd = yield* makeTmpDir();
734 const { initialBranch } = yield* initRepoWithCommit(cwd).pipe(
735 Effect.provideService(GitVcsDriver.GitVcsDriver, driver),
736 );
737
738 const refs = yield* driver.listRefs({ cwd, refresh: true });
739
740 assert.isTrue(refs.isRepo);
741 assert.isTrue(refs.refs.find((ref) => ref.name === initialBranch)?.current);
742 }),
743 ).pipe(Effect.provide(layerServerConfig.pipe(Layer.provideMerge(NodeServices.layer)))),
744);
745
746it.effect("ignores worktree metadata for directories that no longer exist", () =>
747 Effect.scoped(
748 Effect.gen(function* () {
749 const delegate = yield* ChildProcessSpawner.ChildProcessSpawner;
750 const missingWorktreePath = "/missing/deleted-worktree";
751 const staleWorktreeSpawner = ChildProcessSpawner.make((command) =>
752 Effect.gen(function* () {
753 if (!ChildProcess.isStandardCommand(command)) {
754 return yield* Effect.die("expected a standard Git command");
755 }
756 const isWorktreeList =
757 command.args.includes("worktree") && command.args.includes("--porcelain");
758 if (isWorktreeList) {
759 return makeSuccessfulHandle(
760 `worktree ${missingWorktreePath}\0HEAD deadbeef\0branch refs/heads/stale-worktree\0\0`,
761 );
762 }
763 return yield* delegate.spawn(command);
764 }),
765 );
766 const driver = yield* makeGitVcsDriverCore().pipe(
767 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, staleWorktreeSpawner),
768 );
769 const cwd = yield* makeTmpDir();
770 yield* initRepoWithCommit(cwd).pipe(Effect.provideService(GitVcsDriver.GitVcsDriver, driver));
771 yield* git(cwd, ["branch", "stale-worktree"]).pipe(
772 Effect.provideService(GitVcsDriver.GitVcsDriver, driver),
773 );
774
775 const refs = yield* driver.listRefs({ cwd, refresh: true });
776
777 assert.equal(refs.refs.find((ref) => ref.name === "stale-worktree")?.worktreePath, null);
778 }),
779 ).pipe(Effect.provide(layerServerConfig.pipe(Layer.provideMerge(NodeServices.layer)))),
780);
781
782it.effect("reads worktrees from git that rejects worktree list -z", () =>
783 Effect.scoped(
784 Effect.gen(function* () {
785 const delegate = yield* ChildProcessSpawner.ChildProcessSpawner;
786 // Git before 2.36 has no `worktree list -z` and exits with a usage error.
787 const oldGitSpawner = ChildProcessSpawner.make((command) =>
788 Effect.gen(function* () {
789 if (!ChildProcess.isStandardCommand(command)) {
790 return yield* Effect.die("expected a standard Git command");
791 }
792 if (command.args.includes("worktree") && command.args.includes("-z")) {
793 return ChildProcessSpawner.makeHandle({
794 pid: ChildProcessSpawner.ProcessId(1),
795 exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(129)),
796 isRunning: Effect.succeed(false),
797 kill: () => Effect.void,
798 unref: Effect.succeed(Effect.void),
799 stdin: Sink.drain,
800 stdout: Stream.empty,
801 stderr: Stream.encodeText(Stream.make("error: unknown switch `z'")),
802 all: Stream.empty,
803 getInputFd: () => Sink.drain,
804 getOutputFd: () => Stream.empty,
805 });
806 }
807 return yield* delegate.spawn(command);
808 }),
809 );
810 const driver = yield* makeGitVcsDriverCore().pipe(
811 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, oldGitSpawner),
812 );
813 const cwd = yield* makeTmpDir();
814 yield* initRepoWithCommit(cwd).pipe(Effect.provideService(GitVcsDriver.GitVcsDriver, driver));
815 const worktreePath = NodeFS.realpathSync.native(yield* makeTmpDir());
816 yield* git(cwd, ["worktree", "add", "-b", "feature/old-git", worktreePath]).pipe(
817 Effect.provideService(GitVcsDriver.GitVcsDriver, driver),
818 );
819
820 const refs = yield* driver.listRefs({ cwd, refresh: true });
821 const listedPath = refs.refs.find((ref) => ref.name === "feature/old-git")?.worktreePath;
822 assert.equal(listedPath && NodeFS.realpathSync.native(listedPath), worktreePath);
823
824 const paths = yield* driver.listWorktreePaths(cwd);
825 assert.include(
826 paths.map((listed) => NodeFS.realpathSync.native(listed)),
827 worktreePath,
828 );
829 }),
830 ).pipe(Effect.provide(layerServerConfig.pipe(Layer.provideMerge(NodeServices.layer)))),
831);
832
833it.effect("refreshes the current branch after an external checkout", () =>
834 Effect.scoped(
835 Effect.gen(function* () {
836 const driver = yield* GitVcsDriver.GitVcsDriver;
837 const cwd = yield* makeTmpDir();
838 const { initialBranch } = yield* initRepoWithCommit(cwd);
839 yield* git(cwd, ["branch", "external-checkout"]);
840
841 const initialRefs = yield* driver.listRefs({ cwd, refresh: true });
842 assert.isTrue(initialRefs.refs.find((ref) => ref.name === initialBranch)?.current);
843
844 // Raw execute intentionally bypasses the driver's mutation invalidation,
845 // matching a checkout performed by another process.
846 yield* driver.execute({
847 operation: "GitVcsDriver.test.externalCheckout",
848 cwd,
849 args: ["checkout", "external-checkout"],
850 timeoutMs: 10_000,
851 });
852 yield* TestClock.adjust("6 seconds");
853
854 const refreshedRefs = yield* driver.listRefs({ cwd, refresh: true });
855 assert.isTrue(refreshedRefs.refs.find((ref) => ref.name === "external-checkout")?.current);
856 assert.isFalse(refreshedRefs.refs.find((ref) => ref.name === initialBranch)?.current);
857 }),
858 ).pipe(Effect.provide(layerTest)),
859);
860
861it.effect("backs off and logs failed fetch attempts across linked worktrees", () =>
862 Effect.scoped(
863 Effect.gen(function* () {
864 const delegate = yield* ChildProcessSpawner.ChildProcessSpawner;
865 const fetchAttempts = yield* Ref.make(0);
866 const failingFetchSpawner = ChildProcessSpawner.make((command) =>
867 Effect.gen(function* () {
868 if (!ChildProcess.isStandardCommand(command)) {
869 return yield* Effect.die("expected a standard Git command");
870 }
871 if (command.args.includes("fetch") && command.args.includes("--quiet")) {
872 yield* Ref.update(fetchAttempts, (count) => count + 1);
873 return makeNonRepositoryHandle();
874 }
875 return yield* delegate.spawn(command);
876 }),
877 );
878 const driver = yield* makeGitVcsDriverCore().pipe(
879 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, failingFetchSpawner),
880 );
881 const warnings: string[] = [];
882 const logger = Logger.make<unknown, void>(({ message }) => {
883 warnings.push(String(message));
884 });
885 const readRemoteStatus = (workingDirectory: string) =>
886 driver
887 .statusDetailsRemote(workingDirectory)
888 .pipe(Effect.provideService(Logger.CurrentLoggers, new Set([logger])));
889 const cwd = yield* makeTmpDir();
890 const remote = yield* makeTmpDir("git-vcs-driver-remote-");
891 const worktreesRoot = yield* makeTmpDir("git-vcs-driver-worktrees-");
892 const pathService = yield* Path.Path;
893 const worktreePath = pathService.join(worktreesRoot, "linked");
894 const runGit = (workingDirectory: string, args: ReadonlyArray<string>) =>
895 driver.execute({
896 operation: "GitVcsDriver.test.upstreamRefreshBackoff",
897 cwd: workingDirectory,
898 args,
899 timeoutMs: 10_000,
900 });
901
902 yield* driver.initRepo({ cwd });
903 yield* runGit(cwd, ["config", "user.email", "test@test.com"]);
904 yield* runGit(cwd, ["config", "user.name", "Test"]);
905 yield* writeTextFile(cwd, "README.md", "# test\n");
906 yield* runGit(cwd, ["add", "."]);
907 yield* runGit(cwd, ["commit", "-m", "initial commit"]);
908 const initialBranch = (yield* runGit(cwd, ["branch", "--show-current"])).stdout.trim();
909 yield* runGit(remote, ["init", "--bare"]);
910 yield* runGit(cwd, ["remote", "add", "origin", remote]);
911 yield* runGit(cwd, ["push", "-u", "origin", initialBranch]);
912 yield* runGit(cwd, ["worktree", "add", "-b", "feature/linked", worktreePath]);
913 yield* runGit(worktreePath, [
914 "branch",
915 "--set-upstream-to",
916 `origin/${initialBranch}`,
917 "feature/linked",
918 ]);
919 const rootCommonDir = (yield* runGit(cwd, ["rev-parse", "--git-common-dir"])).stdout.trim();
920 const linkedCommonDir = (yield* runGit(worktreePath, [
921 "rev-parse",
922 "--git-common-dir",
923 ])).stdout.trim();
924 // Native realpath, since git reports the long form of a directory the
925 // temp dir may name by its 8.3 short form on Windows.
926 assert.equal(
927 NodeFS.realpathSync.native(pathService.resolve(cwd, rootCommonDir)),
928 NodeFS.realpathSync.native(pathService.resolve(worktreePath, linkedCommonDir)),
929 );
930 yield* Ref.set(fetchAttempts, 0);
931
932 yield* readRemoteStatus(cwd);
933 yield* readRemoteStatus(worktreePath);
934 assert.equal(yield* Ref.get(fetchAttempts), 1);
935 assert.lengthOf(warnings, 1);
936 assert.include(warnings[0], "Background Git fetch failed");
937
938 yield* TestClock.adjust("29 seconds");
939 yield* readRemoteStatus(worktreePath);
940 assert.equal(yield* Ref.get(fetchAttempts), 1);
941 assert.lengthOf(warnings, 1);
942
943 yield* TestClock.adjust("1 second");
944 yield* readRemoteStatus(cwd);
945 assert.equal(yield* Ref.get(fetchAttempts), 2);
946 assert.lengthOf(warnings, 2);
947
948 yield* TestClock.adjust("59 seconds");
949 yield* readRemoteStatus(worktreePath);
950 assert.equal(yield* Ref.get(fetchAttempts), 2);
951 assert.lengthOf(warnings, 2);
952
953 yield* TestClock.adjust("1 second");
954 yield* readRemoteStatus(cwd);
955 assert.equal(yield* Ref.get(fetchAttempts), 3);
956 assert.lengthOf(warnings, 3);
957 }),
958 ).pipe(Effect.provide(layerServerConfig.pipe(Layer.provideMerge(NodeServices.layer)))),
959);
960
961it.effect.each([
962 {
963 name: "HTTPS credentials",
964 stderr: "fatal: Authentication failed for",
965 expected: "could not authenticate",
966 },
967 {
968 name: "SSH credentials",
969 stderr: "git@example.com: Permission denied (publickey).",
970 expected: "could not authenticate",
971 },
972 {
973 name: "disabled prompts",
974 stderr: "fatal: could not read Username: terminal prompts disabled",
975 expected: "could not authenticate",
976 },
977 {
978 name: "DNS failure",
979 stderr: "fatal: Could not resolve host: example.com",
980 expected: "could not reach the remote",
981 },
982 {
983 name: "connection failure",
984 stderr: "ssh: connect to host example.com port 22: Connection refused",
985 expected: "could not reach the remote",
986 },
987 {
988 name: "missing remote",
989 stderr: "remote: Repository not found.",
990 expected: "could not access the remote repository",
991 },
992 {
993 name: "invalid remote",
994 stderr: "fatal: remote does not appear to be a git repository",
995 expected: "could not access the remote repository",
996 },
997 {
998 name: "reference lock",
999 stderr: "error: cannot lock ref 'refs/remotes/origin/main': is at abc but expected def",
1000 expected: "could not update a local reference",
1001 attempts: 2,
1002 },
1003 {
1004 name: "lock file",
1005 stderr: "fatal: Unable to create '/repo/.git/FETCH_HEAD.lock': File exists.",
1006 expected: "could not update a local reference",
1007 attempts: 2,
1008 },
1009 {
1010 name: "reference race on Git 2.53",
1011 stderr: "error: fetching ref refs/remotes/origin/main failed: incorrect old value provided",
1012 expected: "could not update a local reference",
1013 attempts: 2,
1014 },
1015 {
1016 name: "held reference lock on Git 2.53",
1017 stderr: "error: fetching ref refs/remotes/origin/main failed: reference already exists",
1018 expected: "could not update a local reference",
1019 attempts: 2,
1020 },
1021 {
1022 name: "unrelated remote chatter",
1023 stderr:
1024 "remote: Help: authentication failed, connection refused, cannot lock ref\nremote: unrelated service error",
1025 expected: "git fetch origin failed",
1026 },
1027 {
1028 name: "HTTPS DNS failure",
1029 stderr:
1030 "fatal: unable to access 'https://example.com/repo.git/': Could not resolve host: example.com",
1031 expected: "could not reach the remote",
1032 },
1033 {
1034 name: "unknown failure",
1035 stderr: "fatal: unexpected remote failure",
1036 expected: "git fetch origin failed",
1037 },
1038] as const)("reports $name during fetch without retaining remote output", (scenario) =>
1039 Effect.gen(function* () {
1040 const secret = "secret-fetch-token";
1041 const stderr = `${scenario.stderr}\nhttps://user:${secret}@example.com/private?token=${secret}`;
1042 const attempts = yield* Ref.make(0);
1043 const spawner = ChildProcessSpawner.make((command) =>
1044 Effect.gen(function* () {
1045 if (!ChildProcess.isStandardCommand(command))
1046 return yield* Effect.die("expected Git command");
1047 if (command.args[0] !== "fetch") return makeNonRepositoryHandle();
1048 assert.deepEqual(command.args, ["fetch", "--quiet", "origin"]);
1049 assert.equal(command.options.env?.LC_ALL, "C");
1050 assert.equal(command.options.env?.GIT_TERMINAL_PROMPT, "0");
1051 yield* Ref.update(attempts, (count) => count + 1);
1052 return ChildProcessSpawner.makeHandle({
1053 pid: ChildProcessSpawner.ProcessId(1),
1054 exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(128)),
1055 isRunning: Effect.succeed(false),
1056 kill: () => Effect.void,
1057 unref: Effect.succeed(Effect.void),
1058 stdin: Sink.drain,
1059 stdout: Stream.encodeText(Stream.make(secret)),
1060 stderr: Stream.encodeText(Stream.make(stderr)),
1061 all: Stream.empty,
1062 getInputFd: () => Sink.drain,
1063 getOutputFd: () => Stream.empty,
1064 });
1065 }),
1066 );
1067 const driver = yield* makeGitVcsDriverCore().pipe(
1068 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
1069 );
1070 const cwd = yield* makeTmpDir();
1071 const error = yield* driver.fetchRemote({ cwd, remoteName: "origin" }).pipe(Effect.flip);
1072 assert.include(error.detail, scenario.expected);
1073 assert.equal(error.exitCode, 128);
1074 assert.equal(error.stderrLength, stderr.length);
1075 assert.equal(error.stdoutLength, secret.length);
1076 assert.notInclude(error.message, secret);
1077 assert.notInclude(yield* encodeGitCommandError(error), secret);
1078 assert.notProperty(error, "stderr");
1079 assert.notProperty(error, "args");
1080 assert.equal(yield* Ref.get(attempts), "attempts" in scenario ? scenario.attempts : 1);
1081 }).pipe(Effect.provide(layerServerConfig.pipe(Layer.provideMerge(NodeServices.layer)))),
1082);
1083
1084it.effect("retries a fetch that lost a ref update to another Git process", () =>
1085 Effect.gen(function* () {
1086 const attempts = yield* Ref.make(0);
1087 const spawner = ChildProcessSpawner.make((command) =>
1088 Effect.gen(function* () {
1089 if (!ChildProcess.isStandardCommand(command))
1090 return yield* Effect.die("expected Git command");
1091 if (command.args[0] !== "fetch") return makeNonRepositoryHandle();
1092 const attempt = yield* Ref.updateAndGet(attempts, (count) => count + 1);
1093 if (attempt > 1) return makeSuccessfulHandle("");
1094 return ChildProcessSpawner.makeHandle({
1095 ...makeNonRepositoryHandle(),
1096 exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(1)),
1097 stderr: Stream.encodeText(
1098 Stream.make(
1099 "error: fetching ref refs/remotes/origin/main failed: incorrect old value provided",
1100 ),
1101 ),
1102 });
1103 }),
1104 );
1105 const driver = yield* makeGitVcsDriverCore().pipe(
1106 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
1107 );
1108 const cwd = yield* makeTmpDir();
1109 yield* driver.fetchRemote({ cwd, remoteName: "origin", refName: "main" });
1110 assert.equal(yield* Ref.get(attempts), 2);
1111 }).pipe(Effect.provide(layerServerConfig.pipe(Layer.provideMerge(NodeServices.layer)))),
1112);
1113
1114it.layer(layerTest)("GitVcsDriver core integration", (it) => {
1115 describe("process environment", () => {
1116 it.effect("preserves the caller locale for general Git subprocesses", () =>
1117 Effect.gen(function* () {
1118 const cwd = yield* makeTmpDir();
1119
1120 const locale = yield* git(
1121 cwd,
1122 ["-c", 'alias.print-locale=!printf "%s" "$LC_ALL"', "print-locale"],
1123 { LC_ALL: "zh_CN.UTF-8" },
1124 );
1125
1126 assert.equal(locale, "zh_CN.UTF-8");
1127 }),
1128 );
1129 });
1130
1131 describe("structured errors", () => {
1132 it.effect("preserves structured spawn context and the platform cause", () =>
1133 Effect.gen(function* () {
1134 const parent = yield* makeTmpDir();
1135 const pathService = yield* Path.Path;
1136 const cwd = pathService.join(parent, "missing");
1137 const driver = yield* GitVcsDriver.GitVcsDriver;
1138
1139 const error = yield* driver
1140 .execute({
1141 operation: "GitVcsDriver.test.missingCwd",
1142 cwd,
1143 args: ["status", "--short"],
1144 })
1145 .pipe(Effect.flip);
1146
1147 assert.deepInclude(error, {
1148 _tag: "GitCommandError",
1149 operation: "GitVcsDriver.test.missingCwd",
1150 command: "git",
1151 argumentCount: 2,
1152 cwd,
1153 detail: "Failed to spawn Git process.",
1154 });
1155 if (!(error.cause instanceof PlatformError.PlatformError)) {
1156 return assert.fail("expected the original platform error cause");
1157 }
1158 assert.equal(error.cause.reason._tag, "NotFound");
1159 assert.notInclude(error.detail, error.cause.message);
1160 }),
1161 );
1162
1163 it.effect("does not retain git arguments or stderr in command failures", () =>
1164 Effect.gen(function* () {
1165 const cwd = yield* makeTmpDir();
1166 const driver = yield* GitVcsDriver.GitVcsDriver;
1167 yield* driver.initRepo({ cwd });
1168
1169 const secret = "secret-token-value";
1170 const error = yield* driver
1171 .execute({
1172 operation: "GitVcsDriver.test.redactedFailure",
1173 cwd,
1174 args: ["status", `--unknown-option=${secret}`],
1175 })
1176 .pipe(Effect.flip);
1177
1178 assert.deepInclude(error, {
1179 _tag: "GitCommandError",
1180 operation: "GitVcsDriver.test.redactedFailure",
1181 command: "git",
1182 argumentCount: 2,
1183 cwd,
1184 });
1185 assert.isNumber(error.exitCode);
1186 assert.isAbove(error.stderrLength ?? 0, 0);
1187 assert.notInclude(error.detail, secret);
1188 assert.notInclude(error.message, secret);
1189 assert.notProperty(error, "args");
1190 assert.notProperty(error, "stderr");
1191 assert.notProperty(error, "reason");
1192 }),
1193 );
1194
1195 it.effect("names the branch conflict behind a failed worktree add", () =>
1196 Effect.gen(function* () {
1197 const parent = yield* makeTmpDir();
1198 const pathService = yield* Path.Path;
1199 const cwd = pathService.join(parent, "repo");
1200 const fileSystem = yield* FileSystem.FileSystem;
1201 yield* fileSystem.makeDirectory(cwd);
1202 const driver = yield* GitVcsDriver.GitVcsDriver;
1203 yield* initRepoWithCommit(cwd);
1204 const firstWorktree = pathService.join(parent, "first");
1205 yield* git(cwd, ["worktree", "add", firstWorktree, "-b", "shared-branch"]);
1206
1207 const error = yield* driver
1208 .execute({
1209 operation: "GitVcsDriver.test.worktreeAddConflict",
1210 cwd,
1211 args: ["worktree", "add", pathService.join(parent, "second"), "shared-branch"],
1212 env: { LC_ALL: "C" },
1213 })
1214 .pipe(Effect.flip);
1215
1216 assert.deepInclude(error, {
1217 _tag: "GitCommandError",
1218 operation: "GitVcsDriver.test.worktreeAddConflict",
1219 reason: "branch_checked_out_in_worktree",
1220 });
1221 assert.include(error.message, "(branch_checked_out_in_worktree)");
1222 assert.notInclude(error.message, firstWorktree);
1223 }),
1224 );
1225
1226 it.effect("names a missing repository behind a failed command", () =>
1227 Effect.gen(function* () {
1228 const cwd = yield* makeTmpDir();
1229 const driver = yield* GitVcsDriver.GitVcsDriver;
1230
1231 const error = yield* driver
1232 .execute({
1233 operation: "GitVcsDriver.test.notARepository",
1234 cwd,
1235 args: ["rev-parse", "--abbrev-ref", "HEAD"],
1236 env: { LC_ALL: "C" },
1237 })
1238 .pipe(Effect.flip);
1239
1240 assert.deepInclude(error, {
1241 _tag: "GitCommandError",
1242 operation: "GitVcsDriver.test.notARepository",
1243 reason: "not_a_repository",
1244 });
1245 assert.include(error.message, "(not_a_repository)");
1246 }),
1247 );
1248
1249 it.effect("appends the reason tag to a caller-supplied detail", () =>
1250 Effect.gen(function* () {
1251 const parent = yield* makeTmpDir();
1252 const pathService = yield* Path.Path;
1253 const cwd = pathService.join(parent, "repo");
1254 const fileSystem = yield* FileSystem.FileSystem;
1255 yield* fileSystem.makeDirectory(cwd);
1256 const driver = yield* GitVcsDriver.GitVcsDriver;
1257 yield* initRepoWithCommit(cwd);
1258 yield* git(cwd, [
1259 "worktree",
1260 "add",
1261 pathService.join(parent, "taken"),
1262 "-b",
1263 "taken-branch",
1264 ]);
1265
1266 const error = yield* driver
1267 .createWorktree({
1268 cwd,
1269 refName: "taken-branch",
1270 path: pathService.join(parent, "second"),
1271 })
1272 .pipe(Effect.flip);
1273
1274 assert.equal(error.detail, "git worktree add failed");
1275 assert.include(error.message, "git worktree add failed (branch_checked_out_in_worktree)");
1276 }),
1277 );
1278
1279 it.effect.each([
1280 {
1281 label: "a refused key",
1282 sshMessage: "git@example.invalid: Permission denied (publickey).",
1283 expectedReason: "authentication_failed" as const,
1284 expectedText: "(authentication_failed)",
1285 },
1286 {
1287 label: "a refused key among several methods",
1288 sshMessage: "git@example.invalid: Permission denied (publickey,password).",
1289 expectedReason: "authentication_failed" as const,
1290 expectedText: "(authentication_failed)",
1291 },
1292 {
1293 label: "a refused keyboard-interactive attempt",
1294 sshMessage: "git@example.invalid: Permission denied (keyboard-interactive).",
1295 expectedReason: "authentication_failed" as const,
1296 expectedText: "(authentication_failed)",
1297 },
1298 {
1299 label: "a rejected password",
1300 sshMessage: "Permission denied, please try again.",
1301 expectedReason: "authentication_failed" as const,
1302 expectedText: "(authentication_failed)",
1303 },
1304 {
1305 label: "an untrusted host key",
1306 sshMessage: "Host key verification failed.",
1307 expectedReason: "host_key_unverified" as const,
1308 expectedText: "(host_key_unverified)",
1309 },
1310 ])(
1311 "names $label rather than an unreachable remote",
1312 ({ sshMessage, expectedReason, expectedText }) =>
1313 Effect.gen(function* () {
1314 const parent = yield* makeTmpDir();
1315 const pathService = yield* Path.Path;
1316 const cwd = pathService.join(parent, "repo");
1317 const fileSystem = yield* FileSystem.FileSystem;
1318 const driver = yield* GitVcsDriver.GitVcsDriver;
1319 yield* fileSystem.makeDirectory(cwd);
1320 yield* initRepoWithCommit(cwd);
1321 yield* git(cwd, ["remote", "add", "origin", "git@example.invalid:owner/repo.git"]);
1322 // Stands in for ssh, which prints its refusal unprefixed and leaves
1323 // git to add only a generic "could not read" line after it.
1324 const fakeSsh = pathService.join(parent, "fake-ssh");
1325 yield* writeTextFile(
1326 parent,
1327 "fake-ssh",
1328 `#!/bin/sh\necho "${sshMessage}" >&2\nexit 255\n`,
1329 );
1330 yield* fileSystem.chmod(fakeSsh, 0o755);
1331
1332 const error = yield* driver
1333 .execute({
1334 operation: "GitVcsDriver.test.sshRefusal",
1335 cwd,
1336 args: ["fetch", "origin"],
1337 env: { LC_ALL: "C", GIT_SSH_COMMAND: fakeSsh },
1338 })
1339 .pipe(Effect.flip);
1340
1341 assert.deepInclude(error, { reason: expectedReason });
1342 assert.include(error.message, expectedText);
1343 }),
1344 );
1345
1346 it.effect("does not read a filesystem permission error as an ssh refusal", () =>
1347 Effect.gen(function* () {
1348 const parent = yield* makeTmpDir();
1349 const pathService = yield* Path.Path;
1350 const cwd = pathService.join(parent, "repo");
1351 const fileSystem = yield* FileSystem.FileSystem;
1352 const driver = yield* GitVcsDriver.GitVcsDriver;
1353 yield* fileSystem.makeDirectory(cwd);
1354 yield* initRepoWithCommit(cwd);
1355 yield* git(cwd, ["remote", "add", "origin", "git@example.invalid:owner/repo.git"]);
1356 const fakeSsh = pathService.join(parent, "fake-ssh");
1357 yield* writeTextFile(
1358 parent,
1359 "fake-ssh",
1360 '#!/bin/sh\necho "fatal: cannot open backup file: Permission denied (os error 13)" >&2\nexit 255\n',
1361 );
1362 yield* fileSystem.chmod(fakeSsh, 0o755);
1363
1364 const error = yield* driver
1365 .execute({
1366 operation: "GitVcsDriver.test.osPermission",
1367 cwd,
1368 args: ["fetch", "origin"],
1369 env: { LC_ALL: "C", GIT_SSH_COMMAND: fakeSsh },
1370 })
1371 .pipe(Effect.flip);
1372
1373 assert.notInclude(error.message, "(authentication_failed)");
1374 }),
1375 );
1376
1377 it.effect("does not read remote hook output as a git failure reason", () =>
1378 Effect.gen(function* () {
1379 const parent = yield* makeTmpDir();
1380 const pathService = yield* Path.Path;
1381 const remote = pathService.join(parent, "origin.git");
1382 const cwd = pathService.join(parent, "work");
1383 const fileSystem = yield* FileSystem.FileSystem;
1384 const driver = yield* GitVcsDriver.GitVcsDriver;
1385 yield* fileSystem.makeDirectory(cwd);
1386 yield* initRepoWithCommit(cwd);
1387 yield* git(cwd, ["init", "--bare", remote]);
1388 yield* git(cwd, ["remote", "add", "origin", remote]);
1389 // Git prefixes everything the server says with `remote:`, so a remote
1390 // hook can put arbitrary text in front of the classifier.
1391 const preReceive = pathService.join(remote, "hooks", "pre-receive");
1392 yield* writeTextFile(
1393 remote,
1394 "hooks/pre-receive",
1395 '#!/bin/sh\necho "authentication failed" >&2\necho "Permission denied (publickey)." >&2\necho "Host key verification failed." >&2\nexit 1\n',
1396 );
1397 yield* fileSystem.chmod(preReceive, 0o755);
1398
1399 const error = yield* driver
1400 .execute({
1401 operation: "GitVcsDriver.test.remoteHookOutput",
1402 cwd,
1403 args: ["push", "origin", "HEAD"],
1404 env: { LC_ALL: "C" },
1405 })
1406 .pipe(Effect.flip);
1407
1408 assert.notProperty(error, "reason");
1409 assert.notInclude(error.message, "(authentication_failed)");
1410 }),
1411 );
1412
1413 it.effect("does not read hook output as a git failure reason", () =>
1414 Effect.gen(function* () {
1415 const parent = yield* makeTmpDir();
1416 const pathService = yield* Path.Path;
1417 const remote = pathService.join(parent, "origin.git");
1418 const cwd = pathService.join(parent, "work");
1419 const fileSystem = yield* FileSystem.FileSystem;
1420 const driver = yield* GitVcsDriver.GitVcsDriver;
1421 yield* fileSystem.makeDirectory(cwd);
1422 yield* initRepoWithCommit(cwd);
1423 yield* git(cwd, ["init", "--bare", remote]);
1424 yield* git(cwd, ["remote", "add", "origin", remote]);
1425 yield* writeTextFile(
1426 cwd,
1427 ".git/hooks/pre-push",
1428 '#!/bin/sh\necho "authentication failed" >&2\nexit 1\n',
1429 );
1430 yield* fileSystem.chmod(pathService.join(cwd, ".git/hooks/pre-push"), 0o755);
1431
1432 const error = yield* driver
1433 .execute({
1434 operation: "GitVcsDriver.test.hookOutput",
1435 cwd,
1436 args: ["push", "origin", "HEAD"],
1437 env: { LC_ALL: "C" },
1438 })
1439 .pipe(Effect.flip);
1440
1441 assert.notProperty(error, "reason");
1442 assert.notInclude(error.message, "(authentication_failed)");
1443 }),
1444 );
1445
1446 it.effect("leaves a tag collision unclassified rather than calling it a path", () =>
1447 Effect.gen(function* () {
1448 const cwd = yield* makeTmpDir();
1449 const driver = yield* GitVcsDriver.GitVcsDriver;
1450 yield* initRepoWithCommit(cwd);
1451 yield* git(cwd, ["tag", "v1"]);
1452
1453 const error = yield* driver
1454 .execute({
1455 operation: "GitVcsDriver.test.tagCollision",
1456 cwd,
1457 args: ["tag", "v1"],
1458 env: { LC_ALL: "C" },
1459 })
1460 .pipe(Effect.flip);
1461
1462 assert.notProperty(error, "reason");
1463 assert.notInclude(error.message, "(path_already_exists)");
1464 }),
1465 );
1466
1467 it.effect("keeps line callbacks flowing past the output cap when asked", () =>
1468 Effect.gen(function* () {
1469 const cwd = yield* makeTmpDir();
1470 const driver = yield* GitVcsDriver.GitVcsDriver;
1471 // 4 KiB of multi-byte lines, well past a 512-byte cap; the last line
1472 // is the one a failure surface would need.
1473 const lines: Array<string> = [];
1474 const result = yield* driver.execute({
1475 operation: "GitVcsDriver.test.callbacksPastCap",
1476 cwd,
1477 args: [
1478 "-c",
1479 'alias.spew=!for i in $(seq 1 128); do printf "é%03d\\n" $i >&2; done; echo fatal: last line >&2',
1480 "spew",
1481 ],
1482 maxOutputBytes: 512,
1483 appendTruncationMarker: true,
1484 keepLineCallbacksAfterTruncation: true,
1485 progress: { onStderrLine: (line) => Effect.sync(() => void lines.push(line)) },
1486 });
1487
1488 assert.isTrue(result.stderrTruncated);
1489 assert.isAtMost(result.stderr.length, 600);
1490 assert.equal(lines.length, 129);
1491 assert.equal(lines[0], "é001");
1492 assert.equal(lines[127], "é128");
1493 assert.equal(lines.at(-1), "fatal: last line");
1494 // No replacement characters: the cap landing inside "é" is invisible to callbacks.
1495 assert.isFalse(lines.some((line) => line.includes("\uFFFD")));
1496 }),
1497 );
1498
1499 it.effect("recovers a structurally identified missing cwd as a non-repository", () =>
1500 Effect.gen(function* () {
1501 const parent = yield* makeTmpDir();
1502 const pathService = yield* Path.Path;
1503 const cwd = pathService.join(parent, "missing");
1504 const driver = yield* GitVcsDriver.GitVcsDriver;
1505
1506 const [localStatus, remoteStatus, refs] = yield* Effect.all([
1507 driver.statusDetails(cwd),
1508 driver.statusDetailsRemote(cwd, { refreshUpstream: false }),
1509 driver.listRefs({ cwd }),
1510 ]);
1511
1512 assert.equal(localStatus.isRepo, false);
1513 assert.equal(remoteStatus.isRepo, false);
1514 assert.equal(refs.isRepo, false);
1515 assert.deepStrictEqual(refs.refs, []);
1516 }),
1517 );
1518
1519 it.effect("does not wrap a remove-worktree command failure in a synthetic error", () =>
1520 Effect.gen(function* () {
1521 const cwd = yield* makeTmpDir();
1522 const pathService = yield* Path.Path;
1523 const fileSystem = yield* FileSystem.FileSystem;
1524 const notAWorktree = pathService.join(cwd, "not-a-worktree");
1525 yield* fileSystem.makeDirectory(notAWorktree);
1526 const driver = yield* GitVcsDriver.GitVcsDriver;
1527 yield* driver.initRepo({ cwd });
1528
1529 const error = yield* driver.removeWorktree({ cwd, path: notAWorktree }).pipe(Effect.flip);
1530
1531 assert.deepInclude(error, {
1532 _tag: "GitCommandError",
1533 operation: "GitVcsDriver.removeWorktree",
1534 command: "git",
1535 argumentCount: 5,
1536 cwd,
1537 });
1538 assert.notProperty(error, "cause");
1539 assert.notProperty(error, "stderr");
1540 assert.notInclude(error.detail, "Git command failed in");
1541 }),
1542 );
1543 });
1544
1545 describe("review diff previews", () => {
1546 it.effect("loads repository-relative files from a nested project directory", () =>
1547 Effect.gen(function* () {
1548 const cwd = yield* makeTmpDir();
1549 const { initialBranch } = yield* initRepoWithCommit(cwd);
1550 yield* git(cwd, ["checkout", "-b", "feature/nested"]);
1551 yield* writeTextFile(cwd, "nested/tracked.txt", "committed\n");
1552 yield* git(cwd, ["add", "."]);
1553 yield* git(cwd, ["commit", "-m", "nested file"]);
1554 yield* writeTextFile(cwd, "nested/tracked.txt", "changed\n");
1555 yield* writeTextFile(cwd, "untracked.txt", "new\n");
1556 const path = yield* Path.Path;
1557 const driver = yield* GitVcsDriver.GitVcsDriver;
1558 const nestedCwd = path.join(cwd, "nested");
1559 const preview = yield* driver.getReviewDiffPreview({
1560 cwd: nestedCwd,
1561 baseRef: initialBranch,
1562 });
1563 assert.equal(
1564 preview.sources.find((source) => source.kind === "working-tree")!.files!.length,
1565 2,
1566 );
1567 for (const source of preview.sources) {
1568 for (const file of source.files ?? []) {
1569 const scoped = yield* driver.getReviewDiffPreview({
1570 cwd: nestedCwd,
1571 baseRef: initialBranch,
1572 file: { path: file.path, previousPath: file.previousPath, sourceKind: source.kind },
1573 });
1574 const patch = scoped.sources.find((item) => item.kind === source.kind)!;
1575 assert.deepStrictEqual(patch.files, [file]);
1576 assert.include(patch.diff, `b/${file.path}`);
1577 }
1578 }
1579 }),
1580 );
1581
1582 it.effect("reads complete tracked and untracked manifests beyond 1 MB", () =>
1583 Effect.gen(function* () {
1584 const cwd = yield* makeTmpDir();
1585 const { initialBranch } = yield* initRepoWithCommit(cwd);
1586 const mergeBase = yield* git(cwd, ["rev-parse", "HEAD"]);
1587 yield* writeTextFile(cwd, "untracked.txt", "untracked content\n");
1588 const paths = Array.from({ length: 5000 }, (_, index) => `${"a".repeat(220)}-${index}.txt`);
1589 const stats = paths.map((path) => `1\t0\t${path}\0`).join("");
1590 const untracked = [...paths, "untracked.txt"].join("\0") + "\0";
1591 assert.isAbove(stats.length, 1024 * 1024);
1592 assert.isAbove(untracked.length, 1024 * 1024);
1593 let readLargeUntracked = false;
1594 const delegate = yield* ChildProcessSpawner.ChildProcessSpawner;
1595 const spawner = ChildProcessSpawner.make((command) => {
1596 if (ChildProcess.isStandardCommand(command)) {
1597 if (command.args.includes("--numstat") && command.args.includes(mergeBase)) {
1598 return Effect.succeed(makeSuccessfulHandle(stats));
1599 }
1600 if (command.args.includes("ls-files") && command.args.includes("--others")) {
1601 return Effect.succeed(makeSuccessfulHandle(readLargeUntracked ? untracked : ""));
1602 }
1603 }
1604 return delegate.spawn(command);
1605 });
1606 const driver = yield* makeGitVcsDriverCore().pipe(
1607 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
1608 Effect.provide(layerServerConfig),
1609 );
1610 const branch = yield* driver.getReviewDiffPreview({
1611 cwd,
1612 baseRef: initialBranch,
1613 });
1614 const files = branch.sources.find((source) => source.kind === "branch-range")!.files!;
1615 assert.equal(files.length, paths.length);
1616 assert.equal(files.at(-1)?.path, paths.at(-1));
1617 assert.equal(
1618 files.reduce((total, file) => total + file.additions, 0),
1619 paths.length,
1620 );
1621 readLargeUntracked = true;
1622 const dirty = yield* driver.getReviewDiffPreview({
1623 cwd,
1624 file: { path: "untracked.txt", previousPath: null, sourceKind: "working-tree" },
1625 });
1626 const source = dirty.sources.find((source) => source.kind === "working-tree")!;
1627 assert.deepStrictEqual(source.files, [
1628 { path: "untracked.txt", previousPath: null, additions: 1, deletions: 0 },
1629 ]);
1630 assert.include(source.diff, "+untracked content");
1631 }),
1632 );
1633
1634 it.effect("propagates patch failures instead of reporting an empty complete diff", () =>
1635 Effect.gen(function* () {
1636 const cwd = yield* makeTmpDir();
1637 yield* initRepoWithCommit(cwd);
1638 yield* writeTextFile(cwd, "README.md", "changed\n");
1639 const delegate = yield* ChildProcessSpawner.ChildProcessSpawner;
1640 const spawner = ChildProcessSpawner.make((command) =>
1641 ChildProcess.isStandardCommand(command) && command.args.includes("--patch")
1642 ? Effect.succeed(makeNonRepositoryHandle())
1643 : delegate.spawn(command),
1644 );
1645 const driver = yield* makeGitVcsDriverCore().pipe(
1646 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
1647 Effect.provide(layerServerConfig),
1648 );
1649 const result = yield* driver.getReviewDiffPreview({ cwd }).pipe(Effect.result);
1650 assert.isTrue(Result.isFailure(result));
1651 if (Result.isFailure(result)) {
1652 assert.equal(result.failure.operation, "GitVcsDriver.getReviewDiffPreview.patch");
1653 }
1654 }),
1655 );
1656
1657 it.effect("drops an unterminated path from truncated NUL-separated git output", () =>
1658 Effect.sync(() => {
1659 const paths = splitNullSeparatedGitStdoutPaths({
1660 stdout: "complete.txt\0partial",
1661 stdoutTruncated: true,
1662 });
1663
1664 assert.deepStrictEqual(paths, ["complete.txt"]);
1665 }),
1666 );
1667
1668 it.effect("keeps the final path when NUL-separated git output is complete", () =>
1669 Effect.sync(() => {
1670 const paths = splitNullSeparatedGitStdoutPaths({
1671 stdout: "complete.txt\0final.txt",
1672 stdoutTruncated: false,
1673 });
1674
1675 assert.deepStrictEqual(paths, ["complete.txt", "final.txt"]);
1676 }),
1677 );
1678
1679 it.effect("honors whitespace filtering for worktree and branch previews", () =>
1680 Effect.gen(function* () {
1681 const cwd = yield* makeTmpDir();
1682 const { initialBranch } = yield* initRepoWithCommit(cwd);
1683 const driver = yield* GitVcsDriver.GitVcsDriver;
1684 yield* git(cwd, ["checkout", "-b", "feature/whitespace"]);
1685 yield* writeTextFile(cwd, "README.md", "# test\n");
1686 yield* git(cwd, ["add", "README.md"]);
1687 yield* git(cwd, ["commit", "-m", "change whitespace"]);
1688 yield* writeTextFile(cwd, "README.md", "# test\n");
1689
1690 const included = yield* driver.getReviewDiffPreview({
1691 cwd,
1692 baseRef: initialBranch,
1693 ignoreWhitespace: false,
1694 });
1695 const ignored = yield* driver.getReviewDiffPreview({
1696 cwd,
1697 baseRef: initialBranch,
1698 ignoreWhitespace: true,
1699 });
1700
1701 assert.isNotEmpty(included.sources.find((source) => source.kind === "working-tree")?.diff);
1702 assert.isNotEmpty(included.sources.find((source) => source.kind === "branch-range")?.diff);
1703 assert.strictEqual(
1704 ignored.sources.find((source) => source.kind === "working-tree")?.diff,
1705 "",
1706 );
1707 assert.deepStrictEqual(
1708 ignored.sources.find((source) => source.kind === "working-tree")?.files,
1709 [],
1710 );
1711 assert.deepStrictEqual(
1712 ignored.sources.find((source) => source.kind === "branch-range")?.files,
1713 [],
1714 );
1715 assert.strictEqual(
1716 ignored.sources.find((source) => source.kind === "branch-range")?.diff,
1717 "",
1718 );
1719 }),
1720 );
1721
1722 it.effect("keeps a/ and b/ patch prefixes when the repository disables them", () =>
1723 Effect.gen(function* () {
1724 const cwd = yield* makeTmpDir();
1725 const { initialBranch } = yield* initRepoWithCommit(cwd);
1726 const driver = yield* GitVcsDriver.GitVcsDriver;
1727 yield* git(cwd, ["config", "diff.noprefix", "true"]);
1728 yield* git(cwd, ["config", "diff.mnemonicPrefix", "true"]);
1729 yield* git(cwd, ["checkout", "-b", "feature/noprefix"]);
1730 yield* writeTextFile(cwd, "README.md", "# committed change\n");
1731 yield* git(cwd, ["add", "README.md"]);
1732 yield* git(cwd, ["commit", "-m", "committed change"]);
1733 yield* writeTextFile(cwd, "README.md", "# dirty change\n");
1734 yield* writeTextFile(cwd, "untracked.txt", "untracked\n");
1735
1736 const preview = yield* driver.getReviewDiffPreview({
1737 cwd,
1738 baseRef: initialBranch,
1739 ignoreWhitespace: false,
1740 });
1741
1742 const workingTree = preview.sources.find((source) => source.kind === "working-tree")?.diff;
1743 const branchRange = preview.sources.find((source) => source.kind === "branch-range")?.diff;
1744 assert.include(workingTree, "diff --git a/README.md b/README.md");
1745 assert.include(workingTree, "+++ b/untracked.txt");
1746 assert.include(branchRange, "diff --git a/README.md b/README.md");
1747 }),
1748 );
1749
1750 it.effect("keeps untracked filenames with pathspec magic in the review", () =>
1751 Effect.gen(function* () {
1752 const cwd = yield* makeTmpDir();
1753 yield* initRepoWithCommit(cwd);
1754 const driver = yield* GitVcsDriver.GitVcsDriver;
1755 yield* writeTextFile(cwd, ":(exclude)after.ts", "literal pathspec contents\n");
1756 yield* writeTextFile(cwd, "ordinary.ts", "ordinary contents\n");
1757 const indexBefore = yield* git(cwd, ["ls-files", "--stage"]);
1758
1759 const preview = yield* driver.getReviewDiffPreview({ cwd, ignoreWhitespace: false });
1760 const diff = preview.sources.find((source) => source.kind === "working-tree")?.diff ?? "";
1761
1762 assert.include(diff, "+literal pathspec contents");
1763 assert.include(diff, "+ordinary contents");
1764 const scoped = yield* driver.getReviewDiffPreview({
1765 cwd,
1766 file: {
1767 path: ":(exclude)after.ts",
1768 previousPath: null,
1769 sourceKind: "working-tree",
1770 },
1771 });
1772 const scopedSource = scoped.sources.find((source) => source.kind === "working-tree")!;
1773 assert.deepStrictEqual(scopedSource.files, [
1774 { path: ":(exclude)after.ts", previousPath: null, additions: 1, deletions: 0 },
1775 ]);
1776 assert.include(scopedSource.diff, "+literal pathspec contents");
1777 assert.notInclude(scopedSource.diff, "ordinary.ts");
1778 assert.strictEqual(yield* git(cwd, ["ls-files", "--stage"]), indexBefore);
1779 }),
1780 );
1781
1782 it.effect("detects an unstaged rename with edits without mutating a split index", () =>
1783 Effect.gen(function* () {
1784 const cwd = yield* makeTmpDir();
1785 yield* initRepoWithCommit(cwd);
1786 const driver = yield* GitVcsDriver.GitVcsDriver;
1787 const fileSystem = yield* FileSystem.FileSystem;
1788 const pathService = yield* Path.Path;
1789 yield* writeTextFile(cwd, "before.ts", "one\ntwo\nthree\nfour\nfive\n");
1790 yield* git(cwd, ["add", "before.ts"]);
1791 yield* git(cwd, ["commit", "-m", "add source file"]);
1792 yield* git(cwd, ["config", "core.splitIndex", "true"]);
1793 yield* git(cwd, ["config", "splitIndex.sharedIndexExpire", "now"]);
1794 yield* git(cwd, ["update-index", "--split-index"]);
1795 const indexPath = yield* git(cwd, ["rev-parse", "--git-path", "index"]);
1796 const indexHashBefore = yield* git(cwd, ["hash-object", indexPath]);
1797 const gitDirValue = yield* git(cwd, ["rev-parse", "--git-dir"]);
1798 const gitDir = pathService.isAbsolute(gitDirValue)
1799 ? gitDirValue
1800 : pathService.resolve(cwd, gitDirValue);
1801 const sharedIndexesBefore = (yield* fileSystem.readDirectory(gitDir))
1802 .filter((entry) => entry.startsWith("sharedindex."))
1803 .sort();
1804 yield* fileSystem.rename(
1805 pathService.join(cwd, "before.ts"),
1806 pathService.join(cwd, "after.ts"),
1807 );
1808 yield* writeTextFile(cwd, "after.ts", "one\ntwo\nTHREE\nfour\nfive\n");
1809
1810 const preview = yield* driver.getReviewDiffPreview({ cwd, ignoreWhitespace: false });
1811 const diff = preview.sources.find((source) => source.kind === "working-tree")?.diff ?? "";
1812 const indexHashAfter = yield* git(cwd, ["hash-object", indexPath]);
1813 const sharedIndexesAfter = (yield* fileSystem.readDirectory(gitDir))
1814 .filter((entry) => entry.startsWith("sharedindex."))
1815 .sort();
1816
1817 const source = preview.sources.find((candidate) => candidate.kind === "working-tree")!;
1818 assert.deepStrictEqual(source.files, [
1819 { path: "after.ts", previousPath: "before.ts", additions: 1, deletions: 1 },
1820 ]);
1821 const scoped = yield* driver.getReviewDiffPreview({
1822 cwd,
1823 file: { path: "after.ts", previousPath: "before.ts", sourceKind: "working-tree" },
1824 });
1825 const scopedSource = scoped.sources.find((candidate) => candidate.kind === "working-tree")!;
1826 assert.deepStrictEqual(scopedSource.files, source.files);
1827 assert.equal(scopedSource.diff, diff);
1828 assert.include(diff, "rename from before.ts");
1829 assert.include(diff, "rename to after.ts");
1830 assert.include(diff, "-three");
1831 assert.include(diff, "+THREE");
1832 assert.strictEqual(diff.match(/^diff --git /gm)?.length, 1);
1833 assert.strictEqual(indexHashAfter, indexHashBefore);
1834 assert.deepStrictEqual(sharedIndexesAfter, sharedIndexesBefore);
1835 }),
1836 );
1837
1838 it.effect("keeps tracked changes visible when untracked discovery fails", () =>
1839 Effect.gen(function* () {
1840 const delegate = yield* ChildProcessSpawner.ChildProcessSpawner;
1841 const failingLsFilesSpawner = ChildProcessSpawner.make((command) => {
1842 if (!ChildProcess.isStandardCommand(command)) {
1843 return Effect.die("expected a standard Git command");
1844 }
1845 return command.args[0] === "ls-files" && command.args[1] === "--others"
1846 ? Effect.succeed(makeNonRepositoryHandle())
1847 : delegate.spawn(command);
1848 });
1849 const driver = yield* makeGitVcsDriverCore().pipe(
1850 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, failingLsFilesSpawner),
1851 Effect.provide(layerServerConfig),
1852 );
1853 const cwd = yield* makeTmpDir();
1854 yield* initRepoWithCommit(cwd).pipe(
1855 Effect.provideService(GitVcsDriver.GitVcsDriver, driver),
1856 );
1857 yield* writeTextFile(cwd, "README.md", "# tracked change\n");
1858
1859 const preview = yield* driver.getReviewDiffPreview({ cwd, ignoreWhitespace: false });
1860 const diff = preview.sources.find((source) => source.kind === "working-tree")?.diff ?? "";
1861
1862 assert.include(diff, "-# test");
1863 assert.include(diff, "+# tracked change");
1864 }),
1865 );
1866
1867 it.effect("preserves a staged deletion when the removed path still exists", () =>
1868 Effect.gen(function* () {
1869 const cwd = yield* makeTmpDir();
1870 yield* initRepoWithCommit(cwd);
1871 const driver = yield* GitVcsDriver.GitVcsDriver;
1872 yield* writeTextFile(cwd, "removed.txt", "remove me\n");
1873 yield* git(cwd, ["add", "removed.txt"]);
1874 yield* git(cwd, ["commit", "-m", "add removable file"]);
1875 yield* git(cwd, ["rm", "--cached", "removed.txt"]);
1876
1877 const preview = yield* driver.getReviewDiffPreview({ cwd, ignoreWhitespace: false });
1878 const diff = preview.sources.find((source) => source.kind === "working-tree")?.diff ?? "";
1879
1880 assert.include(diff, "deleted file mode");
1881 assert.include(diff, "-remove me");
1882 assert.notInclude(diff, "new file mode");
1883 }),
1884 );
1885
1886 it.effect("keeps untracked files visible before the first commit", () =>
1887 Effect.gen(function* () {
1888 const cwd = yield* makeTmpDir();
1889 const driver = yield* GitVcsDriver.GitVcsDriver;
1890 yield* driver.initRepo({ cwd });
1891 yield* writeTextFile(cwd, "untracked.txt", "visible before HEAD\n");
1892
1893 const preview = yield* driver.getReviewDiffPreview({ cwd, ignoreWhitespace: false });
1894 const source = preview.sources.find((candidate) => candidate.kind === "working-tree");
1895
1896 assert.include(source?.diff, "visible before HEAD");
1897 assert.equal(source?.truncated, false);
1898 }),
1899 );
1900
1901 it.effect.each([false, true])(
1902 "keeps the preceding second cached in review previews (split: %s)",
1903 (splitIndex) =>
1904 Effect.gen(function* () {
1905 const cwd = yield* makeTmpDir();
1906 yield* initRepoWithCommit(cwd);
1907 const driver = yield* GitVcsDriver.GitVcsDriver;
1908 const fs = yield* FileSystem.FileSystem;
1909 const path = yield* Path.Path;
1910 yield* writeTextFile(cwd, ".gitattributes", "stable.txt filter=probe\n");
1911 yield* writeTextFile(cwd, "stable.txt", "unchanged\n");
1912 yield* writeTextFile(
1913 cwd,
1914 ".git/filter.cjs",
1915 'require("node:fs").appendFileSync(".git/filter-runs", "read\\n"); process.stdin.pipe(process.stdout);',
1916 );
1917 yield* git(cwd, ["config", "filter.probe.clean", "node .git/filter.cjs"]);
1918 yield* fs.utimes(path.join(cwd, "stable.txt"), 1_699_999_999.5, 1_699_999_999.5);
1919 yield* git(cwd, ["add", "."]);
1920 yield* git(cwd, ["commit", "-m", "cache stable file"]);
1921 if (splitIndex) yield* git(cwd, ["update-index", "--split-index"]);
1922 const indexPath = path.join(cwd, ".git", "index");
1923 yield* fs.utimes(indexPath, 1_700_000_000, 1_700_000_000);
1924 const originalIndex = yield* fs.readFile(indexPath);
1925 const originalMtime = (yield* fs.stat(indexPath)).mtime;
1926 yield* writeTextFile(cwd, ".git/filter-runs", "");
1927 yield* writeTextFile(cwd, "untracked.txt", "new\n");
1928 const preview = yield* driver.getReviewDiffPreview({ cwd });
1929 assert.deepStrictEqual(
1930 preview.sources.find((source) => source.kind === "working-tree")!.files,
1931 [{ path: "untracked.txt", previousPath: null, additions: 1, deletions: 0 }],
1932 );
1933 assert.strictEqual(yield* fs.readFileString(path.join(cwd, ".git/filter-runs")), "");
1934 assert.deepStrictEqual(yield* fs.readFile(indexPath), originalIndex);
1935 assert.deepStrictEqual((yield* fs.stat(indexPath)).mtime, originalMtime);
1936 }),
1937 );
1938
1939 it.effect.each([
1940 [1_700_000_000, false],
1941 [1_700_000_000.9999, false],
1942 [1_700_000_000, true],
1943 [1_700_000_000.9999, true],
1944 ] as const)(
1945 "preserves same-size edits with a racy review index (%s, split: %s)",
1946 ([timestamp, splitIndex]) =>
1947 Effect.gen(function* () {
1948 const cwd = yield* makeTmpDir();
1949 yield* initRepoWithCommit(cwd);
1950 const driver = yield* GitVcsDriver.GitVcsDriver;
1951 const fileSystem = yield* FileSystem.FileSystem;
1952 const path = yield* Path.Path;
1953 const filePath = path.join(cwd, "tracked.txt");
1954 const indexPath = path.join(cwd, ".git", "index");
1955 // Reproduce a same-timestamp edit without relying on filesystem clock resolution.
1956 yield* git(cwd, ["config", "core.trustctime", "false"]);
1957 yield* writeTextFile(cwd, "tracked.txt", "before\n");
1958 yield* fileSystem.utimes(filePath, timestamp, timestamp);
1959 yield* git(cwd, ["add", "tracked.txt"]);
1960 yield* git(cwd, ["commit", "-m", "record racy file"]);
1961 if (splitIndex) yield* git(cwd, ["update-index", "--split-index"]);
1962 yield* fileSystem.utimes(indexPath, timestamp, timestamp);
1963 const originalIndex = yield* fileSystem.readFile(indexPath);
1964 const originalIndexMtime = (yield* fileSystem.stat(indexPath)).mtime;
1965 yield* writeTextFile(cwd, "tracked.txt", "after!\n");
1966 yield* fileSystem.utimes(filePath, timestamp, timestamp);
1967 yield* writeTextFile(cwd, "untracked.txt", "new\n");
1968
1969 const preview = yield* driver.getReviewDiffPreview({ cwd });
1970 const dirty = preview.sources.find((source) => source.kind === "working-tree")!;
1971 assert.deepStrictEqual(dirty.files, [
1972 { path: "tracked.txt", previousPath: null, additions: 1, deletions: 1 },
1973 { path: "untracked.txt", previousPath: null, additions: 1, deletions: 0 },
1974 ]);
1975 assert.include(dirty.diff, "-before");
1976 assert.include(dirty.diff, "+after!");
1977 assert.deepStrictEqual(yield* fileSystem.readFile(indexPath), originalIndex);
1978 assert.deepStrictEqual((yield* fileSystem.stat(indexPath)).mtime, originalIndexMtime);
1979 }),
1980 );
1981
1982 it.effect("keeps complete stats for files beyond the combined patch limit", () =>
1983 Effect.gen(function* () {
1984 const cwd = yield* makeTmpDir();
1985 const { initialBranch } = yield* initRepoWithCommit(cwd);
1986 const driver = yield* GitVcsDriver.GitVcsDriver;
1987 const largeContents = "a long line of changed content for the diff preview\n".repeat(4000);
1988 yield* git(cwd, ["checkout", "-b", "feature/large"]);
1989 yield* writeTextFile(cwd, "a-large.txt", largeContents);
1990 yield* writeTextFile(cwd, "z-last.txt", "last file\n");
1991 yield* git(cwd, ["add", "."]);
1992 yield* git(cwd, ["commit", "-m", "large change"]);
1993 yield* writeTextFile(cwd, "a-large.txt", largeContents.replaceAll("changed", "updated"));
1994 yield* writeTextFile(cwd, "z-last.txt", "last file updated\n");
1995 yield* writeTextFile(cwd, "untracked.txt", largeContents);
1996
1997 const preview = yield* driver.getReviewDiffPreview({ cwd, baseRef: initialBranch });
1998 const branch = preview.sources.find((source) => source.kind === "branch-range")!;
1999 const dirty = preview.sources.find((source) => source.kind === "working-tree")!;
2000 assert.isTrue(branch.truncated);
2001 assert.isTrue(dirty.truncated);
2002 assert.notInclude(branch.diff, "z-last.txt");
2003 for (const source of [branch, dirty]) {
2004 for (const file of source.files ?? []) {
2005 const individual = yield* driver.getReviewDiffPreview({
2006 cwd,
2007 baseRef: initialBranch,
2008 file: { path: file.path, previousPath: file.previousPath, sourceKind: source.kind },
2009 });
2010 const patch = individual.sources.find((candidate) => candidate.kind === source.kind)!;
2011 assert.isFalse(patch.truncated);
2012 assert.deepStrictEqual(patch.files, [file]);
2013 assert.include(patch.diff, `b/${file.path}`);
2014 assert.isEmpty(
2015 individual.sources.find((candidate) => candidate.kind !== source.kind)!.diff,
2016 );
2017 }
2018 }
2019
2020 assert.deepStrictEqual(branch.files, [
2021 { path: "a-large.txt", previousPath: null, additions: 4000, deletions: 0 },
2022 { path: "untracked.txt", previousPath: null, additions: 4000, deletions: 0 },
2023 { path: "z-last.txt", previousPath: null, additions: 1, deletions: 0 },
2024 ]);
2025 assert.deepStrictEqual(dirty.files, [
2026 { path: "a-large.txt", previousPath: null, additions: 4000, deletions: 4000 },
2027 { path: "untracked.txt", previousPath: null, additions: 4000, deletions: 0 },
2028 { path: "z-last.txt", previousPath: null, additions: 1, deletions: 1 },
2029 ]);
2030 }),
2031 );
2032
2033 it.effect("preserves renames, unusual paths, modes, and binary statistics", () =>
2034 Effect.gen(function* () {
2035 const cwd = yield* makeTmpDir();
2036 const { initialBranch } = yield* initRepoWithCommit(cwd);
2037 const driver = yield* GitVcsDriver.GitVcsDriver;
2038 yield* writeTextFile(cwd, "mode-only.sh", "echo unchanged\n");
2039 yield* git(cwd, ["add", "mode-only.sh"]);
2040 yield* git(cwd, ["commit", "-m", "add executable candidate"]);
2041 yield* git(cwd, ["checkout", "-b", "feature/paths"]);
2042 yield* git(cwd, ["mv", "README.md", "renamed.md"]);
2043 yield* writeTextFile(cwd, "[literal].txt", "literal\n");
2044 yield* writeTextFile(cwd, " leading.txt", "whitespace path\n");
2045 yield* writeTextFile(cwd, "l.txt", "other\n");
2046 yield* writeTextFile(cwd, "binary.dat", "binary\0data");
2047 if ((yield* HostProcess.Platform) !== "win32") {
2048 yield* writeTextFile(cwd, "tab\tand\nnewline.txt", "unusual path\n");
2049 }
2050 yield* git(cwd, ["add", "."]);
2051 yield* git(cwd, ["update-index", "--chmod=+x", "mode-only.sh"]);
2052 yield* git(cwd, ["commit", "-m", "rename and add files"]);
2053 const preview = yield* driver.getReviewDiffPreview({
2054 cwd,
2055 baseRef: initialBranch,
2056 });
2057 const branch = preview.sources.find((source) => source.kind === "branch-range")!;
2058 for (const path of ["renamed.md", "[literal].txt", " leading.txt", "mode-only.sh"]) {
2059 const stat = branch.files!.find((file) => file.path === path)!;
2060 const request = yield* Schema.decodeEffect(ReviewDiffPreviewInput)({
2061 cwd,
2062 baseRef: initialBranch,
2063 file: { path, previousPath: stat.previousPath, sourceKind: "branch-range" },
2064 });
2065 const result = yield* driver.getReviewDiffPreview(request);
2066 const scoped = result.sources.find((source) => source.kind === "branch-range")!;
2067 assert.deepStrictEqual(scoped.files, [stat]);
2068 assert.notInclude(scoped.diff, "b/l.txt");
2069 if (path === "renamed.md") assert.include(scoped.diff, "rename from README.md");
2070 if (path === "mode-only.sh") {
2071 assert.include(scoped.diff, "old mode 100644");
2072 assert.include(scoped.diff, "new mode 100755");
2073 }
2074 }
2075 assert.include(branch.diff, "rename from README.md");
2076 assert.include(branch.diff, "rename to renamed.md");
2077 assert.deepInclude(branch.files ?? [], {
2078 path: "renamed.md",
2079 previousPath: "README.md",
2080 additions: 0,
2081 deletions: 0,
2082 });
2083 assert.deepInclude(branch.files ?? [], {
2084 path: "binary.dat",
2085 previousPath: null,
2086 additions: 0,
2087 deletions: 0,
2088 });
2089 if ((yield* HostProcess.Platform) !== "win32") {
2090 assert.deepInclude(branch.files ?? [], {
2091 path: "tab\tand\nnewline.txt",
2092 previousPath: null,
2093 additions: 1,
2094 deletions: 0,
2095 });
2096 }
2097 }),
2098 );
2099
2100 it.effect("reports staged and untracked changes before the first commit", () =>
2101 Effect.gen(function* () {
2102 const cwd = yield* makeTmpDir();
2103 yield* git(cwd, ["init"]);
2104 yield* writeTextFile(cwd, "staged.txt", "staged\n");
2105 yield* git(cwd, ["add", "staged.txt"]);
2106 yield* writeTextFile(cwd, "untracked.txt", "untracked\n");
2107 const driver = yield* GitVcsDriver.GitVcsDriver;
2108 const preview = yield* driver.getReviewDiffPreview({ cwd });
2109 const dirty = preview.sources.find((source) => source.kind === "working-tree")!;
2110 assert.deepStrictEqual(dirty.files, [
2111 { path: "staged.txt", previousPath: null, additions: 1, deletions: 0 },
2112 { path: "untracked.txt", previousPath: null, additions: 1, deletions: 0 },
2113 ]);
2114 assert.include(dirty.diff, "b/staged.txt");
2115 assert.include(dirty.diff, "b/untracked.txt");
2116 }),
2117 );
2118
2119 it.effect("loads full file contents for working-tree diff expansion", () =>
2120 Effect.gen(function* () {
2121 const cwd = yield* makeTmpDir();
2122 yield* initRepoWithCommit(cwd);
2123 const driver = yield* GitVcsDriver.GitVcsDriver;
2124 const pathService = yield* Path.Path;
2125 yield* writeTextFile(cwd, "nested/.keep", "");
2126 yield* writeTextFile(cwd, "README.md", "# changed\nunchanged context\n");
2127
2128 const contents = yield* driver.getReviewDiffFileContents(
2129 makeReviewDiffFileContentsInput(pathService.join(cwd, "nested")),
2130 );
2131
2132 assert.strictEqual(contents.oldContents, "# test\n");
2133 assert.strictEqual(contents.newContents, "# changed\nunchanged context\n");
2134 }),
2135 );
2136
2137 it.effect("attributes working-tree filesystem failures to the failing operation", () =>
2138 Effect.gen(function* () {
2139 const cwd = yield* makeTmpDir();
2140 yield* initRepoWithCommit(cwd);
2141 const driver = yield* GitVcsDriver.GitVcsDriver;
2142
2143 const error = yield* driver
2144 .getReviewDiffFileContents(
2145 makeReviewDiffFileContentsInput(cwd, {
2146 changeType: "new",
2147 oldPath: "missing.ts",
2148 newPath: "missing.ts",
2149 }),
2150 )
2151 .pipe(Effect.flip);
2152
2153 assert.deepInclude(error, {
2154 _tag: "GitCommandError",
2155 operation: "GitVcsDriver.getReviewDiffFileContents.workingTree.fs.realPath",
2156 command: "fs.realPath",
2157 cwd,
2158 detail: "Could not resolve diff file 'missing.ts'.",
2159 });
2160 }),
2161 );
2162
2163 it.effect("loads new and deleted files without reading their missing side", () =>
2164 Effect.gen(function* () {
2165 const cwd = yield* makeTmpDir();
2166 yield* initRepoWithCommit(cwd);
2167 const driver = yield* GitVcsDriver.GitVcsDriver;
2168 const fileSystem = yield* FileSystem.FileSystem;
2169 const pathService = yield* Path.Path;
2170 yield* writeTextFile(cwd, "added.ts", "export const added = true;\n");
2171 yield* fileSystem.remove(pathService.join(cwd, "README.md"));
2172
2173 const [added, deleted] = yield* Effect.all([
2174 driver.getReviewDiffFileContents(
2175 makeReviewDiffFileContentsInput(cwd, {
2176 changeType: "new",
2177 oldPath: "added.ts",
2178 newPath: "added.ts",
2179 }),
2180 ),
2181 driver.getReviewDiffFileContents(
2182 makeReviewDiffFileContentsInput(cwd, { changeType: "deleted" }),
2183 ),
2184 ]);
2185
2186 assert.deepStrictEqual(added, {
2187 oldContents: "",
2188 newContents: "export const added = true;\n",
2189 });
2190 assert.deepStrictEqual(deleted, {
2191 oldContents: "# test\n",
2192 newContents: "",
2193 });
2194 }),
2195 );
2196
2197 it.effect("loads merge-base and disk contents for Changes expansion", () =>
2198 Effect.gen(function* () {
2199 const cwd = yield* makeTmpDir();
2200 const { initialBranch } = yield* initRepoWithCommit(cwd);
2201 const driver = yield* GitVcsDriver.GitVcsDriver;
2202 yield* git(cwd, ["checkout", "-b", "feature/context"]);
2203 yield* writeTextFile(cwd, "README.md", "# branch change\nunchanged context\n");
2204 yield* git(cwd, ["add", "README.md"]);
2205 yield* git(cwd, ["commit", "-m", "change readme"]);
2206 yield* writeTextFile(cwd, "README.md", "# dirty change\nunchanged context\n");
2207
2208 const contents = yield* driver.getReviewDiffFileContents(
2209 makeReviewDiffFileContentsInput(cwd, {
2210 sourceKind: "branch-range",
2211 baseRef: initialBranch,
2212 headRef: "feature/context",
2213 }),
2214 );
2215
2216 assert.strictEqual(contents.oldContents, "# test\n");
2217 assert.strictEqual(contents.newContents, "# dirty change\nunchanged context\n");
2218 }),
2219 );
2220
2221 it.effect("Changes combines commits, uncommitted edits, and untracked files", () =>
2222 Effect.gen(function* () {
2223 const cwd = yield* makeTmpDir();
2224 const { initialBranch } = yield* initRepoWithCommit(cwd);
2225 const driver = yield* GitVcsDriver.GitVcsDriver;
2226 const sourceFiles = (
2227 preview: ReviewDiffPreviewResult,
2228 kind: "working-tree" | "branch-range",
2229 ) => preview.sources.find((source) => source.kind === kind)!.files;
2230 yield* git(cwd, ["checkout", "-b", "feature/combined"]);
2231 yield* writeTextFile(cwd, "README.md", "# test\ncommitted\n");
2232 yield* git(cwd, ["commit", "-am", "commit edit"]);
2233
2234 // A fully committed branch has no uncommitted work, but Changes still shows it.
2235 const clean = yield* driver.getReviewDiffPreview({ cwd, baseRef: initialBranch });
2236 assert.deepStrictEqual(sourceFiles(clean, "working-tree"), []);
2237 assert.deepStrictEqual(sourceFiles(clean, "branch-range"), [
2238 { path: "README.md", previousPath: null, additions: 1, deletions: 0 },
2239 ]);
2240
2241 yield* writeTextFile(cwd, "README.md", "# test\ncommitted\ndirty\n");
2242 yield* writeTextFile(cwd, "new.txt", "new\n");
2243 const dirty = yield* driver.getReviewDiffPreview({ cwd, baseRef: initialBranch });
2244 const changes = dirty.sources.find((source) => source.kind === "branch-range")!;
2245 assert.deepStrictEqual(changes.files, [
2246 { path: "README.md", previousPath: null, additions: 2, deletions: 0 },
2247 { path: "new.txt", previousPath: null, additions: 1, deletions: 0 },
2248 ]);
2249 assert.strictEqual(changes.diff.match(/^diff --git a\/README\.md /gm)?.length, 1);
2250 assert.include(changes.diff, "+dirty");
2251 assert.deepStrictEqual(sourceFiles(dirty, "working-tree"), [
2252 { path: "README.md", previousPath: null, additions: 1, deletions: 0 },
2253 { path: "new.txt", previousPath: null, additions: 1, deletions: 0 },
2254 ]);
2255 }),
2256 );
2257
2258 it.effect("Changes on the default branch compares with its remote copy", () =>
2259 Effect.gen(function* () {
2260 const cwd = yield* makeTmpDir();
2261 const remote = yield* makeTmpDir();
2262 yield* initRepoWithCommit(cwd);
2263 const driver = yield* GitVcsDriver.GitVcsDriver;
2264 yield* git(cwd, ["branch", "-M", "main"]);
2265 yield* writeTextFile(cwd, "unpushed.txt", "unpushed\n");
2266 yield* git(cwd, ["add", "unpushed.txt"]);
2267 yield* git(cwd, ["commit", "-m", "unpushed"]);
2268
2269 // Without a remote there is no base, so Changes equals Uncommitted.
2270 const local = yield* driver.getReviewDiffPreview({ cwd });
2271 const localChanges = local.sources.find((source) => source.kind === "branch-range")!;
2272 assert.isNull(localChanges.baseRef);
2273 assert.deepStrictEqual(localChanges.files, []);
2274
2275 yield* git(remote, ["init", "--bare"]);
2276 yield* git(cwd, ["remote", "add", "origin", remote]);
2277 yield* git(cwd, ["push", "origin", "HEAD~1:refs/heads/main"]);
2278 yield* git(cwd, ["fetch", "origin"]);
2279 const preview = yield* driver.getReviewDiffPreview({ cwd });
2280 const changes = preview.sources.find((source) => source.kind === "branch-range")!;
2281 assert.strictEqual(changes.baseRef, "origin/main");
2282 assert.deepStrictEqual(changes.files, [
2283 { path: "unpushed.txt", previousPath: null, additions: 1, deletions: 0 },
2284 ]);
2285 }),
2286 );
2287
2288 it.effect("Changes accepts an explicit base on a detached HEAD and rejects a bad one", () =>
2289 Effect.gen(function* () {
2290 const cwd = yield* makeTmpDir();
2291 const { initialBranch } = yield* initRepoWithCommit(cwd);
2292 const driver = yield* GitVcsDriver.GitVcsDriver;
2293 yield* git(cwd, ["checkout", "--detach"]);
2294 yield* writeTextFile(cwd, "detached.txt", "detached\n");
2295 yield* git(cwd, ["add", "detached.txt"]);
2296 yield* git(cwd, ["commit", "-m", "detached work"]);
2297
2298 const explicit = yield* driver.getReviewDiffPreview({ cwd, baseRef: initialBranch });
2299 assert.deepStrictEqual(
2300 explicit.sources.find((source) => source.kind === "branch-range")!.files,
2301 [{ path: "detached.txt", previousPath: null, additions: 1, deletions: 0 }],
2302 );
2303 const implicit = yield* driver.getReviewDiffPreview({ cwd });
2304 assert.deepStrictEqual(
2305 implicit.sources.find((source) => source.kind === "branch-range")!.files,
2306 [],
2307 );
2308 const error = yield* driver
2309 .getReviewDiffPreview({ cwd, baseRef: "missing-base" })
2310 .pipe(Effect.flip);
2311 assert.strictEqual(error.operation, "GitVcsDriver.resolveReviewMergeBase");
2312 }),
2313 );
2314
2315 it.effect("Changes compares with the empty tree before the first commit", () =>
2316 Effect.gen(function* () {
2317 const upstream = yield* makeTmpDir();
2318 yield* initRepoWithCommit(upstream);
2319 yield* git(upstream, ["branch", "-M", "main"]);
2320 const cwd = yield* makeTmpDir();
2321 const driver = yield* GitVcsDriver.GitVcsDriver;
2322 // An unborn main whose remote copy exists must not fail on merge-base.
2323 yield* git(cwd, ["init", "-b", "main"]);
2324 yield* git(cwd, ["remote", "add", "origin", upstream]);
2325 yield* git(cwd, ["fetch", "origin"]);
2326 yield* writeTextFile(cwd, "new.txt", "one\ntwo\n");
2327
2328 const preview = yield* driver.getReviewDiffPreview({ cwd });
2329 const changes = preview.sources.find((source) => source.kind === "branch-range")!;
2330 assert.isNull(changes.baseRef);
2331 assert.deepStrictEqual(changes.files, [
2332 { path: "new.txt", previousPath: null, additions: 2, deletions: 0 },
2333 ]);
2334 const status = yield* driver.statusDetailsLocal(cwd, { includeBranchChanges: true });
2335 assert.deepStrictEqual(status.branchChanges, {
2336 baseRef: null,
2337 insertions: 2,
2338 deletions: 0,
2339 });
2340 }),
2341 );
2342
2343 it.effect("skips Changes totals instead of indexing thousands of untracked files", () =>
2344 Effect.gen(function* () {
2345 const cwd = yield* makeTmpDir();
2346 yield* initRepoWithCommit(cwd);
2347 const driver = yield* GitVcsDriver.GitVcsDriver;
2348 yield* Effect.forEach(
2349 Array.from({ length: 5_001 }, (_, index) => `bulk/${index}.txt`),
2350 (file) => writeTextFile(cwd, file, "x\n"),
2351 { concurrency: 32, discard: true },
2352 );
2353 yield* writeTextFile(cwd, "README.md", "changed\n");
2354
2355 const status = yield* driver.statusDetailsLocal(cwd, { includeBranchChanges: true });
2356 assert.isTrue(status.hasWorkingTreeChanges);
2357 assert.isUndefined(status.branchChanges);
2358
2359 const preview = yield* driver.getReviewDiffPreview({ cwd });
2360 assert.isNotEmpty(preview.sources);
2361 assert.isTrue(preview.sources.every((source) => source.truncated));
2362 }),
2363 );
2364
2365 it.effect("Changes does not show newer base commits as deletions", () =>
2366 Effect.gen(function* () {
2367 const cwd = yield* makeTmpDir();
2368 const { initialBranch } = yield* initRepoWithCommit(cwd);
2369 const driver = yield* GitVcsDriver.GitVcsDriver;
2370 yield* git(cwd, ["checkout", "-b", "feature/rebased"]);
2371 yield* writeTextFile(cwd, "feature.txt", "feature\n");
2372 yield* git(cwd, ["add", "feature.txt"]);
2373 yield* git(cwd, ["commit", "-m", "feature work"]);
2374 yield* git(cwd, ["checkout", initialBranch]);
2375 yield* writeTextFile(cwd, "upstream.txt", "upstream\n");
2376 yield* git(cwd, ["add", "upstream.txt"]);
2377 yield* git(cwd, ["commit", "-m", "base moves"]);
2378 yield* git(cwd, ["checkout", "feature/rebased"]);
2379
2380 for (const rebase of [false, true]) {
2381 if (rebase) yield* git(cwd, ["rebase", initialBranch]);
2382 const preview = yield* driver.getReviewDiffPreview({ cwd, baseRef: initialBranch });
2383 assert.deepStrictEqual(
2384 preview.sources.find((source) => source.kind === "branch-range")!.files,
2385 [{ path: "feature.txt", previousPath: null, additions: 1, deletions: 0 }],
2386 );
2387 }
2388 }),
2389 );
2390 });
2391
2392 describe("repository status", () => {
2393 it.effect("reads Changes totals with untracked files when requested", () =>
2394 Effect.gen(function* () {
2395 const cwd = yield* makeTmpDir();
2396 yield* initRepoWithCommit(cwd);
2397 const driver = yield* GitVcsDriver.GitVcsDriver;
2398 const pathService = yield* Path.Path;
2399 yield* git(cwd, ["branch", "-M", "main"]);
2400 yield* git(cwd, ["checkout", "-b", "feature/totals"]);
2401 yield* writeTextFile(cwd, "README.md", "# test\ncommitted\n");
2402 yield* git(cwd, ["commit", "-am", "commit edit"]);
2403 yield* writeTextFile(cwd, "nested/untracked.txt", "one\ntwo\n");
2404
2405 const status = yield* driver.statusDetailsLocal(pathService.join(cwd, "nested"), {
2406 includeBranchChanges: true,
2407 });
2408 assert.deepStrictEqual(status.branchChanges, {
2409 baseRef: "main",
2410 insertions: 3,
2411 deletions: 0,
2412 });
2413 assert.isUndefined((yield* driver.statusDetailsLocal(cwd)).branchChanges);
2414 }),
2415 );
2416
2417 it.effect("reports non-repository directories without failing", () =>
2418 Effect.gen(function* () {
2419 const cwd = yield* makeTmpDir();
2420 const driver = yield* GitVcsDriver.GitVcsDriver;
2421
2422 const refs = yield* driver.listRefs({ cwd });
2423 assert.equal(refs.isRepo, false);
2424 assert.deepStrictEqual(refs.refs, []);
2425 }),
2426 );
2427
2428 it.effect("reports refName and dirty state for a repository", () =>
2429 Effect.gen(function* () {
2430 const cwd = yield* makeTmpDir();
2431 const { initialBranch } = yield* initRepoWithCommit(cwd);
2432 yield* writeTextFile(cwd, "feature.ts", "export const value = 1;\n");
2433
2434 const status = yield* (yield* GitVcsDriver.GitVcsDriver).statusDetails(cwd);
2435
2436 assert.equal(status.isRepo, true);
2437 assert.equal(status.branch, initialBranch);
2438 assert.equal(status.hasWorkingTreeChanges, true);
2439 assert.include(
2440 status.workingTree.files.map((file) => file.path),
2441 "feature.ts",
2442 );
2443 }),
2444 );
2445
2446 it.effect("reports changes to a file named HEAD", () =>
2447 Effect.gen(function* () {
2448 const cwd = yield* makeTmpDir();
2449 yield* initRepoWithCommit(cwd);
2450 yield* writeTextFile(cwd, "HEAD", "first line\n");
2451 yield* git(cwd, ["add", "HEAD"]);
2452 yield* git(cwd, ["commit", "-m", "add HEAD file"]);
2453 yield* writeTextFile(cwd, "HEAD", "first line\nsecond line\n");
2454
2455 const status = yield* (yield* GitVcsDriver.GitVcsDriver).statusDetails(cwd);
2456
2457 assert.equal(status.isRepo, true);
2458 assert.equal(status.hasWorkingTreeChanges, true);
2459 assert.deepInclude(status.workingTree.files, {
2460 path: "HEAD",
2461 insertions: 1,
2462 deletions: 0,
2463 });
2464 }),
2465 );
2466
2467 it.effect("reports default-branch delta separately from upstream delta", () =>
2468 Effect.gen(function* () {
2469 const cwd = yield* makeTmpDir();
2470 const remote = yield* makeTmpDir("git-vcs-driver-remote-");
2471 const { initialBranch } = yield* initRepoWithCommit(cwd);
2472 yield* git(remote, ["init", "--bare"]);
2473 yield* git(cwd, ["remote", "add", "origin", remote]);
2474 yield* git(cwd, ["push", "-u", "origin", initialBranch]);
2475 yield* git(cwd, ["checkout", "-b", "feature/synced"]);
2476 yield* writeTextFile(cwd, "feature.txt", "feature\n");
2477 yield* git(cwd, ["add", "feature.txt"]);
2478 yield* git(cwd, ["commit", "-m", "feature commit"]);
2479 yield* git(cwd, ["push", "-u", "origin", "feature/synced"]);
2480
2481 const status = yield* (yield* GitVcsDriver.GitVcsDriver).statusDetails(cwd);
2482
2483 assert.equal(status.hasUpstream, true);
2484 assert.equal(status.aheadCount, 0);
2485 assert.equal(status.behindCount, 0);
2486 assert.equal(status.aheadOfDefaultCount, 1);
2487 }),
2488 );
2489
2490 it.effect("can read local files without traversing revision history", () =>
2491 Effect.gen(function* () {
2492 const cwd = yield* makeTmpDir();
2493 const remote = yield* makeTmpDir("git-vcs-driver-remote-");
2494 const { initialBranch } = yield* initRepoWithCommit(cwd);
2495 yield* git(remote, ["init", "--bare"]);
2496 yield* git(cwd, ["remote", "add", "origin", remote]);
2497 yield* git(cwd, ["push", "-u", "origin", initialBranch]);
2498 yield* git(cwd, ["checkout", "-b", "feature/local-only"]);
2499 yield* writeTextFile(cwd, "feature.txt", "feature\n");
2500 yield* git(cwd, ["add", "feature.txt"]);
2501 yield* git(cwd, ["commit", "-m", "feature commit"]);
2502 yield* writeTextFile(cwd, "feature.txt", "feature\nlocal edit\n");
2503 yield* writeTextFile(cwd, "untracked.txt", "untracked\n");
2504
2505 const delegate = yield* ChildProcessSpawner.ChildProcessSpawner;
2506 const commands: ReadonlyArray<string>[] = [];
2507 const spawner = ChildProcessSpawner.make((command) =>
2508 Effect.gen(function* () {
2509 if (!ChildProcess.isStandardCommand(command)) {
2510 return yield* Effect.die("expected a standard Git command");
2511 }
2512 commands.push(command.args);
2513 return yield* delegate.spawn(command);
2514 }),
2515 );
2516 const driver = yield* makeGitVcsDriverCore().pipe(
2517 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
2518 Effect.provide(layerServerConfig),
2519 );
2520
2521 for (const hasUpstream of [false, true]) {
2522 if (hasUpstream) {
2523 yield* git(cwd, ["push", "-u", "origin", "feature/local-only"]);
2524 }
2525 commands.length = 0;
2526 const local = yield* driver.statusDetailsLocal(cwd, { includeDivergence: false });
2527 assert.isFalse(commands.some((args) => args.includes("rev-list")));
2528 assert.isTrue(commands.some((args) => args.includes("--no-ahead-behind")));
2529 assert.equal(local.hasUpstream, hasUpstream);
2530 assert.isTrue(local.hasWorkingTreeChanges);
2531 assert.deepEqual(local.workingTree.files, [
2532 { path: "feature.txt", insertions: 1, deletions: 0 },
2533 { path: "untracked.txt", insertions: 0, deletions: 0 },
2534 ]);
2535
2536 commands.length = 0;
2537 const full = yield* driver.statusDetailsLocal(cwd);
2538 assert.isTrue(commands.some((args) => args.includes("rev-list")));
2539 assert.equal(full.aheadCount, hasUpstream ? 0 : 1);
2540 assert.equal(full.aheadOfDefaultCount, 1);
2541 assert.deepEqual(local, {
2542 ...full,
2543 aheadCount: 0,
2544 behindCount: 0,
2545 aheadOfDefaultCount: 0,
2546 });
2547 }
2548 }),
2549 );
2550
2551 it.effect("reports remote divergence without reading working-tree details", () =>
2552 Effect.gen(function* () {
2553 const cwd = yield* makeTmpDir();
2554 const remote = yield* makeTmpDir("git-vcs-driver-remote-");
2555 const { initialBranch } = yield* initRepoWithCommit(cwd);
2556 yield* git(remote, ["init", "--bare"]);
2557 yield* git(cwd, ["remote", "add", "origin", remote]);
2558 yield* git(cwd, ["push", "-u", "origin", initialBranch]);
2559 yield* git(cwd, ["checkout", "-b", "feature/remote-status"]);
2560 yield* writeTextFile(cwd, "feature.txt", "feature\n");
2561 yield* git(cwd, ["add", "feature.txt"]);
2562 yield* git(cwd, ["commit", "-m", "feature commit"]);
2563 yield* git(cwd, ["push", "-u", "origin", "feature/remote-status"]);
2564 yield* writeTextFile(cwd, "untracked.txt", "local-only\n");
2565
2566 const status = yield* (yield* GitVcsDriver.GitVcsDriver).statusDetailsRemote(cwd);
2567
2568 assert.equal(status.isRepo, true);
2569 assert.equal(status.branch, "feature/remote-status");
2570 assert.equal(status.hasUpstream, true);
2571 assert.equal(status.aheadCount, 0);
2572 assert.equal(status.behindCount, 0);
2573 assert.equal(status.aheadOfDefaultCount, 1);
2574 assert.notProperty(status, "workingTree");
2575 assert.notProperty(status, "hasWorkingTreeChanges");
2576 }),
2577 );
2578
2579 it.effect("reports remote status on unborn HEAD without failing", () =>
2580 Effect.gen(function* () {
2581 const cwd = yield* makeTmpDir();
2582 const driver = yield* GitVcsDriver.GitVcsDriver;
2583 yield* driver.initRepo({ cwd });
2584 const initialBranch = yield* git(cwd, ["symbolic-ref", "--short", "HEAD"]);
2585
2586 const status = yield* driver.statusDetailsRemote(cwd, { refreshUpstream: false });
2587
2588 assert.equal(status.isRepo, true);
2589 assert.equal(status.branch, initialBranch);
2590 assert.equal(status.hasUpstream, false);
2591 assert.equal(status.aheadCount, 0);
2592 assert.equal(status.behindCount, 0);
2593 }),
2594 );
2595
2596 it.effect("can read cached remote divergence without fetching upstream", () =>
2597 Effect.gen(function* () {
2598 const cwd = yield* makeTmpDir();
2599 const remote = yield* makeTmpDir("git-vcs-driver-remote-");
2600 const updater = yield* makeTmpDir("git-vcs-driver-updater-");
2601 const { initialBranch } = yield* initRepoWithCommit(cwd);
2602 yield* git(remote, ["init", "--bare"]);
2603 yield* git(cwd, ["remote", "add", "origin", remote]);
2604 yield* git(cwd, ["push", "-u", "origin", initialBranch]);
2605
2606 yield* git(updater, ["clone", remote, "."]);
2607 yield* git(updater, ["config", "user.email", "test@test.com"]);
2608 yield* git(updater, ["config", "user.name", "Test"]);
2609 yield* writeTextFile(updater, "remote.txt", "remote\n");
2610 yield* git(updater, ["add", "remote.txt"]);
2611 yield* git(updater, ["commit", "-m", "remote commit"]);
2612 yield* git(updater, ["push", "origin", initialBranch]);
2613
2614 const driver = yield* GitVcsDriver.GitVcsDriver;
2615 const cachedStatus = yield* driver.statusDetailsRemote(cwd, {
2616 refreshUpstream: false,
2617 });
2618 const refreshedStatus = yield* driver.statusDetailsRemote(cwd);
2619
2620 assert.equal(cachedStatus.behindCount, 0);
2621 assert.equal(refreshedStatus.behindCount, 1);
2622 }),
2623 );
2624
2625 it.effect("does not start Git auto-maintenance from background upstream fetches", () =>
2626 Effect.gen(function* () {
2627 const cwd = yield* makeTmpDir();
2628 const remote = yield* makeTmpDir("git-vcs-driver-remote-");
2629 const { initialBranch } = yield* initRepoWithCommit(cwd);
2630 yield* git(remote, ["init", "--bare"]);
2631 yield* git(cwd, ["remote", "add", "origin", remote]);
2632 yield* git(cwd, ["push", "-u", "origin", initialBranch]);
2633 yield* git(cwd, ["repack", "-d"]);
2634 yield* writeTextFile(cwd, "second.txt", "second\n");
2635 yield* git(cwd, ["add", "second.txt"]);
2636 yield* git(cwd, ["commit", "-m", "second commit"]);
2637 yield* git(cwd, ["push"]);
2638 yield* git(cwd, ["repack", "-d"]);
2639 // Two packs make `git gc --auto` due, and without detaching it would run inside the fetch.
2640 yield* git(cwd, ["config", "gc.autoPackLimit", "1"]);
2641 yield* git(cwd, ["config", "gc.autoDetach", "false"]);
2642 yield* git(cwd, ["config", "maintenance.autoDetach", "false"]);
2643 const packCount = git(cwd, ["count-objects", "-v"]).pipe(
2644 Effect.map((stdout) => stdout.match(/^packs: (\d+)$/m)?.[1]),
2645 );
2646 assert.equal(yield* packCount, "2");
2647
2648 yield* (yield* GitVcsDriver.GitVcsDriver).statusDetailsRemote(cwd);
2649
2650 assert.equal(yield* packCount, "2");
2651 }),
2652 );
2653
2654 it.effect("uses origin HEAD for default-branch detection with a non-origin upstream", () =>
2655 Effect.gen(function* () {
2656 const cwd = yield* makeTmpDir();
2657 const origin = yield* makeTmpDir("git-vcs-driver-origin-");
2658 const upstream = yield* makeTmpDir("git-vcs-driver-upstream-");
2659 yield* initRepoWithCommit(cwd);
2660 yield* git(origin, ["init", "--bare"]);
2661 yield* git(upstream, ["init", "--bare"]);
2662 yield* git(cwd, ["branch", "-M", "main"]);
2663 yield* git(cwd, ["remote", "add", "origin", origin]);
2664 yield* git(cwd, ["remote", "add", "upstream", upstream]);
2665 yield* git(cwd, ["push", "origin", "main"]);
2666 yield* git(cwd, ["push", "upstream", "main"]);
2667 yield* git(cwd, ["symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/main"]);
2668 yield* git(cwd, ["checkout", "-b", "release"]);
2669 yield* writeTextFile(cwd, "release.txt", "release\n");
2670 yield* git(cwd, ["add", "release.txt"]);
2671 yield* git(cwd, ["commit", "-m", "release commit"]);
2672 yield* git(cwd, ["push", "-u", "upstream", "release"]);
2673 yield* git(cwd, [
2674 "symbolic-ref",
2675 "refs/remotes/upstream/HEAD",
2676 "refs/remotes/upstream/release",
2677 ]);
2678
2679 const status = yield* (yield* GitVcsDriver.GitVcsDriver).statusDetailsRemote(cwd);
2680
2681 assert.equal(status.branch, "release");
2682 assert.equal(status.upstreamRef, "upstream/release");
2683 assert.equal(status.isDefaultBranch, false);
2684 }),
2685 );
2686
2687 it.effect("makes background upstream status fetches non-interactive", () =>
2688 Effect.gen(function* () {
2689 const cwd = yield* makeTmpDir();
2690 const tempDir = yield* makeTmpDir("git-vcs-driver-ssh-env-");
2691 const { initialBranch } = yield* initRepoWithCommit(cwd);
2692 const fileSystem = yield* FileSystem.FileSystem;
2693 const pathService = yield* Path.Path;
2694 const sshLogPath = pathService.join(tempDir, "ssh-env.txt");
2695 const sshWrapperPath = yield* pathService.fromFileUrl(
2696 new URL("./testing/GitVcsDriverCore.fixture.sh", import.meta.url),
2697 );
2698 const envKeys = [
2699 "GCM_INTERACTIVE",
2700 "GIT_ASKPASS",
2701 "GIT_SSH",
2702 "GIT_TERMINAL_PROMPT",
2703 "SSH_ASKPASS",
2704 "SSH_ASKPASS_REQUIRE",
2705 "T3_TEST_SSH_ASKPASS_LOG",
2706 ] as const;
2707 const previousEnv = new Map(envKeys.map((key) => [key, process.env[key]]));
2708
2709 yield* git(cwd, ["remote", "add", "origin", "ssh://example.invalid/repo.git"]);
2710 yield* git(cwd, ["update-ref", `refs/remotes/origin/${initialBranch}`, "HEAD"]);
2711 yield* git(cwd, ["branch", "--set-upstream-to", `origin/${initialBranch}`]);
2712
2713 yield* Effect.gen(function* () {
2714 process.env.GIT_SSH = sshWrapperPath;
2715 process.env.GCM_INTERACTIVE = "always";
2716 process.env.GIT_ASKPASS = "git-askpass";
2717 process.env.GIT_TERMINAL_PROMPT = "1";
2718 process.env.SSH_ASKPASS = "ssh-askpass";
2719 process.env.SSH_ASKPASS_REQUIRE = "force";
2720 process.env.T3_TEST_SSH_ASKPASS_LOG = sshLogPath;
2721
2722 yield* (yield* GitVcsDriver.GitVcsDriver).statusDetails(cwd);
2723
2724 assert.deepEqual((yield* fileSystem.readFileString(sshLogPath)).trim().split(/\r?\n/), [
2725 "GCM_INTERACTIVE=never",
2726 "GIT_ASKPASS=",
2727 "GIT_TERMINAL_PROMPT=0",
2728 "SSH_ASKPASS=",
2729 "SSH_ASKPASS_REQUIRE=never",
2730 ]);
2731 }).pipe(
2732 Effect.ensuring(
2733 Effect.sync(() => {
2734 for (const key of envKeys) {
2735 const previous = previousEnv.get(key);
2736 if (previous === undefined) {
2737 delete process.env[key];
2738 } else {
2739 process.env[key] = previous;
2740 }
2741 }
2742 }),
2743 ),
2744 );
2745 }),
2746 );
2747
2748 it.effect("reuses the no-upstream fallback ahead count for default-branch delta", () =>
2749 Effect.gen(function* () {
2750 const cwd = yield* makeTmpDir();
2751 const remote = yield* makeTmpDir("git-vcs-driver-remote-");
2752 const { initialBranch } = yield* initRepoWithCommit(cwd);
2753 yield* git(remote, ["init", "--bare"]);
2754 yield* git(cwd, ["remote", "add", "origin", remote]);
2755 yield* git(cwd, ["push", "-u", "origin", initialBranch]);
2756 yield* git(cwd, ["checkout", "-b", "feature/no-upstream"]);
2757 yield* writeTextFile(cwd, "feature.txt", "feature\n");
2758 yield* git(cwd, ["add", "feature.txt"]);
2759 yield* git(cwd, ["commit", "-m", "feature commit"]);
2760
2761 const status = yield* (yield* GitVcsDriver.GitVcsDriver).statusDetails(cwd);
2762
2763 assert.equal(status.hasUpstream, false);
2764 assert.equal(status.aheadCount, 1);
2765 assert.equal(status.behindCount, 0);
2766 assert.equal(status.aheadOfDefaultCount, 1);
2767 }),
2768 );
2769
2770 it.effect("reports combined staged and unstaged edits to the same file", () =>
2771 Effect.gen(function* () {
2772 const cwd = yield* makeTmpDir();
2773 yield* initRepoWithCommit(cwd);
2774 yield* writeTextFile(cwd, "feature.ts", "// line one\n");
2775 yield* git(cwd, ["add", "feature.ts"]);
2776 yield* git(cwd, ["commit", "-m", "add feature"]);
2777 yield* writeTextFile(cwd, "feature.ts", "// line one\n// line two\n");
2778 yield* git(cwd, ["add", "feature.ts"]);
2779 yield* writeTextFile(cwd, "feature.ts", "// line one\n// line two\n// line three\n");
2780
2781 const status = yield* (yield* GitVcsDriver.GitVcsDriver).statusDetails(cwd);
2782
2783 assert.equal(status.isRepo, true);
2784 assert.equal(status.hasWorkingTreeChanges, true);
2785 const file = status.workingTree.files.find((f) => f.path === "feature.ts");
2786 assert.ok(file);
2787 // HEAD has 1 line. Staged has 2 lines (+1). Unstaged has 3 lines (+2 from HEAD).
2788 // Combined net from HEAD: +2 insertions.
2789 assert.equal(file.insertions, 2);
2790 assert.equal(file.deletions, 0);
2791 }),
2792 );
2793
2794 it.effect("reports staged file counts on unborn HEAD without failing", () =>
2795 Effect.gen(function* () {
2796 const cwd = yield* makeTmpDir();
2797 const driver = yield* GitVcsDriver.GitVcsDriver;
2798 yield* driver.initRepo({ cwd });
2799 yield* git(cwd, ["config", "user.email", "test@test.com"]);
2800 yield* git(cwd, ["config", "user.name", "Test"]);
2801 yield* writeTextFile(cwd, "initial.ts", "// first file\n");
2802 yield* git(cwd, ["add", "initial.ts"]);
2803
2804 const status = yield* driver.statusDetails(cwd);
2805
2806 assert.equal(status.isRepo, true);
2807 assert.equal(status.workingTree.files.length, 1);
2808 const file = status.workingTree.files[0];
2809 if (file) {
2810 assert.equal(file.path, "initial.ts");
2811 assert.equal(file.insertions, 1);
2812 }
2813 }),
2814 );
2815 });
2816
2817 describe("refName operations", () => {
2818 it.effect("optionally includes remote refs that match local branches", () =>
2819 Effect.gen(function* () {
2820 const cwd = yield* makeTmpDir();
2821 const remote = yield* makeTmpDir("git-vcs-driver-remote-");
2822 const { initialBranch } = yield* initRepoWithCommit(cwd);
2823 yield* git(remote, ["init", "--bare"]);
2824 yield* git(cwd, ["remote", "add", "origin", remote]);
2825 yield* git(cwd, ["push", "-u", "origin", initialBranch]);
2826 const driver = yield* GitVcsDriver.GitVcsDriver;
2827
2828 const deduplicated = yield* driver.listRefs({ cwd });
2829 assert.equal(
2830 deduplicated.refs.some((ref) => ref.name === `origin/${initialBranch}`),
2831 false,
2832 );
2833
2834 const complete = yield* driver.listRefs({ cwd, includeMatchingRemoteRefs: true });
2835 assert.equal(
2836 complete.refs.some((ref) => ref.name === initialBranch),
2837 true,
2838 );
2839 assert.equal(
2840 complete.refs.some((ref) => ref.name === `origin/${initialBranch}`),
2841 true,
2842 );
2843
2844 const remoteOnly = yield* driver.listRefs({
2845 cwd,
2846 includeMatchingRemoteRefs: true,
2847 refKind: "remote",
2848 limit: 1,
2849 });
2850 assert.equal(remoteOnly.refs.length, 1);
2851 assert.equal(remoteOnly.refs[0]?.name, `origin/${initialBranch}`);
2852 assert.equal(remoteOnly.refs[0]?.isRemote, true);
2853 }),
2854 );
2855
2856 it.effect("marks the origin default ref as default when no local copy exists", () =>
2857 Effect.gen(function* () {
2858 const cwd = yield* makeTmpDir();
2859 const remote = yield* makeTmpDir("git-vcs-driver-remote-");
2860 const { initialBranch } = yield* initRepoWithCommit(cwd);
2861 yield* git(remote, ["init", "--bare"]);
2862 yield* git(cwd, ["remote", "add", "origin", remote]);
2863 yield* git(cwd, ["push", "-u", "origin", initialBranch]);
2864 yield* git(cwd, ["remote", "set-head", "origin", initialBranch]);
2865 yield* git(cwd, ["checkout", "-b", "feature/only-local"]);
2866 yield* git(cwd, ["branch", "-D", initialBranch]);
2867 const driver = yield* GitVcsDriver.GitVcsDriver;
2868
2869 const refs = yield* driver.listRefs({ cwd });
2870 const remoteDefault = refs.refs.find((ref) => ref.name === `origin/${initialBranch}`);
2871 assert.equal(remoteDefault?.isRemote, true);
2872 assert.equal(remoteDefault?.isDefault, true);
2873 }),
2874 );
2875
2876 it.effect("creates, checks out, renames, and lists refs", () =>
2877 Effect.gen(function* () {
2878 const cwd = yield* makeTmpDir();
2879 yield* initRepoWithCommit(cwd);
2880 const driver = yield* GitVcsDriver.GitVcsDriver;
2881
2882 yield* driver.createRef({ cwd, refName: "feature/original" });
2883 const switchRef = yield* driver.switchRef({ cwd, refName: "feature/original" });
2884 assert.equal(switchRef.refName, "feature/original");
2885
2886 const renamed = yield* driver.renameBranch({
2887 cwd,
2888 oldBranch: "feature/original",
2889 newBranch: "feature/renamed",
2890 });
2891 assert.equal(renamed.branch, "feature/renamed");
2892 assert.equal(yield* git(cwd, ["branch", "--show-current"]), "feature/renamed");
2893
2894 const refs = yield* driver.listRefs({ cwd });
2895 assert.equal(
2896 refs.refs.find((refName) => refName.name === "feature/renamed")?.current,
2897 true,
2898 );
2899 }),
2900 );
2901
2902 it.effect("preserves exact custom names and refuses collisions without a suffix", () =>
2903 Effect.gen(function* () {
2904 const cwd = yield* makeTmpDir();
2905 const { initialBranch } = yield* initRepoWithCommit(cwd);
2906 const driver = yield* GitVcsDriver.GitVcsDriver;
2907 yield* driver.createRef({ cwd, refName: "Julius/ABC-123" });
2908 const failed = yield* driver
2909 .renameBranch({
2910 cwd,
2911 oldBranch: initialBranch,
2912 newBranch: "Julius/ABC-123",
2913 exactName: true,
2914 })
2915 .pipe(Effect.exit);
2916 assert.equal(failed._tag, "Failure");
2917 assert.equal(yield* git(cwd, ["branch", "--show-current"]), initialBranch);
2918 const renamed = yield* driver.renameBranch({
2919 cwd,
2920 oldBranch: initialBranch,
2921 newBranch: "Julius/ABC-124.v2",
2922 exactName: true,
2923 });
2924 assert.equal(renamed.branch, "Julius/ABC-124.v2");
2925 }),
2926 );
2927
2928 it.effect("returns the existing refName when rename source and target match", () =>
2929 Effect.gen(function* () {
2930 const cwd = yield* makeTmpDir();
2931 yield* initRepoWithCommit(cwd);
2932 const driver = yield* GitVcsDriver.GitVcsDriver;
2933
2934 const current = yield* git(cwd, ["branch", "--show-current"]);
2935 const result = yield* driver.renameBranch({
2936 cwd,
2937 oldBranch: current,
2938 newBranch: current,
2939 });
2940
2941 assert.equal(result.branch, current);
2942 }),
2943 );
2944
2945 it.effect("rejects a missing branch without restoring a matching dirty file", () =>
2946 Effect.gen(function* () {
2947 const cwd = yield* makeTmpDir();
2948 const { initialBranch } = yield* initRepoWithCommit(cwd);
2949 const driver = yield* GitVcsDriver.GitVcsDriver;
2950 yield* writeTextFile(cwd, "obsolete-branch", "original\n");
2951 yield* git(cwd, ["add", "obsolete-branch"]);
2952 yield* git(cwd, ["commit", "-m", "tracked file"]);
2953 yield* git(cwd, ["branch", "obsolete-branch"]);
2954 yield* git(cwd, ["branch", "-D", "obsolete-branch"]);
2955 yield* writeTextFile(cwd, "obsolete-branch", "uncommitted work\n");
2956
2957 const result = yield* driver
2958 .switchRef({ cwd, refName: "obsolete-branch" })
2959 .pipe(Effect.result);
2960
2961 assert.equal(result._tag, "Failure");
2962 const fileSystem = yield* FileSystem.FileSystem;
2963 const path = yield* Path.Path;
2964 assert.equal(
2965 yield* fileSystem.readFileString(path.join(cwd, "obsolete-branch")),
2966 "uncommitted work\n",
2967 );
2968 assert.equal(yield* git(cwd, ["branch", "--show-current"]), initialBranch);
2969 }),
2970 );
2971
2972 it.effect("still creates and reuses remote tracking branches and allows detached refs", () =>
2973 Effect.gen(function* () {
2974 const cwd = yield* makeTmpDir();
2975 const remote = yield* makeTmpDir("git-remote-");
2976 const { initialBranch } = yield* initRepoWithCommit(cwd);
2977 const driver = yield* GitVcsDriver.GitVcsDriver;
2978 yield* git(remote, ["init", "--bare"]);
2979 yield* git(cwd, ["remote", "add", "origin", remote]);
2980 yield* git(cwd, ["push", "origin", "HEAD:refs/heads/remote-only"]);
2981
2982 for (let attempt = 0; attempt < 2; attempt += 1) {
2983 const result = yield* driver.switchRef({ cwd, refName: "origin/remote-only" });
2984 assert.equal(result.refName, "remote-only");
2985 assert.equal(
2986 yield* git(cwd, ["rev-parse", "--abbrev-ref", "@{upstream}"]),
2987 "origin/remote-only",
2988 );
2989 yield* driver.switchRef({ cwd, refName: initialBranch });
2990 }
2991 const commit = yield* git(cwd, ["rev-parse", "HEAD"]);
2992 const detached = yield* driver.switchRef({ cwd, refName: commit });
2993 assert.equal(detached.refName, null);
2994 assert.equal(yield* git(cwd, ["rev-parse", "HEAD"]), commit);
2995 }),
2996 );
2997 });
2998
2999 describe("worktree operations", () => {
3000 it.effect("uses parallel checkout without skipping filters or hooks", () =>
3001 Effect.gen(function* () {
3002 const cwd = yield* makeTmpDir();
3003 const { initialBranch } = yield* initRepoWithCommit(cwd);
3004 const fs = yield* FileSystem.FileSystem;
3005 const path = yield* Path.Path;
3006 const driver = yield* GitVcsDriver.GitVcsDriver;
3007 yield* git(cwd, ["config", "filter.test.smudge", "sed s/original/filtered/g"]);
3008 yield* writeTextFile(cwd, ".gitattributes", "asset.txt filter=test\n");
3009 yield* writeTextFile(cwd, "asset.txt", "original\n");
3010 yield* git(cwd, ["add", "."]);
3011 yield* git(cwd, ["commit", "-m", "filtered asset"]);
3012 yield* writeTextFile(
3013 cwd,
3014 ".git/hooks/post-checkout",
3015 "#!/bin/sh\ngit config checkout.workers > checkout-workers\nexit 0\n",
3016 );
3017 yield* fs.chmod(path.join(cwd, ".git/hooks/post-checkout"), 0o755);
3018 const worktreePath = path.join(yield* makeTmpDir("git-worktrees-"), "parallel");
3019
3020 yield* driver.createWorktree({
3021 cwd,
3022 path: worktreePath,
3023 refName: initialBranch,
3024 newRefName: "feature/parallel",
3025 baseRefName: initialBranch,
3026 });
3027
3028 assert.notInclude(yield* git(cwd, ["worktree", "list", "--porcelain"]), "locked");
3029 assert.equal(yield* fs.readFileString(path.join(worktreePath, "checkout-workers")), "0\n");
3030 assert.equal(yield* fs.readFileString(path.join(worktreePath, "asset.txt")), "filtered\n");
3031 assert.equal(
3032 yield* git(worktreePath, ["rev-parse", "HEAD"]),
3033 yield* git(cwd, ["rev-parse", "HEAD"]),
3034 );
3035 assert.equal(
3036 yield* git(cwd, ["config", "branch.feature/parallel.gh-merge-base"]),
3037 initialBranch,
3038 );
3039 for (const [configured, expected] of [
3040 ["1", "1"],
3041 ["", "0"],
3042 ] as const) {
3043 yield* git(cwd, ["config", "checkout.workers", configured]);
3044 const configuredPath = path.join(yield* makeTmpDir("git-worktrees-"), "configured");
3045 yield* driver.createWorktree({
3046 cwd,
3047 path: configuredPath,
3048 refName: initialBranch,
3049 newRefName: `feature/configured-${expected}`,
3050 });
3051 assert.equal(
3052 yield* fs.readFileString(path.join(configuredPath, "checkout-workers")),
3053 `${expected}\n`,
3054 );
3055 }
3056 }),
3057 );
3058 it.effect.each(["commit", "short-commit", "tag", "revision"] as const)(
3059 "creates a worktree from a %s without recording it as a PR base",
3060 (kind) =>
3061 Effect.gen(function* () {
3062 const cwd = yield* makeTmpDir();
3063 yield* initRepoWithCommit(cwd);
3064 const commit = yield* git(cwd, ["rev-parse", "HEAD"]);
3065 yield* git(cwd, ["tag", "v1"]);
3066 const baseRef =
3067 kind === "commit"
3068 ? commit
3069 : kind === "short-commit"
3070 ? commit.slice(0, 12)
3071 : kind === "tag"
3072 ? "v1"
3073 : "HEAD~0";
3074 const path = yield* Path.Path;
3075 const worktreePath = path.join(yield* makeTmpDir("git-worktrees-"), "revision");
3076 const driver = yield* GitVcsDriver.GitVcsDriver;
3077 yield* driver.createWorktree({
3078 cwd,
3079 path: worktreePath,
3080 refName: baseRef,
3081 newRefName: "feature/revision",
3082 baseRefName: baseRef,
3083 });
3084
3085 assert.equal(yield* git(worktreePath, ["rev-parse", "HEAD"]), commit);
3086 assert.equal(
3087 yield* driver.readConfigValue(cwd, "branch.feature/revision.gh-merge-base"),
3088 null,
3089 );
3090 }),
3091 );
3092
3093 it.effect.each(["release/v2", "refs/heads/release/v2", "deadbeef"])(
3094 "records the branch name when creating a worktree from %s",
3095 (baseRef) =>
3096 Effect.gen(function* () {
3097 const cwd = yield* makeTmpDir();
3098 yield* initRepoWithCommit(cwd);
3099 const baseBranch = baseRef.replace(/^refs\/heads\//, "");
3100 yield* git(cwd, ["branch", baseBranch]);
3101 const path = yield* Path.Path;
3102 const driver = yield* GitVcsDriver.GitVcsDriver;
3103 yield* driver.createWorktree({
3104 cwd,
3105 path: path.join(yield* makeTmpDir("git-worktrees-"), "branch"),
3106 refName: baseRef,
3107 newRefName: "feature/branch",
3108 baseRefName: baseRef,
3109 });
3110
3111 assert.equal(
3112 yield* driver.readConfigValue(cwd, "branch.feature/branch.gh-merge-base"),
3113 baseBranch,
3114 );
3115 }),
3116 );
3117
3118 it.effect.each(["release/v2", "origin/release/v2", "refs/remotes/origin/release/v2"])(
3119 "records a remote-only PR base when creating a worktree from %s",
3120 (baseRef) =>
3121 Effect.gen(function* () {
3122 const cwd = yield* makeTmpDir();
3123 yield* initRepoWithCommit(cwd);
3124 const remote = yield* makeTmpDir("git-remote-");
3125 yield* git(remote, ["init", "--bare"]);
3126 yield* git(cwd, ["remote", "add", "origin", remote]);
3127 yield* git(cwd, ["push", "origin", "HEAD:refs/heads/release/v2"]);
3128 const commit = yield* git(cwd, ["rev-parse", "origin/release/v2"]);
3129 const path = yield* Path.Path;
3130 const driver = yield* GitVcsDriver.GitVcsDriver;
3131 yield* driver.createWorktree({
3132 cwd,
3133 path: path.join(yield* makeTmpDir("git-worktrees-"), "remote"),
3134 refName: commit,
3135 newRefName: "feature/remote",
3136 baseRefName: baseRef,
3137 });
3138
3139 assert.equal(
3140 yield* driver.readConfigValue(cwd, "branch.feature/remote.gh-merge-base"),
3141 "release/v2",
3142 );
3143 }),
3144 );
3145
3146 it("parses checkout progress lines from git's stderr", () => {
3147 assert.deepStrictEqual(parseGitCheckoutProgressLine("Updating files: 78% (2104/2700)"), {
3148 percent: 78,
3149 completed: 2104,
3150 total: 2700,
3151 });
3152 // Progress lines arrive carriage-return separated and end with a done marker.
3153 assert.deepStrictEqual(
3154 parseGitCheckoutProgressLine("Updating files: 100% (2700/2700), done."),
3155 { percent: 100, completed: 2700, total: 2700 },
3156 );
3157 assert.strictEqual(parseGitCheckoutProgressLine("Preparing worktree (new branch 'x')"), null);
3158 });
3159
3160 // NTFS rejects a newline in a file name, so there is nothing to preserve there.
3161 it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")(
3162 "preserves newline characters in worktree paths when listing refs",
3163 () =>
3164 Effect.gen(function* () {
3165 const cwd = yield* makeTmpDir();
3166 yield* initRepoWithCommit(cwd);
3167 const worktreesRoot = yield* makeTmpDir("git-vcs-driver-worktrees-");
3168 const pathService = yield* Path.Path;
3169 const worktreePath = pathService.join(worktreesRoot, "linked\nworktree");
3170 const driver = yield* GitVcsDriver.GitVcsDriver;
3171
3172 yield* git(cwd, ["worktree", "add", "-b", "feature/newline-path", worktreePath]);
3173
3174 const refs = yield* driver.listRefs({ cwd, refresh: true });
3175 const listedPath = refs.refs.find(
3176 (ref) => ref.name === "feature/newline-path",
3177 )?.worktreePath;
3178
3179 if (typeof listedPath !== "string") {
3180 return assert.fail("expected the linked branch to include its worktree path");
3181 }
3182 assert.equal(
3183 NodeFS.realpathSync.native(listedPath),
3184 NodeFS.realpathSync.native(worktreePath),
3185 );
3186 }),
3187 );
3188
3189 it.effect("checks out submodules in a new worktree", () =>
3190 Effect.gen(function* () {
3191 const fileSystem = yield* FileSystem.FileSystem;
3192 const pathService = yield* Path.Path;
3193
3194 // Git refuses `file:` submodule transports by default (CVE-2022-39253)
3195 // and ignores repo-level config for it, so a local fixture needs the
3196 // env allowance. Real submodules are https/ssh and need none of this.
3197 const previousAllowedProtocol = process.env.GIT_ALLOW_PROTOCOL;
3198 process.env.GIT_ALLOW_PROTOCOL = "file";
3199 yield* Effect.addFinalizer(() =>
3200 Effect.sync(() => {
3201 if (previousAllowedProtocol === undefined) {
3202 delete process.env.GIT_ALLOW_PROTOCOL;
3203 } else {
3204 process.env.GIT_ALLOW_PROTOCOL = previousAllowedProtocol;
3205 }
3206 }),
3207 );
3208
3209 // A real submodule: `git worktree add` leaves these empty, which is
3210 // what silently strips shared tooling out of every new worktree.
3211 const submoduleRepo = yield* makeTmpDir("git-submodule-");
3212 yield* initRepoWithCommit(submoduleRepo);
3213 yield* writeTextFile(submoduleRepo, "SHARED.md", "# shared\n");
3214 yield* git(submoduleRepo, ["add", "."]);
3215 yield* git(submoduleRepo, ["commit", "-m", "shared"]);
3216
3217 const cwd = yield* makeTmpDir();
3218 const { initialBranch } = yield* initRepoWithCommit(cwd);
3219 yield* git(cwd, ["submodule", "add", submoduleRepo, "shared"]);
3220 yield* git(cwd, ["commit", "-m", "add submodule"]);
3221
3222 const worktreePath = pathService.join(
3223 yield* makeTmpDir("git-worktrees-"),
3224 "submodule-worktree",
3225 );
3226 const driver = yield* GitVcsDriver.GitVcsDriver;
3227 yield* driver.createWorktree({
3228 cwd,
3229 path: worktreePath,
3230 refName: initialBranch,
3231 newRefName: "feature/submodules",
3232 });
3233
3234 assert.equal(
3235 yield* fileSystem.exists(pathService.join(worktreePath, "shared", "SHARED.md")),
3236 true,
3237 );
3238 }),
3239 );
3240
3241 it.effect("still creates the worktree when submodule checkout fails", () =>
3242 Effect.gen(function* () {
3243 const fileSystem = yield* FileSystem.FileSystem;
3244 const pathService = yield* Path.Path;
3245
3246 const cwd = yield* makeTmpDir();
3247 const { initialBranch } = yield* initRepoWithCommit(cwd);
3248 // Points at a repository that does not exist, so the checkout fails the
3249 // way an unreachable private remote would. Creation must still succeed.
3250 yield* writeTextFile(
3251 cwd,
3252 ".gitmodules",
3253 '[submodule "missing"]\n\tpath = missing\n\turl = /nonexistent/repo.git\n',
3254 );
3255 yield* git(cwd, ["add", "."]);
3256 yield* git(cwd, ["commit", "-m", "add unreachable submodule"]);
3257
3258 const worktreePath = pathService.join(
3259 yield* makeTmpDir("git-worktrees-"),
3260 "broken-submodule-worktree",
3261 );
3262 const driver = yield* GitVcsDriver.GitVcsDriver;
3263 const created = yield* driver.createWorktree({
3264 cwd,
3265 path: worktreePath,
3266 refName: initialBranch,
3267 newRefName: "feature/broken-submodules",
3268 });
3269
3270 assert.equal(created.worktree.path, worktreePath);
3271 assert.equal(yield* fileSystem.exists(worktreePath), true);
3272 }),
3273 );
3274
3275 it.effect("creates worktrees under the configured worktrees directory", () =>
3276 Effect.gen(function* () {
3277 const fileSystem = yield* FileSystem.FileSystem;
3278 const pathService = yield* Path.Path;
3279 const cwd = yield* makeTmpDir();
3280 const { initialBranch } = yield* initRepoWithCommit(cwd);
3281 const worktreesDirectory = yield* makeTmpDir("custom-worktrees-");
3282 const driver = yield* GitVcsDriver.GitVcsDriver;
3283
3284 const created = yield* driver.createWorktree(
3285 { cwd, path: null, refName: initialBranch, newRefName: "feature/custom-dir" },
3286 { worktreesDirectory },
3287 );
3288 const expected = pathService.join(
3289 worktreesDirectory,
3290 pathService.basename(cwd),
3291 "feature-custom-dir",
3292 );
3293 assert.equal(created.worktree.path, expected);
3294 assert.equal(yield* fileSystem.exists(expected), true);
3295
3296 const error = yield* driver
3297 .createWorktree(
3298 { cwd, path: null, refName: initialBranch, newRefName: "feature/relative-dir" },
3299 { worktreesDirectory: "relative/worktrees" },
3300 )
3301 .pipe(Effect.flip);
3302 assert.match(error.detail, /must be an absolute folder on this machine/);
3303
3304 const rootError = yield* driver
3305 .createWorktree(
3306 { cwd, path: null, refName: initialBranch, newRefName: "feature/root-dir" },
3307 { worktreesDirectory: "/" },
3308 )
3309 .pipe(Effect.flip);
3310 assert.match(rootError.detail, /not a drive root/);
3311 }),
3312 );
3313
3314 it.effect("resolves the submodule mode from the option, then t3.json", () =>
3315 Effect.gen(function* () {
3316 const fileSystem = yield* FileSystem.FileSystem;
3317 const pathService = yield* Path.Path;
3318
3319 const previousAllowedProtocol = process.env.GIT_ALLOW_PROTOCOL;
3320 process.env.GIT_ALLOW_PROTOCOL = "file";
3321 yield* Effect.addFinalizer(() =>
3322 Effect.sync(() => {
3323 if (previousAllowedProtocol === undefined) {
3324 delete process.env.GIT_ALLOW_PROTOCOL;
3325 } else {
3326 process.env.GIT_ALLOW_PROTOCOL = previousAllowedProtocol;
3327 }
3328 }),
3329 );
3330
3331 // inner -> nested, so a recursive init populates nested/NESTED.md and
3332 // a top-level init leaves it empty.
3333 const nestedRepo = yield* makeTmpDir("git-nested-");
3334 yield* initRepoWithCommit(nestedRepo);
3335 yield* writeTextFile(nestedRepo, "NESTED.md", "# nested\n");
3336 yield* git(nestedRepo, ["add", "."]);
3337 yield* git(nestedRepo, ["commit", "-m", "nested"]);
3338 const innerRepo = yield* makeTmpDir("git-inner-");
3339 yield* initRepoWithCommit(innerRepo);
3340 yield* writeTextFile(innerRepo, "INNER.md", "# inner\n");
3341 yield* git(innerRepo, ["submodule", "add", nestedRepo, "nested"]);
3342 yield* git(innerRepo, ["add", "."]);
3343 yield* git(innerRepo, ["commit", "-m", "inner"]);
3344
3345 const cwd = yield* makeTmpDir();
3346 const { initialBranch } = yield* initRepoWithCommit(cwd);
3347 yield* git(cwd, ["submodule", "add", innerRepo, "inner"]);
3348 yield* git(cwd, ["commit", "-m", "add submodule"]);
3349 const driver = yield* GitVcsDriver.GitVcsDriver;
3350 const worktreesDir = yield* makeTmpDir("git-worktrees-");
3351
3352 const createWithMode = Effect.fn(function* (
3353 fileMode: WorktreeSubmodules,
3354 branch: string,
3355 submodules: WorktreeSubmodules | null = null,
3356 ) {
3357 yield* writeTextFile(cwd, "t3.json", `{ "worktreeSubmodules": "${fileMode}" }`);
3358 yield* git(cwd, ["add", "t3.json"]);
3359 // Consecutive cases may reuse a file mode to test the option alone.
3360 yield* git(cwd, ["commit", "--allow-empty", "-m", `submodules: ${fileMode}`]);
3361 const worktreePath = pathService.join(worktreesDir, branch);
3362 const disabled = yield* Ref.make<"settings" | "t3.json" | false>(false);
3363 yield* driver.createWorktree(
3364 { cwd, path: worktreePath, refName: initialBranch, newRefName: branch },
3365 {
3366 submodules,
3367 progress: { onSubmodulesDisabled: ({ source }) => Ref.set(disabled, source) },
3368 },
3369 );
3370 return {
3371 disabled: yield* Ref.get(disabled),
3372 inner: yield* fileSystem.exists(pathService.join(worktreePath, "inner", "INNER.md")),
3373 nested: yield* fileSystem.exists(
3374 pathService.join(worktreePath, "inner", "nested", "NESTED.md"),
3375 ),
3376 };
3377 });
3378
3379 assert.deepEqual(yield* createWithMode("recursive", "recursive"), {
3380 disabled: false,
3381 inner: true,
3382 nested: true,
3383 });
3384 assert.deepEqual(yield* createWithMode("top-level", "top-level"), {
3385 disabled: false,
3386 inner: true,
3387 nested: false,
3388 });
3389 // A resolved setting outranks the file in both directions.
3390 assert.deepEqual(yield* createWithMode("recursive", "setting-none", "none"), {
3391 disabled: "settings",
3392 inner: false,
3393 nested: false,
3394 });
3395 assert.deepEqual(yield* createWithMode("none", "setting-wins", "top-level"), {
3396 disabled: false,
3397 inner: true,
3398 nested: false,
3399 });
3400 assert.deepEqual(yield* createWithMode("none", "none"), {
3401 disabled: "t3.json",
3402 inner: false,
3403 nested: false,
3404 });
3405 }),
3406 );
3407
3408 it.effect("reports checkout progress during parallel worktree creation", () =>
3409 Effect.gen(function* () {
3410 const cwd = yield* makeTmpDir();
3411 const { initialBranch } = yield* initRepoWithCommit(cwd);
3412 for (let index = 0; index < 200; index += 1) {
3413 yield* writeTextFile(cwd, `file-${index}.txt`, `${index}\n`);
3414 }
3415 yield* git(cwd, ["add", "."]);
3416 yield* git(cwd, ["commit", "-m", "add files"]);
3417 const pathService = yield* Path.Path;
3418 const worktreePath = pathService.join(
3419 yield* makeTmpDir("git-worktrees-"),
3420 "progress-worktree",
3421 );
3422 const driver = yield* GitVcsDriver.GitVcsDriver;
3423 const seen = yield* Ref.make<Array<{ percent: number; completed: number; total: number }>>(
3424 [],
3425 );
3426 const claimed = yield* Ref.make<{ path: string; existed: boolean } | null>(null);
3427
3428 yield* driver.createWorktree(
3429 { cwd, path: worktreePath, refName: initialBranch, newRefName: "feature/progress" },
3430 {
3431 progress: {
3432 onWorktreeClaimed: (path) =>
3433 Ref.set(claimed, { path, existed: NodeFS.existsSync(path) }),
3434 onCheckoutProgress: (update) => Ref.update(seen, (all) => [...all, update]),
3435 },
3436 },
3437 );
3438 // Claimed only once git has registered the directory.
3439 assert.deepEqual(yield* Ref.get(claimed), { path: worktreePath, existed: true });
3440
3441 // Git separates live progress updates with `\r`, so the driver must
3442 // surface every intermediate percentage, not just the final line.
3443 const updates = yield* Ref.get(seen);
3444 assert.isAbove(updates.length, 1);
3445 assert.equal(updates.at(-1)?.percent, 100);
3446 assert.equal(updates.at(-1)?.total, 201);
3447 const completed = updates.map((update) => update.completed);
3448 assert.deepEqual(
3449 completed,
3450 completed.toSorted((a, b) => a - b),
3451 );
3452 }),
3453 );
3454
3455 it.effect("creates and removes a worktree and its new local branch", () =>
3456 Effect.gen(function* () {
3457 const cwd = yield* makeTmpDir();
3458 const { initialBranch } = yield* initRepoWithCommit(cwd);
3459 const pathService = yield* Path.Path;
3460 const worktreePath = pathService.join(
3461 yield* makeTmpDir("git-worktrees-"),
3462 "feature-worktree",
3463 );
3464 const driver = yield* GitVcsDriver.GitVcsDriver;
3465
3466 const created = yield* driver.createWorktree({
3467 cwd,
3468 path: worktreePath,
3469 refName: initialBranch,
3470 newRefName: "feature/worktree",
3471 });
3472
3473 assert.equal(created.worktree.path, worktreePath);
3474 assert.equal(created.worktree.refName, "feature/worktree");
3475 assert.equal(yield* git(worktreePath, ["branch", "--show-current"]), "feature/worktree");
3476
3477 yield* driver.removeWorktree({ cwd, path: worktreePath });
3478 yield* driver.deleteLocalBranch({
3479 cwd,
3480 refName: "feature/worktree",
3481 force: true,
3482 });
3483 const fileSystem = yield* FileSystem.FileSystem;
3484 assert.equal(yield* fileSystem.exists(worktreePath), false);
3485 assert.notInclude(yield* driver.listLocalBranchNames(cwd), "feature/worktree");
3486 }),
3487 );
3488
3489 it.effect("keeps a worktree whose untracked files status is configured to hide", () =>
3490 Effect.gen(function* () {
3491 const cwd = yield* makeTmpDir();
3492 const { initialBranch } = yield* initRepoWithCommit(cwd);
3493 const pathService = yield* Path.Path;
3494 const worktreePath = pathService.join(yield* makeTmpDir("git-worktrees-"), "hidden");
3495 const driver = yield* GitVcsDriver.GitVcsDriver;
3496 yield* driver.createWorktree({
3497 cwd,
3498 path: worktreePath,
3499 refName: initialBranch,
3500 newRefName: "feature/hidden",
3501 });
3502 yield* git(cwd, ["config", "status.showUntrackedFiles", "no"]);
3503 yield* writeTextFile(worktreePath, "notes.txt", "draft\n");
3504 assert.equal(yield* git(worktreePath, ["status", "--porcelain"]), "");
3505
3506 const result = yield* Effect.result(driver.removeWorktree({ cwd, path: worktreePath }));
3507
3508 assert.isTrue(Result.isFailure(result));
3509 const fileSystem = yield* FileSystem.FileSystem;
3510 assert.equal(
3511 yield* fileSystem.readFileString(pathService.join(worktreePath, "notes.txt")),
3512 "draft\n",
3513 );
3514 }),
3515 );
3516
3517 it.effect("allows worktree removal to run longer than the default command timeout", () =>
3518 Effect.gen(function* () {
3519 const delegate = yield* ChildProcessSpawner.ChildProcessSpawner;
3520 const removalStarted = yield* Deferred.make<void>();
3521 const delayedRemovalSpawner = ChildProcessSpawner.make((command) =>
3522 Effect.gen(function* () {
3523 if (
3524 ChildProcess.isStandardCommand(command) &&
3525 command.args.join(" ").includes("worktree remove")
3526 ) {
3527 yield* Deferred.succeed(removalStarted, undefined);
3528 yield* Effect.sleep("31 seconds");
3529 }
3530 return yield* delegate.spawn(command);
3531 }),
3532 );
3533 const driver = yield* makeGitVcsDriverCore().pipe(
3534 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, delayedRemovalSpawner),
3535 Effect.provide(layerServerConfig),
3536 );
3537 const cwd = yield* makeTmpDir();
3538 const { initialBranch } = yield* initRepoWithCommit(cwd);
3539 const fileSystem = yield* FileSystem.FileSystem;
3540 const pathService = yield* Path.Path;
3541 const worktreePath = pathService.join(yield* makeTmpDir("git-worktrees-"), "slow-removal");
3542
3543 yield* driver.createWorktree({
3544 cwd,
3545 path: worktreePath,
3546 refName: initialBranch,
3547 newRefName: "feature/slow-removal",
3548 });
3549
3550 const removal = yield* driver
3551 .removeWorktree({ cwd, path: worktreePath, force: true })
3552 .pipe(Effect.forkChild({ startImmediately: true }));
3553 yield* Deferred.await(removalStarted);
3554 yield* TestClock.adjust("31 seconds");
3555 yield* Fiber.join(removal);
3556
3557 assert.equal(yield* fileSystem.exists(worktreePath), false);
3558 }),
3559 );
3560
3561 it.effect("removes the same worktree path twice without failing", () =>
3562 Effect.gen(function* () {
3563 const cwd = yield* makeTmpDir();
3564 const { initialBranch } = yield* initRepoWithCommit(cwd);
3565 const pathService = yield* Path.Path;
3566 const worktreePath = pathService.join(yield* makeTmpDir("git-worktrees-"), "shared");
3567 const driver = yield* GitVcsDriver.GitVcsDriver;
3568
3569 yield* driver.createWorktree({
3570 cwd,
3571 path: worktreePath,
3572 refName: initialBranch,
3573 newRefName: "feature/shared",
3574 });
3575
3576 // Two threads can record the same worktree path; the second delete
3577 // must be a no-op instead of exit 128.
3578 yield* driver.removeWorktree({ cwd, path: worktreePath });
3579 yield* driver.removeWorktree({ cwd, path: worktreePath });
3580 }),
3581 );
3582
3583 it.effect("prunes stale registrations when removing an already-gone worktree", () =>
3584 Effect.gen(function* () {
3585 const cwd = yield* makeTmpDir();
3586 const { initialBranch } = yield* initRepoWithCommit(cwd);
3587 const pathService = yield* Path.Path;
3588 const fileSystem = yield* FileSystem.FileSystem;
3589 const worktreesRoot = yield* makeTmpDir("git-worktrees-");
3590 const stalePath = pathService.join(worktreesRoot, "stale");
3591 const driver = yield* GitVcsDriver.GitVcsDriver;
3592
3593 yield* driver.createWorktree({
3594 cwd,
3595 path: stalePath,
3596 refName: initialBranch,
3597 newRefName: "feature/stale",
3598 });
3599 // Delete the directory behind git's back so the registration goes stale.
3600 yield* fileSystem.remove(stalePath, { recursive: true });
3601
3602 yield* driver.removeWorktree({
3603 cwd,
3604 path: pathService.join(worktreesRoot, "never-registered"),
3605 });
3606
3607 const registered = yield* git(cwd, ["worktree", "list", "--porcelain"]);
3608 assert.notInclude(registered, "stale");
3609 }),
3610 );
3611 });
3612
3613 describe("remote operations", () => {
3614 it.effect("explains a real fetch failure for a missing local remote", () =>
3615 Effect.gen(function* () {
3616 const cwd = yield* makeTmpDir();
3617 yield* initRepoWithCommit(cwd);
3618 const driver = yield* GitVcsDriver.GitVcsDriver;
3619 const missingRemote = `${cwd}/private-missing-remote`;
3620 yield* git(cwd, ["remote", "add", "origin", missingRemote]);
3621 const error = yield* driver.fetchRemote({ cwd, remoteName: "origin" }).pipe(Effect.flip);
3622 assert.include(error.detail, "could not access the remote repository");
3623 assert.equal(error.exitCode, 128);
3624 assert.isAbove(error.stderrLength ?? 0, 0);
3625 assert.notInclude(error.detail, missingRemote);
3626 assert.notProperty(error, "stderr");
3627 }),
3628 );
3629
3630 it.effect("ensureRemote reuses an existing remote across ssh/https transport variants", () =>
3631 Effect.gen(function* () {
3632 const cwd = yield* makeTmpDir();
3633 yield* initRepoWithCommit(cwd);
3634 const driver = yield* GitVcsDriver.GitVcsDriver;
3635
3636 yield* git(cwd, ["remote", "add", "origin", "https://github.com/pingdotgg/t3code.git"]);
3637
3638 const reusedForSsh = yield* driver.ensureRemote({
3639 cwd,
3640 preferredName: "pingdotgg",
3641 url: "git@github.com:pingdotgg/t3code.git",
3642 });
3643 assert.equal(reusedForSsh, "origin");
3644
3645 const reusedForSshScheme = yield* driver.ensureRemote({
3646 cwd,
3647 preferredName: "pingdotgg",
3648 url: "ssh://git@github.com/pingdotgg/t3code",
3649 });
3650 assert.equal(reusedForSshScheme, "origin");
3651
3652 const reusedForBareSshScheme = yield* driver.ensureRemote({
3653 cwd,
3654 preferredName: "pingdotgg",
3655 url: "ssh://github.com/pingdotgg/t3code",
3656 });
3657 assert.equal(reusedForBareSshScheme, "origin");
3658
3659 const reusedForSshPort = yield* driver.ensureRemote({
3660 cwd,
3661 preferredName: "pingdotgg",
3662 url: "ssh://git@github.com:22/pingdotgg/t3code",
3663 });
3664 assert.equal(reusedForSshPort, "origin");
3665
3666 const reusedForSshWithPort = yield* driver.ensureRemote({
3667 cwd,
3668 preferredName: "pingdotgg",
3669 url: "ssh://git@github.com:22/pingdotgg/t3code.git",
3670 });
3671 assert.equal(reusedForSshWithPort, "origin");
3672
3673 const addedForFork = yield* driver.ensureRemote({
3674 cwd,
3675 preferredName: "octocat",
3676 url: "git@github.com:octocat/t3code.git",
3677 });
3678 assert.equal(addedForFork, "octocat");
3679 assert.equal(yield* git(cwd, ["remote"]), "octocat\norigin");
3680 }),
3681 );
3682 });
3683
3684 describe("commit context", () => {
3685 it.effect("prepares selected files without staging and commits only those files", () =>
3686 Effect.gen(function* () {
3687 const cwd = yield* makeTmpDir();
3688 yield* initRepoWithCommit(cwd);
3689 const driver = yield* GitVcsDriver.GitVcsDriver;
3690
3691 yield* writeTextFile(cwd, "a.txt", "a\n");
3692 yield* writeTextFile(cwd, "b.txt", "b\n");
3693
3694 const context = yield* driver.prepareCommitContext(cwd, ["a.txt"]);
3695 assert.include(context?.stagedSummary ?? "", "a.txt");
3696 assert.notInclude(context?.stagedSummary ?? "", "b.txt");
3697 assert.equal(yield* git(cwd, ["diff", "--cached", "--name-only"]), "");
3698
3699 const commit = yield* driver.commit(cwd, "Add a", "", { stage: { filePaths: ["a.txt"] } });
3700 assert.match(commit.commitSha, /^[a-f0-9]{40}$/);
3701 assert.equal(yield* git(cwd, ["log", "-1", "--pretty=%s"]), "Add a");
3702
3703 const status = yield* git(cwd, ["status", "--porcelain"]);
3704 assert.include(status, "?? b.txt");
3705 assert.notInclude(status, "a.txt");
3706 }),
3707 );
3708
3709 it.effect("treats selected file paths literally", () =>
3710 Effect.gen(function* () {
3711 const cwd = yield* makeTmpDir();
3712 yield* initRepoWithCommit(cwd);
3713 const driver = yield* GitVcsDriver.GitVcsDriver;
3714
3715 yield* writeTextFile(cwd, "selected[1].txt", "literal\n");
3716 yield* writeTextFile(cwd, "selected1.txt", "pattern match\n");
3717
3718 const context = yield* driver.prepareCommitContext(cwd, ["selected[1].txt"]);
3719
3720 assert.include(context?.stagedSummary ?? "", "selected[1].txt");
3721 assert.notInclude(context?.stagedSummary ?? "", "selected1.txt");
3722 assert.equal(yield* git(cwd, ["diff", "--cached", "--name-only"]), "");
3723 yield* driver.commit(cwd, "Add literal path", "", {
3724 stage: { filePaths: ["selected[1].txt"] },
3725 });
3726 assert.equal(
3727 yield* git(cwd, ["show", "--pretty=", "--name-only", "HEAD"]),
3728 "selected[1].txt",
3729 );
3730
3731 const status = yield* git(cwd, ["status", "--porcelain"]);
3732 assert.include(status, "?? selected1.txt");
3733 }),
3734 );
3735
3736 it.effect.each([false, true])(
3737 "preserves partial staging and split-index files (split: %s)",
3738 (split) =>
3739 Effect.gen(function* () {
3740 const cwd = yield* makeTmpDir();
3741 yield* initRepoWithCommit(cwd);
3742 if (split) yield* git(cwd, ["config", "core.splitIndex", "true"]);
3743 yield* writeTextFile(cwd, "README.md", "# test\nstaged\n");
3744 yield* git(cwd, ["add", "README.md"]);
3745 yield* writeTextFile(cwd, "README.md", "# test\nstaged\nunstaged\n");
3746 yield* writeTextFile(cwd, "untracked.txt", "untracked\n");
3747 yield* writeTextFile(cwd, ".gitignore", "ignored.txt\n");
3748 yield* writeTextFile(cwd, "ignored.txt", "ignored\n");
3749 const before = NodeFS.readFileSync(`${cwd}/.git/index`);
3750 const shared = NodeFS.readdirSync(`${cwd}/.git`).filter((name) =>
3751 name.startsWith("sharedindex."),
3752 );
3753 const driver = yield* GitVcsDriver.GitVcsDriver;
3754 for (const paths of [undefined, ["README.md"]]) {
3755 const context = yield* driver.prepareCommitContext(cwd, paths);
3756 assert.include(context?.stagedPatch ?? "", "unstaged");
3757 assert.notInclude(context?.stagedSummary ?? "", "ignored.txt");
3758 assert.deepEqual(NodeFS.readFileSync(`${cwd}/.git/index`), before);
3759 assert.deepEqual(
3760 NodeFS.readdirSync(`${cwd}/.git`).filter((name) => name.startsWith("sharedindex.")),
3761 shared,
3762 );
3763 }
3764 }),
3765 );
3766
3767 it.effect("prepares an unborn repository without creating a real index", () =>
3768 Effect.gen(function* () {
3769 const cwd = yield* makeTmpDir();
3770 const driver = yield* GitVcsDriver.GitVcsDriver;
3771 yield* driver.initRepo({ cwd });
3772 yield* git(cwd, ["config", "user.email", "test@test.com"]);
3773 yield* git(cwd, ["config", "user.name", "Test"]);
3774 yield* writeTextFile(cwd, "initial.txt", "initial\n");
3775 for (const paths of [undefined, ["initial.txt"]]) {
3776 const context = yield* driver.prepareCommitContext(cwd, paths);
3777 assert.include(context?.stagedSummary ?? "", "initial.txt");
3778 assert.isFalse(NodeFS.existsSync(`${cwd}/.git/index`));
3779 }
3780 yield* driver.commit(cwd, "Initial", "", { stage: { filePaths: ["initial.txt"] } });
3781 assert.equal(yield* git(cwd, ["show", "HEAD:initial.txt"]), "initial");
3782 }),
3783 );
3784
3785 it.effect("preserves merge metadata while preparing selected and all files", () =>
3786 Effect.gen(function* () {
3787 const cwd = yield* makeTmpDir();
3788 const { initialBranch } = yield* initRepoWithCommit(cwd);
3789 yield* git(cwd, ["checkout", "-b", "feature"]);
3790 yield* writeTextFile(cwd, "feature.txt", "feature\n");
3791 yield* git(cwd, ["add", "."]);
3792 yield* git(cwd, ["commit", "-m", "Feature"]);
3793 yield* git(cwd, ["checkout", initialBranch]);
3794 yield* git(cwd, ["merge", "--no-commit", "--no-ff", "feature"]);
3795 yield* writeTextFile(cwd, "README.md", "# test\nmerge edit\n");
3796 const names = ["index", "MERGE_HEAD", "MERGE_MSG", "ORIG_HEAD"];
3797 const before = names.map((name) => NodeFS.readFileSync(`${cwd}/.git/${name}`));
3798 const driver = yield* GitVcsDriver.GitVcsDriver;
3799 for (const paths of [undefined, ["README.md"]]) {
3800 yield* driver.prepareCommitContext(cwd, paths);
3801 assert.deepEqual(
3802 names.map((name) => NodeFS.readFileSync(`${cwd}/.git/${name}`)),
3803 before,
3804 );
3805 }
3806 }),
3807 );
3808 });
3809
3810 describe("remote operations", () => {
3811 it.effect.each(["offline", "auth", "timeout"] as const)(
3812 "does not retry a scoped fetch after %s",
3813 (failure) =>
3814 Effect.gen(function* () {
3815 const cwd = yield* makeTmpDir();
3816 const delegate = yield* ChildProcessSpawner.ChildProcessSpawner;
3817 const started = yield* Deferred.make<void>();
3818 const attempts: Array<ReadonlyArray<string>> = [];
3819 const spawner = ChildProcessSpawner.make((command) =>
3820 Effect.gen(function* () {
3821 if (!ChildProcess.isStandardCommand(command))
3822 return yield* Effect.die("unexpected command");
3823 if (command.args[0] !== "fetch") return yield* delegate.spawn(command);
3824 attempts.push(command.args);
3825 yield* Deferred.succeed(started, undefined);
3826 return ChildProcessSpawner.makeHandle({
3827 ...makeNonRepositoryHandle(),
3828 exitCode:
3829 failure === "timeout"
3830 ? Effect.never
3831 : Effect.succeed(ChildProcessSpawner.ExitCode(128)),
3832 stderr: Stream.encodeText(
3833 Stream.make(
3834 failure === "auth"
3835 ? "fatal: Authentication failed"
3836 : "fatal: Could not resolve host",
3837 ),
3838 ),
3839 });
3840 }),
3841 );
3842 const driver = yield* makeGitVcsDriverCore().pipe(
3843 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
3844 Effect.provide(layerServerConfig),
3845 );
3846 const fetching = yield* driver
3847 .fetchRemote({ cwd, remoteName: "origin", refName: "main" })
3848 .pipe(Effect.result, Effect.forkChild({ startImmediately: true }));
3849 yield* Deferred.await(started);
3850 if (failure === "timeout") {
3851 yield* TestClock.adjust("31 seconds");
3852 yield* TestClock.adjust("31 seconds");
3853 }
3854 const result = yield* Fiber.join(fetching);
3855 assert.isTrue(Result.isFailure(result));
3856 assert.equal(attempts.length, 1);
3857 if (Result.isFailure(result)) {
3858 assert.equal(
3859 result.failure.detail,
3860 failure === "timeout"
3861 ? "Git command timed out."
3862 : failure === "offline"
3863 ? "Git could not reach the remote. Check the server's network connection and remote host, then retry."
3864 : "Git could not authenticate with the remote. Check Git credentials or SSH access on the server, then retry.",
3865 );
3866 }
3867 }),
3868 );
3869
3870 it.effect("creates a worktree from the latest fetched remote commit", () =>
3871 Effect.gen(function* () {
3872 const cwd = yield* makeTmpDir();
3873 const remote = yield* makeTmpDir("git-remote-");
3874 const peer = yield* makeTmpDir("git-peer-");
3875 const { initialBranch } = yield* initRepoWithCommit(cwd);
3876 yield* git(remote, ["init", "--bare"]);
3877 yield* git(cwd, ["remote", "add", "origin", remote]);
3878 yield* git(cwd, ["push", "-u", "origin", initialBranch]);
3879 yield* git(remote, ["symbolic-ref", "HEAD", `refs/heads/${initialBranch}`]);
3880 const beforeFetch = yield* git(cwd, ["rev-parse", `refs/remotes/origin/${initialBranch}`]);
3881
3882 yield* git(peer, ["clone", remote, "."]);
3883 yield* git(peer, ["config", "user.email", "test@test.com"]);
3884 yield* git(peer, ["config", "user.name", "Test"]);
3885 yield* writeTextFile(peer, "remote-change.txt", "remote\n");
3886 yield* git(peer, ["add", "remote-change.txt"]);
3887 yield* git(peer, ["commit", "-m", "remote change"]);
3888 yield* git(peer, ["push", "origin", initialBranch]);
3889 const remoteHead = yield* git(peer, ["rev-parse", "HEAD"]);
3890 assert.notEqual(beforeFetch, remoteHead);
3891
3892 yield* git(peer, ["push", "origin", "HEAD:refs/heads/unrelated"]);
3893 const driver = yield* GitVcsDriver.GitVcsDriver;
3894 yield* driver.fetchRemote({
3895 cwd,
3896 remoteName: "origin",
3897 refName: `origin/${initialBranch}`,
3898 });
3899 assert.isFalse(
3900 yield* driver.remoteBranchExists({ cwd, remoteName: "origin", refName: "unrelated" }),
3901 );
3902
3903 assert.equal(
3904 yield* driver.remoteBranchExists({
3905 cwd,
3906 remoteName: "origin",
3907 refName: initialBranch,
3908 }),
3909 true,
3910 );
3911 assert.equal(
3912 yield* driver.remoteBranchExists({
3913 cwd,
3914 remoteName: "origin",
3915 refName: "local-only",
3916 }),
3917 false,
3918 );
3919
3920 const resolvedBase = yield* driver.resolveRemoteTrackingCommit({
3921 cwd,
3922 refName: initialBranch,
3923 fallbackRemoteName: "origin",
3924 });
3925 const explicitlyResolvedBase = yield* driver.resolveRemoteTrackingCommit({
3926 cwd,
3927 refName: `origin/${initialBranch}`,
3928 fallbackRemoteName: "origin",
3929 });
3930
3931 assert.deepEqual(resolvedBase, {
3932 commitSha: remoteHead,
3933 remoteRefName: `origin/${initialBranch}`,
3934 });
3935 assert.deepEqual(explicitlyResolvedBase, resolvedBase);
3936 assert.equal(yield* git(cwd, ["rev-parse", initialBranch]), beforeFetch);
3937
3938 const pathService = yield* Path.Path;
3939 const worktreePath = pathService.join(
3940 yield* makeTmpDir("git-fetched-worktrees-"),
3941 "fetched-origin",
3942 );
3943 yield* driver.createWorktree({
3944 cwd,
3945 path: worktreePath,
3946 refName: resolvedBase.commitSha,
3947 newRefName: "t3code/fetched-origin",
3948 baseRefName: resolvedBase.remoteRefName,
3949 });
3950
3951 assert.equal(yield* git(worktreePath, ["rev-parse", "HEAD"]), remoteHead);
3952 assert.equal(
3953 yield* driver.readConfigValue(worktreePath, "branch.t3code/fetched-origin.gh-merge-base"),
3954 initialBranch,
3955 );
3956 assert.equal(
3957 yield* driver.readConfigValue(worktreePath, "branch.t3code/fetched-origin.remote"),
3958 null,
3959 );
3960 const status = yield* driver.statusDetails(worktreePath);
3961 assert.equal(status.aheadCount, 0);
3962 assert.equal(status.aheadOfDefaultCount, 0);
3963
3964 yield* driver.fetchRemote({ cwd, remoteName: "origin", refName: "local-only" });
3965 assert.isTrue(
3966 yield* driver.remoteBranchExists({ cwd, remoteName: "origin", refName: "unrelated" }),
3967 );
3968 }),
3969 );
3970
3971 it.effect("pushes with upstream setup and skips when already up to date", () =>
3972 Effect.gen(function* () {
3973 const cwd = yield* makeTmpDir();
3974 const remote = yield* makeTmpDir("git-remote-");
3975 yield* initRepoWithCommit(cwd);
3976 yield* git(remote, ["init", "--bare"]);
3977 yield* git(cwd, ["remote", "add", "origin", remote]);
3978 yield* (yield* GitVcsDriver.GitVcsDriver).createRef({
3979 cwd,
3980 refName: "feature/push",
3981 });
3982 yield* (yield* GitVcsDriver.GitVcsDriver).switchRef({
3983 cwd,
3984 refName: "feature/push",
3985 });
3986 yield* writeTextFile(cwd, "feature.txt", "feature\n");
3987 yield* git(cwd, ["add", "-A"]);
3988 yield* (yield* GitVcsDriver.GitVcsDriver).commit(cwd, "Add feature", "");
3989
3990 const pushed = yield* (yield* GitVcsDriver.GitVcsDriver).pushCurrentBranch(cwd, null);
3991 assert.deepInclude(pushed, {
3992 status: "pushed",
3993 branch: "feature/push",
3994 setUpstream: true,
3995 });
3996 assert.equal(
3997 yield* git(cwd, ["rev-parse", "--abbrev-ref", "@{upstream}"]),
3998 "origin/feature/push",
3999 );
4000
4001 const skipped = yield* (yield* GitVcsDriver.GitVcsDriver).pushCurrentBranch(cwd, null);
4002 assert.deepInclude(skipped, {
4003 status: "skipped_up_to_date",
4004 branch: "feature/push",
4005 });
4006 }),
4007 );
4008
4009 it.effect("allows pushes to run longer than the default command timeout", () =>
4010 Effect.gen(function* () {
4011 const delegate = yield* ChildProcessSpawner.ChildProcessSpawner;
4012 const pushStarted = yield* Deferred.make<void>();
4013 const delayedPushSpawner = ChildProcessSpawner.make((command) =>
4014 Effect.gen(function* () {
4015 if (ChildProcess.isStandardCommand(command) && command.args[0] === "push") {
4016 yield* Deferred.succeed(pushStarted, undefined);
4017 yield* Effect.sleep("31 seconds");
4018 }
4019 return yield* delegate.spawn(command);
4020 }),
4021 );
4022 const driver = yield* makeGitVcsDriverCore().pipe(
4023 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, delayedPushSpawner),
4024 Effect.provide(layerServerConfig),
4025 );
4026 const cwd = yield* makeTmpDir();
4027 const remote = yield* makeTmpDir("git-remote-");
4028 yield* initRepoWithCommit(cwd);
4029 yield* git(remote, ["init", "--bare"]);
4030 yield* git(cwd, ["remote", "add", "origin", remote]);
4031
4032 const pushing = yield* driver
4033 .pushCurrentBranch(cwd, null)
4034 .pipe(Effect.forkChild({ startImmediately: true }));
4035 yield* Deferred.await(pushStarted);
4036 yield* TestClock.adjust("31 seconds");
4037 const pushed = yield* Fiber.join(pushing);
4038
4039 assert.deepInclude(pushed, {
4040 status: "pushed",
4041 setUpstream: true,
4042 });
4043 }),
4044 );
4045
4046 it.effect(
4047 "pushes upstream branches to the remote branch name, not the upstream shorthand",
4048 () =>
4049 Effect.gen(function* () {
4050 const cwd = yield* makeTmpDir();
4051 const remote = yield* makeTmpDir("git-remote-");
4052 yield* initRepoWithCommit(cwd);
4053 const driver = yield* GitVcsDriver.GitVcsDriver;
4054 yield* git(cwd, ["branch", "-M", "main"]);
4055 yield* git(remote, ["init", "--bare"]);
4056 yield* git(cwd, ["remote", "add", "origin", remote]);
4057 yield* git(cwd, ["push", "-u", "origin", "main"]);
4058 yield* writeTextFile(cwd, "upstream.txt", "upstream\n");
4059 yield* git(cwd, ["add", "-A"]);
4060 yield* driver.commit(cwd, "Add upstream update", "");
4061
4062 const pushed = yield* driver.pushCurrentBranch(cwd, null);
4063
4064 assert.deepInclude(pushed, {
4065 status: "pushed",
4066 branch: "main",
4067 upstreamBranch: "origin/main",
4068 setUpstream: false,
4069 });
4070 assert.equal(
4071 yield* git(remote, ["log", "-1", "--pretty=%s", "main"]),
4072 "Add upstream update",
4073 );
4074 const badBranch = yield* driver.execute({
4075 operation: "GitVcsDriver.test.showBadRemoteBranch",
4076 cwd: remote,
4077 args: ["show-ref", "--verify", "--quiet", "refs/heads/origin/main"],
4078 allowNonZeroExit: true,
4079 timeoutMs: 10_000,
4080 });
4081 assert.notEqual(badBranch.exitCode, 0);
4082 }),
4083 );
4084
4085 it.effect("publishes a branch tracking its base under its own name, not the base", () =>
4086 Effect.gen(function* () {
4087 const cwd = yield* makeTmpDir();
4088 const remote = yield* makeTmpDir("git-remote-");
4089 yield* initRepoWithCommit(cwd);
4090 const driver = yield* GitVcsDriver.GitVcsDriver;
4091 yield* git(cwd, ["branch", "-M", "main"]);
4092 yield* git(remote, ["init", "--bare"]);
4093 yield* git(cwd, ["remote", "add", "origin", remote]);
4094 yield* git(cwd, ["push", "-u", "origin", "main"]);
4095 yield* git(cwd, ["checkout", "-b", "dev"]);
4096 yield* git(cwd, ["push", "-u", "origin", "dev"]);
4097 const devSha = yield* git(cwd, ["rev-parse", "HEAD"]);
4098 yield* git(cwd, ["checkout", "-b", "feature/x", "origin/dev"]);
4099 yield* writeTextFile(cwd, "feature.txt", "feature\n");
4100 yield* git(cwd, ["add", "-A"]);
4101 yield* driver.commit(cwd, "Add feature", "");
4102
4103 const pushed = yield* driver.pushCurrentBranch(cwd, null);
4104
4105 assert.deepInclude(pushed, {
4106 status: "pushed",
4107 branch: "feature/x",
4108 upstreamBranch: "origin/feature/x",
4109 setUpstream: true,
4110 });
4111 assert.equal(yield* git(remote, ["log", "-1", "--pretty=%s", "feature/x"]), "Add feature");
4112 assert.equal(yield* git(remote, ["rev-parse", "dev"]), devSha);
4113 assert.equal(
4114 yield* git(cwd, ["rev-parse", "--abbrev-ref", "@{upstream}"]),
4115 "origin/feature/x",
4116 );
4117 assert.equal(yield* driver.readConfigValue(cwd, "branch.feature/x.gh-merge-base"), "dev");
4118 }),
4119 );
4120
4121 it.effect("keeps a recorded merge base when publishing a tracked branch", () =>
4122 Effect.gen(function* () {
4123 const cwd = yield* makeTmpDir();
4124 const remote = yield* makeTmpDir("git-remote-");
4125 yield* initRepoWithCommit(cwd);
4126 const driver = yield* GitVcsDriver.GitVcsDriver;
4127 yield* git(cwd, ["branch", "-M", "main"]);
4128 yield* git(remote, ["init", "--bare"]);
4129 yield* git(cwd, ["remote", "add", "origin", remote]);
4130 yield* git(cwd, ["push", "-u", "origin", "main"]);
4131 yield* git(cwd, ["checkout", "-b", "feature/y", "origin/main"]);
4132 yield* git(cwd, ["config", "branch.feature/y.gh-merge-base", "release/v2"]);
4133 yield* writeTextFile(cwd, "feature.txt", "feature\n");
4134 yield* git(cwd, ["add", "-A"]);
4135 yield* driver.commit(cwd, "Add feature", "");
4136
4137 const pushed = yield* driver.pushCurrentBranch(cwd, null);
4138
4139 assert.deepInclude(pushed, {
4140 status: "pushed",
4141 branch: "feature/y",
4142 upstreamBranch: "origin/feature/y",
4143 setUpstream: true,
4144 });
4145 assert.equal(
4146 yield* driver.readConfigValue(cwd, "branch.feature/y.gh-merge-base"),
4147 "release/v2",
4148 );
4149 }),
4150 );
4151
4152 it.effect("still pushes a git-mangled tracking alias to its upstream head", () =>
4153 Effect.gen(function* () {
4154 const cwd = yield* makeTmpDir();
4155 const remote = yield* makeTmpDir("git-remote-");
4156 yield* initRepoWithCommit(cwd);
4157 const driver = yield* GitVcsDriver.GitVcsDriver;
4158 yield* git(cwd, ["branch", "-M", "main"]);
4159 yield* git(remote, ["init", "--bare"]);
4160 yield* git(cwd, ["remote", "add", "my-org/upstream", remote]);
4161 yield* git(cwd, ["push", "my-org/upstream", "main:effect-atom"]);
4162 yield* git(cwd, ["fetch", "my-org/upstream"]);
4163 // `checkout --track my-org/upstream/effect-atom` cannot name the local
4164 // branch `effect-atom`, so git keeps `upstream/effect-atom`. Its
4165 // upstream is still its published head.
4166 yield* git(cwd, ["checkout", "--track", "my-org/upstream/effect-atom"]);
4167 assert.equal(
4168 yield* git(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]),
4169 "upstream/effect-atom",
4170 );
4171 yield* writeTextFile(cwd, "alias.txt", "alias\n");
4172 yield* git(cwd, ["add", "-A"]);
4173 yield* driver.commit(cwd, "Add alias update", "");
4174
4175 const pushed = yield* driver.pushCurrentBranch(cwd, null);
4176
4177 assert.deepInclude(pushed, {
4178 status: "pushed",
4179 branch: "upstream/effect-atom",
4180 upstreamBranch: "my-org/upstream/effect-atom",
4181 setUpstream: false,
4182 });
4183 assert.equal(
4184 yield* git(remote, ["log", "-1", "--pretty=%s", "effect-atom"]),
4185 "Add alias update",
4186 );
4187 }),
4188 );
4189
4190 it.effect("pushes to the requested remote instead of the primary remote", () =>
4191 Effect.gen(function* () {
4192 const cwd = yield* makeTmpDir();
4193 const originRemote = yield* makeTmpDir("git-origin-remote-");
4194 const publishRemote = yield* makeTmpDir("git-publish-remote-");
4195 yield* initRepoWithCommit(cwd);
4196 const driver = yield* GitVcsDriver.GitVcsDriver;
4197 yield* git(cwd, ["branch", "-M", "main"]);
4198 yield* git(originRemote, ["init", "--bare"]);
4199 yield* git(publishRemote, ["init", "--bare"]);
4200 yield* git(cwd, ["remote", "add", "origin", originRemote]);
4201 yield* git(cwd, ["remote", "add", "origin-1", publishRemote]);
4202
4203 const pushed = yield* driver.pushCurrentBranch(cwd, null, { remoteName: "origin-1" });
4204
4205 assert.deepInclude(pushed, {
4206 status: "pushed",
4207 branch: "main",
4208 upstreamBranch: "origin-1/main",
4209 setUpstream: true,
4210 });
4211 assert.equal(
4212 yield* git(publishRemote, ["log", "-1", "--pretty=%s", "main"]),
4213 "initial commit",
4214 );
4215 const originMain = yield* driver.execute({
4216 operation: "GitVcsDriver.test.originMainMissing",
4217 cwd: originRemote,
4218 args: ["show-ref", "--verify", "--quiet", "refs/heads/main"],
4219 allowNonZeroExit: true,
4220 timeoutMs: 10_000,
4221 });
4222 assert.notEqual(originMain.exitCode, 0);
4223 }),
4224 );
4225 });
4226});
4227
4228describe("Windows long path configuration", () => {
4229 const readGitConfig = Effect.fn("readGitConfig")(function* (
4230 platform: NodeJS.Platform,
4231 key: string,
4232 ) {
4233 const layer = GitVcsDriver.layer.pipe(
4234 Layer.provide(layerServerConfig),
4235 Layer.provideMerge(NodeServices.layer),
4236 Layer.provide(Layer.succeed(HostProcess.Platform, platform)),
4237 );
4238 return yield* Effect.gen(function* () {
4239 const driver = yield* GitVcsDriver.GitVcsDriver;
4240 const cwd = yield* makeTmpDir("git-longpath-test-");
4241 const result = yield* driver.execute({
4242 operation: "GitVcsDriverTest.readGitConfig",
4243 cwd,
4244 args: ["config", "--get", key],
4245 // Replaces the suite's own core.longpaths=true so it cannot mask a
4246 // missing injection. Entry 2 sits past the count and must stay unread.
4247 env: {
4248 GIT_CONFIG_COUNT: "2",
4249 GIT_CONFIG_KEY_0: "user.name",
4250 GIT_CONFIG_VALUE_0: "inherited-name",
4251 GIT_CONFIG_KEY_1: "core.longpaths",
4252 GIT_CONFIG_VALUE_1: "false",
4253 GIT_CONFIG_KEY_2: "user.name",
4254 GIT_CONFIG_VALUE_2: "outside-count",
4255 },
4256 });
4257 return result.stdout.trim();
4258 }).pipe(Effect.provide(layer), Effect.scoped);
4259 });
4260
4261 it.effect("enables long paths for every Git command on Windows", () =>
4262 Effect.gen(function* () {
4263 assert.equal(yield* readGitConfig("win32", "core.longpaths"), "true");
4264 assert.equal(yield* readGitConfig("win32", "user.name"), "inherited-name");
4265 }),
4266 );
4267
4268 it.effect("leaves Git config untouched on other platforms", () =>
4269 Effect.gen(function* () {
4270 assert.equal(yield* readGitConfig("linux", "core.longpaths"), "false");
4271 assert.equal(yield* readGitConfig("linux", "user.name"), "inherited-name");
4272 }),
4273 );
4274
4275 it("extends an inherited count spelled in a different case", () => {
4276 // Two spellings collapse to one on spawn (the uppercase one wins), so the
4277 // count must be bumped under the spelling that is already there.
4278 assert.deepStrictEqual(windowsLongPathConfigEnv("win32", { git_config_count: "2" }), {
4279 git_config_count: "3",
4280 GIT_CONFIG_KEY_2: "core.longpaths",
4281 GIT_CONFIG_VALUE_2: "true",
4282 });
4283 });
4284
4285 it("appends after inherited entries, parsing the count as Git does", () => {
4286 for (const [count, next] of [
4287 [undefined, 0],
4288 ["", 0],
4289 ["0", 0],
4290 ["-0", 0],
4291 ["2", 2],
4292 [" 2", 2],
4293 ["+2", 2],
4294 ["02", 2],
4295 ] as const) {
4296 assert.deepStrictEqual(
4297 windowsLongPathConfigEnv("win32", count === undefined ? {} : { GIT_CONFIG_COUNT: count }),
4298 {
4299 GIT_CONFIG_COUNT: String(next + 1),
4300 [`GIT_CONFIG_KEY_${next}`]: "core.longpaths",
4301 [`GIT_CONFIG_VALUE_${next}`]: "true",
4302 },
4303 `GIT_CONFIG_COUNT=${JSON.stringify(count)}`,
4304 );
4305 }
4306 });
4307
4308 it("leaves a count Git would reject for Git to report", () => {
4309 for (const count of ["nope", "2x", "-1", "1.5", " ", "1 "]) {
4310 assert.deepStrictEqual(
4311 windowsLongPathConfigEnv("win32", { GIT_CONFIG_COUNT: count }),
4312 {},
4313 `GIT_CONFIG_COUNT=${JSON.stringify(count)}`,
4314 );
4315 }
4316 });
4317});