1import * as NodeBuffer from "node:buffer";
2
3import * as Context from "effect/Context";
4import * as Crypto from "effect/Crypto";
5import * as DateTime from "effect/DateTime";
6import * as Effect from "effect/Effect";
7import * as FileSystem from "effect/FileSystem";
8import * as Layer from "effect/Layer";
9import * as Option from "effect/Option";
10import * as Path from "effect/Path";
11import { ChildProcessSpawner } from "effect/process";
12
13import {
14 GitCommandError,
15 VcsProcessExitError,
16 type VcsSwitchRefInput,
17 type VcsSwitchRefResult,
18 type VcsCreateRefInput,
19 type VcsCreateRefResult,
20 type VcsCreateWorktreeInput,
21 type VcsCreateWorktreeResult,
22 type ReviewDiffPreviewInput,
23 type ReviewDiffPreviewResult,
24 type ReviewDiffFileContentsInput,
25 type ReviewDiffFileContentsResult,
26 type VcsInitInput,
27 type VcsListRefsInput,
28 type VcsListRefsResult,
29 type VcsPullResult,
30 type VcsRemoveWorktreeInput,
31 type VcsStatusInput,
32 type VcsStatusResult,
33 type WorktreeSubmodules,
34} from "@t3tools/contracts";
35import {
36 makeGitVcsDriverCore,
37 PATCH_RENDER_PREFIX_ARGS,
38 splitNullSeparatedGitStdoutPaths,
39} from "./GitVcsDriverCore.ts";
40import * as VcsDriver from "./VcsDriver.ts";
41import * as VcsProcess from "./VcsProcess.ts";
42
43export interface ExecuteGitInput {
44 readonly operation: string;
45 readonly cwd: string;
46 readonly args: ReadonlyArray<string>;
47 readonly stdin?: string;
48 readonly env?: NodeJS.ProcessEnv;
49 readonly allowNonZeroExit?: boolean;
50 readonly timeoutMs?: number | null;
51 readonly maxOutputBytes?: number;
52 readonly appendTruncationMarker?: boolean;
53 /**
54 * With `appendTruncationMarker`, keep invoking the line callbacks after the
55 * buffered copy is full. For long-running commands whose output is only
56 * consumed through `progress`.
57 */
58 readonly keepLineCallbacksAfterTruncation?: boolean;
59 readonly progress?: ExecuteGitProgress;
60}
61
62export interface ExecuteGitResult {
63 readonly exitCode: ChildProcessSpawner.ExitCode;
64 readonly stdout: string;
65 readonly stderr: string;
66 readonly stdoutTruncated: boolean;
67 readonly stderrTruncated: boolean;
68}
69
70export interface GitStatusDetails {
71 isRepo: boolean;
72 sourceControlProvider?: VcsStatusResult["sourceControlProvider"];
73 hasOriginRemote: boolean;
74 isDefaultBranch: boolean;
75 branch: string | null;
76 upstreamRef: string | null;
77 hasWorkingTreeChanges: boolean;
78 workingTree: VcsStatusResult["workingTree"];
79 branchChanges?: VcsStatusResult["branchChanges"];
80 hasUpstream: boolean;
81 aheadCount: number;
82 behindCount: number;
83 aheadOfDefaultCount: number;
84}
85
86export interface GitLocalStatusOptions {
87 /** Skip revision walks and return zero divergence counts for local-only consumers. */
88 readonly includeDivergence?: boolean;
89 /** Also read the diff panel's Changes totals. Failures leave them out. */
90 readonly includeBranchChanges?: boolean;
91}
92
93export interface GitRemoteStatusDetails {
94 isRepo: boolean;
95 defaultBranch: string | null;
96 isDefaultBranch: boolean;
97 branch: string | null;
98 upstreamRef: string | null;
99 hasUpstream: boolean;
100 aheadCount: number;
101 behindCount: number;
102 aheadOfDefaultCount: number;
103}
104
105export interface GitPreparedCommitContext {
106 stagedSummary: string;
107 stagedPatch: string;
108}
109
110export interface ExecuteGitProgress {
111 readonly onStdoutLine?: (line: string) => Effect.Effect<void, never>;
112 readonly onStderrLine?: (line: string) => Effect.Effect<void, never>;
113 readonly onHookStarted?: (hookName: string) => Effect.Effect<void, never>;
114 readonly onHookFinished?: (input: {
115 hookName: string;
116 exitCode: number | null;
117 durationMs: number | null;
118 }) => Effect.Effect<void, never>;
119}
120
121/**
122 * Progress callbacks for `createWorktree`. Git prints `Updating files: 78% (2104/2700)`
123 * to stderr during checkout, and `Submodule path 'x': checked out` during
124 * submodule init. The tracker uses these to drive the worktree setup card.
125 */
126export interface CreateWorktreeProgress {
127 /**
128 * Fires once `git worktree add` has created and registered the directory,
129 * before the (possibly long) submodule step. Git refuses an existing path,
130 * so a path reported here belongs to this call and is safe to remove on
131 * cancel.
132 */
133 readonly onWorktreeClaimed?: (path: string) => Effect.Effect<void, never>;
134 readonly onCheckoutProgress?: (input: {
135 percent: number;
136 completed: number;
137 total: number;
138 }) => Effect.Effect<void, never>;
139 readonly onSubmodulesStarted?: () => Effect.Effect<void, never>;
140 /** Fires when `.gitmodules` exists but the resolved submodule mode is `"none"`. */
141 readonly onSubmodulesDisabled?: (input: {
142 source: "settings" | "t3.json";
143 }) => Effect.Effect<void, never>;
144 readonly onSubmoduleLine?: (line: string) => Effect.Effect<void, never>;
145 readonly onSubmodulesFinished?: (input: {
146 ok: boolean;
147 detail: string | null;
148 }) => Effect.Effect<void, never>;
149}
150
151export interface CreateWorktreeOptions {
152 readonly progress?: CreateWorktreeProgress;
153 /**
154 * The project-over-environment `worktreeSubmodules` setting. Null (or
155 * omitted, for callers without settings access) defers to the checkout's
156 * own t3.json.
157 */
158 readonly submodules?: WorktreeSubmodules | null;
159 /** The `worktreesDirectory` setting, used when the input has no explicit path. */
160 readonly worktreesDirectory?: string;
161}
162
163export interface GitCommitProgress {
164 readonly onOutputLine?: (input: {
165 stream: "stdout" | "stderr";
166 text: string;
167 }) => Effect.Effect<void, never>;
168 readonly onHookStarted?: (hookName: string) => Effect.Effect<void, never>;
169 readonly onHookFinished?: (input: {
170 hookName: string;
171 exitCode: number | null;
172 durationMs: number | null;
173 }) => Effect.Effect<void, never>;
174}
175
176export interface GitCommitOptions {
177 readonly timeoutMs?: number;
178 readonly progress?: GitCommitProgress;
179 /** Stage the current working tree immediately before committing. */
180 readonly stage?: { readonly filePaths?: readonly string[] };
181}
182
183export interface GitDeleteLocalBranchInput {
184 readonly cwd: string;
185 readonly refName: string;
186 readonly force?: boolean;
187}
188
189export interface GitPushResult {
190 status: "pushed" | "skipped_up_to_date";
191 branch: string;
192 upstreamBranch?: string | undefined;
193 setUpstream?: boolean | undefined;
194}
195
196export interface GitRangeContext {
197 commitSummary: string;
198 diffSummary: string;
199 diffPatch: string;
200}
201
202export interface GitRenameBranchInput {
203 /** Fail on a name collision instead of appending a numeric suffix. */
204 exactName?: boolean;
205 cwd: string;
206 oldBranch: string;
207 newBranch: string;
208}
209
210export interface GitRenameBranchResult {
211 branch: string;
212}
213
214export interface GitFetchPullRequestBranchInput {
215 cwd: string;
216 prNumber: number;
217 branch: string;
218}
219
220export interface GitFetchPullRequestHeadCommitInput {
221 cwd: string;
222 prNumber: number;
223}
224
225export interface GitResolveCommitInput {
226 cwd: string;
227 revision: string;
228}
229
230export interface GitResolveCommitResult {
231 commitSha: string;
232}
233
234export interface GitRefreshCheckedOutBranchInput {
235 cwd: string;
236 targetCommit: string;
237 /**
238 * Commit the checkout is allowed to be hard-reset away from: the upstream commit read before
239 * the fetch. HEAD sitting there means the checkout holds no work of its own.
240 */
241 resetWhenHeadCommit?: string | null | undefined;
242}
243
244export interface GitRefreshCheckedOutBranchResult {
245 headCommit: string;
246 moved: boolean;
247 onTarget: boolean;
248}
249
250export interface GitEnsureRemoteInput {
251 cwd: string;
252 preferredName: string;
253 url: string;
254}
255
256export interface GitFetchRemoteBranchInput {
257 cwd: string;
258 remoteName: string;
259 remoteBranch: string;
260 localBranch: string;
261}
262
263export interface GitFetchRemoteTrackingBranchInput {
264 cwd: string;
265 remoteName: string;
266 remoteBranch: string;
267}
268
269export interface GitFetchRemoteInput {
270 cwd: string;
271 remoteName: string;
272 refName?: string;
273}
274
275export interface GitRemoteExistsInput {
276 cwd: string;
277 remoteName: string;
278}
279
280export interface GitRemoteBranchExistsInput extends GitRemoteExistsInput {
281 refName: string;
282}
283
284export interface GitResolveRemoteTrackingCommitInput {
285 cwd: string;
286 refName: string;
287 fallbackRemoteName: string;
288}
289
290export interface GitResolveRemoteTrackingCommitResult {
291 commitSha: string;
292 remoteRefName: string;
293}
294
295export interface GitSetBranchUpstreamInput {
296 cwd: string;
297 branch: string;
298 remoteName: string;
299 remoteBranch: string;
300}
301
302export interface GitRemoteStatusOptions {
303 readonly refreshUpstream?: boolean;
304}
305
306export class GitVcsDriver extends Context.Service<
307 GitVcsDriver,
308 {
309 readonly execute: (input: ExecuteGitInput) => Effect.Effect<ExecuteGitResult, GitCommandError>;
310 readonly status: (input: VcsStatusInput) => Effect.Effect<VcsStatusResult, GitCommandError>;
311 readonly statusDetails: (cwd: string) => Effect.Effect<GitStatusDetails, GitCommandError>;
312 readonly statusDetailsLocal: (
313 cwd: string,
314 options?: GitLocalStatusOptions,
315 ) => Effect.Effect<GitStatusDetails, GitCommandError>;
316 readonly statusDetailsRemote: (
317 cwd: string,
318 options?: GitRemoteStatusOptions,
319 ) => Effect.Effect<GitRemoteStatusDetails, GitCommandError>;
320 readonly prepareCommitContext: (
321 cwd: string,
322 filePaths?: readonly string[],
323 ) => Effect.Effect<GitPreparedCommitContext | null, GitCommandError>;
324 readonly commit: (
325 cwd: string,
326 subject: string,
327 body: string,
328 options?: GitCommitOptions,
329 ) => Effect.Effect<{ commitSha: string }, GitCommandError>;
330 readonly pushCurrentBranch: (
331 cwd: string,
332 fallbackBranch: string | null,
333 options?: { readonly remoteName?: string | null },
334 ) => Effect.Effect<GitPushResult, GitCommandError>;
335 readonly readRangeContext: (
336 cwd: string,
337 baseRef: string,
338 ) => Effect.Effect<GitRangeContext, GitCommandError>;
339 readonly getReviewDiffPreview: (
340 input: ReviewDiffPreviewInput,
341 ) => Effect.Effect<ReviewDiffPreviewResult, GitCommandError>;
342 readonly getReviewDiffFileContents: (
343 input: ReviewDiffFileContentsInput,
344 ) => Effect.Effect<ReviewDiffFileContentsResult, GitCommandError>;
345 readonly readConfigValue: (
346 cwd: string,
347 key: string,
348 ) => Effect.Effect<string | null, GitCommandError>;
349 /** Returns the branch name of a local or remote-tracking ref, excluding tags and commits. */
350 readonly resolveBranchName: (
351 cwd: string,
352 refName: string,
353 ) => Effect.Effect<string | null, GitCommandError>;
354 readonly listRefs: (
355 input: VcsListRefsInput,
356 ) => Effect.Effect<VcsListRefsResult, GitCommandError>;
357 readonly pullCurrentBranch: (cwd: string) => Effect.Effect<VcsPullResult, GitCommandError>;
358 readonly createWorktree: (
359 input: VcsCreateWorktreeInput,
360 options?: CreateWorktreeOptions,
361 ) => Effect.Effect<VcsCreateWorktreeResult, GitCommandError>;
362 readonly fetchPullRequestBranch: (
363 input: GitFetchPullRequestBranchInput,
364 ) => Effect.Effect<void, GitCommandError>;
365 /** Fetches `refs/pull/<n>/head` without writing a branch, for heads that exist nowhere else. */
366 readonly fetchPullRequestHeadCommit: (
367 input: GitFetchPullRequestHeadCommitInput,
368 ) => Effect.Effect<GitResolveCommitResult, GitCommandError>;
369 readonly resolveCommit: (
370 input: GitResolveCommitInput,
371 ) => Effect.Effect<GitResolveCommitResult, GitCommandError>;
372 /** Moves the branch checked out in `cwd` onto `targetCommit`, from inside that worktree. */
373 readonly refreshCheckedOutBranch: (
374 input: GitRefreshCheckedOutBranchInput,
375 ) => Effect.Effect<GitRefreshCheckedOutBranchResult, GitCommandError>;
376 readonly ensureRemote: (input: GitEnsureRemoteInput) => Effect.Effect<string, GitCommandError>;
377 readonly resolvePrimaryRemoteName: (cwd: string) => Effect.Effect<string, GitCommandError>;
378 readonly resolveDefaultBranchName: (
379 cwd: string,
380 remoteName: string,
381 ) => Effect.Effect<string | null, GitCommandError>;
382 readonly fetchRemote: (input: GitFetchRemoteInput) => Effect.Effect<void, GitCommandError>;
383 readonly remoteExists: (input: GitRemoteExistsInput) => Effect.Effect<boolean, GitCommandError>;
384 readonly remoteBranchExists: (
385 input: GitRemoteBranchExistsInput,
386 ) => Effect.Effect<boolean, GitCommandError>;
387 readonly resolveRemoteTrackingCommit: (
388 input: GitResolveRemoteTrackingCommitInput,
389 ) => Effect.Effect<GitResolveRemoteTrackingCommitResult, GitCommandError>;
390 readonly fetchRemoteBranch: (
391 input: GitFetchRemoteBranchInput,
392 ) => Effect.Effect<void, GitCommandError>;
393 readonly fetchRemoteTrackingBranch: (
394 input: GitFetchRemoteTrackingBranchInput,
395 ) => Effect.Effect<void, GitCommandError>;
396 readonly setBranchUpstream: (
397 input: GitSetBranchUpstreamInput,
398 ) => Effect.Effect<void, GitCommandError>;
399 readonly removeWorktree: (
400 input: VcsRemoveWorktreeInput,
401 ) => Effect.Effect<void, GitCommandError>;
402 /** Drops worktree admin entries whose directory is already gone (`git worktree prune`). */
403 readonly pruneWorktrees: (input: {
404 readonly cwd: string;
405 }) => Effect.Effect<void, GitCommandError>;
406 /**
407 * Absolute paths of every live worktree of the repository at `cwd`, the
408 * main checkout included. Worktrees whose directory is gone are left out.
409 */
410 readonly listWorktreePaths: (cwd: string) => Effect.Effect<string[], GitCommandError>;
411 readonly deleteLocalBranch: (
412 input: GitDeleteLocalBranchInput,
413 ) => Effect.Effect<void, GitCommandError>;
414 readonly renameBranch: (
415 input: GitRenameBranchInput,
416 ) => Effect.Effect<GitRenameBranchResult, GitCommandError>;
417 readonly createRef: (
418 input: VcsCreateRefInput,
419 ) => Effect.Effect<VcsCreateRefResult, GitCommandError>;
420 readonly switchRef: (
421 input: VcsSwitchRefInput,
422 ) => Effect.Effect<VcsSwitchRefResult, GitCommandError>;
423 readonly initRepo: (input: VcsInitInput) => Effect.Effect<void, GitCommandError>;
424 readonly listLocalBranchNames: (cwd: string) => Effect.Effect<string[], GitCommandError>;
425 }
426>()("t3/vcs/GitVcsDriver") {}
427
428const WORKSPACE_FILES_MAX_OUTPUT_BYTES = 16 * 1024 * 1024;
429const CHECKPOINT_RECOVERY_MAX_CANDIDATES = 64;
430const CHECKPOINT_RECOVERY_TIMEOUT = "5 seconds";
431const GIT_CHECK_IGNORE_MAX_STDIN_BYTES = 256 * 1024;
432const CHECKPOINT_DIFF_MAX_OUTPUT_BYTES = 10_000_000;
433const WORKSPACE_GIT_HARDENED_CONFIG_ARGS = [
434 "-c",
435 "core.fsmonitor=false",
436 "-c",
437 "core.untrackedCache=false",
438] as const;
439
440const nowFreshness = Effect.fn("GitVcsDriver.nowFreshness")(function* () {
441 const now = yield* DateTime.now;
442 return {
443 source: "live-local" as const,
444 observedAt: now,
445 expiresAt: Option.none(),
446 };
447});
448
449function chunkPathsForGitCheckIgnore(relativePaths: ReadonlyArray<string>): string[][] {
450 const chunks: string[][] = [];
451 let chunk: string[] = [];
452 let chunkBytes = 0;
453
454 for (const relativePath of relativePaths) {
455 const relativePathBytes = Buffer.byteLength(relativePath) + 1;
456 if (chunk.length > 0 && chunkBytes + relativePathBytes > GIT_CHECK_IGNORE_MAX_STDIN_BYTES) {
457 chunks.push(chunk);
458 chunk = [];
459 chunkBytes = 0;
460 }
461
462 chunk.push(relativePath);
463 chunkBytes += relativePathBytes;
464
465 if (chunkBytes >= GIT_CHECK_IGNORE_MAX_STDIN_BYTES) {
466 chunks.push(chunk);
467 chunk = [];
468 chunkBytes = 0;
469 }
470 }
471
472 if (chunk.length > 0) {
473 chunks.push(chunk);
474 }
475
476 return chunks;
477}
478
479function parseGitRemoteVerboseOutput(
480 output: string,
481): Map<string, { url?: string; pushUrl?: string }> {
482 const remotes = new Map<string, { url?: string; pushUrl?: string }>();
483 for (const line of output.split("\n")) {
484 const trimmed = line.trim();
485 if (trimmed.length === 0) {
486 continue;
487 }
488
489 const match = /^(\S+)\s+(\S+)\s+\((fetch|push)\)$/.exec(trimmed);
490 if (!match) {
491 continue;
492 }
493
494 const name = match[1];
495 const url = match[2];
496 const direction = match[3];
497 if (!name || !url || !direction) {
498 continue;
499 }
500 const remote = remotes.get(name) ?? {};
501 if (direction === "fetch") {
502 remote.url = url;
503 } else {
504 remote.pushUrl = url;
505 }
506 remotes.set(name, remote);
507 }
508 return remotes;
509}
510
511const gitCommand = (
512 process: VcsProcess.VcsProcess["Service"],
513 operation: string,
514 cwd: string,
515 args: ReadonlyArray<string>,
516 options?: {
517 readonly stdin?: string;
518 readonly env?: NodeJS.ProcessEnv;
519 readonly allowNonZeroExit?: boolean;
520 readonly timeoutMs?: number;
521 readonly maxOutputBytes?: number;
522 readonly outputMode?: VcsProcess.VcsProcessInput["outputMode"];
523 readonly appendTruncationMarker?: boolean;
524 },
525) =>
526 process.run({
527 operation,
528 command: "git",
529 args: ["-C", cwd, ...args],
530 cwd,
531 spawnCwd: globalThis.process.cwd(),
532 ...(options?.stdin !== undefined ? { stdin: options.stdin } : {}),
533 ...(options?.env !== undefined ? { env: options.env } : {}),
534 ...(options?.allowNonZeroExit !== undefined
535 ? { allowNonZeroExit: options.allowNonZeroExit }
536 : {}),
537 ...(options?.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
538 ...(options?.maxOutputBytes !== undefined ? { maxOutputBytes: options.maxOutputBytes } : {}),
539 ...(options?.outputMode !== undefined ? { outputMode: options.outputMode } : {}),
540 ...(options?.appendTruncationMarker !== undefined
541 ? { appendTruncationMarker: options.appendTruncationMarker }
542 : {}),
543 });
544
545export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* () {
546 const fileSystem = yield* FileSystem.FileSystem;
547 const path = yield* Path.Path;
548 const vcsProcess = yield* VcsProcess.VcsProcess;
549 const crypto = yield* Crypto.Crypto;
550 const capabilities = {
551 kind: "git" as const,
552 supportsWorktrees: true,
553 supportsBookmarks: false,
554 supportsAtomicSnapshot: false,
555 supportsPushDefaultRemote: true,
556 ignoreClassifier: "native" as const,
557 };
558
559 const isInsideWorkTree: VcsDriver.VcsDriver["Service"]["isInsideWorkTree"] = (cwd) =>
560 gitCommand(
561 vcsProcess,
562 "GitVcsDriver.isInsideWorkTree",
563 cwd,
564 ["rev-parse", "--is-inside-work-tree"],
565 {
566 allowNonZeroExit: true,
567 timeoutMs: 5_000,
568 maxOutputBytes: 4_096,
569 },
570 ).pipe(Effect.map((result) => result.exitCode === 0 && result.stdout.trim() === "true"));
571
572 const execute: VcsDriver.VcsDriver["Service"]["execute"] = (input) =>
573 gitCommand(vcsProcess, input.operation, input.cwd, input.args, {
574 ...(input.stdin !== undefined ? { stdin: input.stdin } : {}),
575 ...(input.env !== undefined ? { env: input.env } : {}),
576 ...(input.allowNonZeroExit !== undefined ? { allowNonZeroExit: input.allowNonZeroExit } : {}),
577 ...(input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {}),
578 ...(input.maxOutputBytes !== undefined ? { maxOutputBytes: input.maxOutputBytes } : {}),
579 ...(input.outputMode !== undefined ? { outputMode: input.outputMode } : {}),
580 ...(input.appendTruncationMarker !== undefined
581 ? { appendTruncationMarker: input.appendTruncationMarker }
582 : {}),
583 });
584
585 const detectRepository: VcsDriver.VcsDriver["Service"]["detectRepository"] = Effect.fn(
586 "detectRepository",
587 )(function* (cwd) {
588 if (!(yield* isInsideWorkTree(cwd))) {
589 return null;
590 }
591
592 const root = yield* gitCommand(vcsProcess, "GitVcsDriver.detectRepository.root", cwd, [
593 "rev-parse",
594 "--show-toplevel",
595 ]);
596 const gitCommonDir = yield* gitCommand(
597 vcsProcess,
598 "GitVcsDriver.detectRepository.commonDir",
599 cwd,
600 ["rev-parse", "--git-common-dir"],
601 ).pipe(Effect.orElseSucceed(() => null));
602
603 return {
604 kind: "git" as const,
605 rootPath: root.stdout.trim(),
606 metadataPath: gitCommonDir?.stdout.trim() || null,
607 freshness: yield* nowFreshness(),
608 };
609 });
610
611 const listWorkspaceFiles: VcsDriver.VcsDriver["Service"]["listWorkspaceFiles"] = (cwd) =>
612 gitCommand(
613 vcsProcess,
614 "GitVcsDriver.listWorkspaceFiles",
615 cwd,
616 [
617 ...WORKSPACE_GIT_HARDENED_CONFIG_ARGS,
618 "ls-files",
619 "--cached",
620 "--others",
621 "--exclude-standard",
622 "-z",
623 ],
624 {
625 allowNonZeroExit: true,
626 timeoutMs: 20_000,
627 maxOutputBytes: WORKSPACE_FILES_MAX_OUTPUT_BYTES,
628 appendTruncationMarker: true,
629 },
630 ).pipe(
631 Effect.flatMap((result) =>
632 result.exitCode === 0
633 ? Effect.gen(function* () {
634 const freshness = yield* nowFreshness();
635 return {
636 paths: splitNullSeparatedGitStdoutPaths(result),
637 truncated: result.stdoutTruncated,
638 freshness,
639 };
640 })
641 : Effect.fail(
642 new VcsProcessExitError({
643 operation: "GitVcsDriver.listWorkspaceFiles",
644 command: "git ls-files",
645 cwd,
646 exitCode: result.exitCode,
647 detail: result.stderr.trim() || "git ls-files failed",
648 }),
649 ),
650 ),
651 );
652
653 const listRemotes: VcsDriver.VcsDriver["Service"]["listRemotes"] = Effect.fn("listRemotes")(
654 function* (cwd) {
655 const result = yield* gitCommand(
656 vcsProcess,
657 "GitVcsDriver.listRemotes",
658 cwd,
659 ["remote", "-v"],
660 {
661 allowNonZeroExit: true,
662 timeoutMs: 5_000,
663 maxOutputBytes: 64 * 1024,
664 },
665 );
666
667 if (result.exitCode !== 0) {
668 return yield* new VcsProcessExitError({
669 operation: "GitVcsDriver.listRemotes",
670 command: "git remote -v",
671 cwd,
672 exitCode: result.exitCode,
673 detail: result.stderr.trim() || "git remote -v failed",
674 });
675 }
676
677 const parsed = parseGitRemoteVerboseOutput(result.stdout);
678 const remotes = Array.from(parsed.entries()).flatMap(([name, remote]) => {
679 if (!remote.url) {
680 return [];
681 }
682 return [
683 {
684 name,
685 url: remote.url,
686 pushUrl: remote.pushUrl ? Option.some(remote.pushUrl) : Option.none(),
687 isPrimary: name === "origin",
688 },
689 ];
690 });
691
692 return {
693 remotes,
694 freshness: yield* nowFreshness(),
695 };
696 },
697 );
698
699 const filterIgnoredPaths: VcsDriver.VcsDriver["Service"]["filterIgnoredPaths"] = Effect.fn(
700 "filterIgnoredPaths",
701 )(function* (cwd, relativePaths) {
702 if (relativePaths.length === 0) {
703 return relativePaths;
704 }
705
706 const ignoredPaths = new Set<string>();
707 const chunks = chunkPathsForGitCheckIgnore(relativePaths);
708
709 for (const chunk of chunks) {
710 const result = yield* gitCommand(
711 vcsProcess,
712 "GitVcsDriver.filterIgnoredPaths",
713 cwd,
714 [...WORKSPACE_GIT_HARDENED_CONFIG_ARGS, "check-ignore", "--no-index", "-z", "--stdin"],
715 {
716 stdin: `${chunk.join("\0")}\0`,
717 allowNonZeroExit: true,
718 timeoutMs: 20_000,
719 maxOutputBytes: WORKSPACE_FILES_MAX_OUTPUT_BYTES,
720 appendTruncationMarker: true,
721 },
722 );
723
724 if (result.exitCode !== 0 && result.exitCode !== 1) {
725 return yield* new VcsProcessExitError({
726 operation: "GitVcsDriver.filterIgnoredPaths",
727 command: "git check-ignore",
728 cwd,
729 exitCode: result.exitCode,
730 detail: result.stderr.trim() || "git check-ignore failed",
731 });
732 }
733
734 for (const ignoredPath of splitNullSeparatedGitStdoutPaths(result)) {
735 ignoredPaths.add(ignoredPath);
736 }
737 }
738
739 if (ignoredPaths.size === 0) {
740 return relativePaths;
741 }
742
743 return relativePaths.filter((relativePath) => !ignoredPaths.has(relativePath));
744 });
745
746 const initRepository: VcsDriver.VcsDriver["Service"]["initRepository"] = (input) =>
747 gitCommand(vcsProcess, "GitVcsDriver.initRepository", input.cwd, ["init"], {
748 timeoutMs: 10_000,
749 maxOutputBytes: 64 * 1024,
750 }).pipe(Effect.asVoid);
751
752 const resolveHeadCommit = (cwd: string) =>
753 execute({
754 operation: "GitVcsDriver.checkpoints.resolveHeadCommit",
755 cwd,
756 args: ["rev-parse", "--verify", "--quiet", "HEAD^{commit}"],
757 allowNonZeroExit: true,
758 }).pipe(
759 Effect.map((result) => {
760 if (result.exitCode !== 0) {
761 return null;
762 }
763 const commit = result.stdout.trim();
764 return commit.length > 0 ? commit : null;
765 }),
766 );
767
768 const hasHeadCommit = (cwd: string, env?: NodeJS.ProcessEnv) =>
769 execute({
770 operation: "GitVcsDriver.checkpoints.hasHeadCommit",
771 cwd,
772 args: ["rev-parse", "--verify", "HEAD"],
773 allowNonZeroExit: true,
774 ...(env !== undefined ? { env } : {}),
775 }).pipe(Effect.map((result) => result.exitCode === 0));
776
777 const resolveCheckpointCommit = (cwd: string, checkpointRef: string) =>
778 execute({
779 operation: "GitVcsDriver.checkpoints.resolveCheckpointCommit",
780 cwd,
781 args: ["rev-parse", "--verify", "--quiet", `${checkpointRef}^{commit}`],
782 allowNonZeroExit: true,
783 }).pipe(
784 Effect.map((result) => {
785 if (result.exitCode !== 0) {
786 return null;
787 }
788 const commit = result.stdout.trim();
789 return commit.length > 0 ? commit : null;
790 }),
791 );
792
793 const resolveGitCommonDir = (cwd: string) =>
794 Effect.gen(function* () {
795 const result = yield* execute({
796 operation: "GitVcsDriver.checkpoints.resolveGitCommonDir",
797 cwd,
798 args: ["rev-parse", "--git-common-dir"],
799 });
800 const gitCommonDir = result.stdout.trim();
801 return path.isAbsolute(gitCommonDir) ? gitCommonDir : path.resolve(cwd, gitCommonDir);
802 });
803
804 // Git renames loose objects and refs into place without fsync by default, so
805 // an unclean restart can leave 0-byte files under refs/t3/** that break every
806 // later fetch and push. Checkpoint writes flush before they are published;
807 // macOS defaults to writeout-only, which does not reach the disk either.
808 const durableWrite = [
809 "-c",
810 "core.fsync=objects,reference",
811 "-c",
812 "core.fsyncMethod=fsync",
813 ] as const;
814
815 const checkpoints: VcsDriver.VcsCheckpointOps = {
816 captureCheckpoint: Effect.fn("GitVcsDriver.checkpoints.captureCheckpoint")(function* (input) {
817 const operation = VcsProcess.CHECKPOINT_CAPTURE_OPERATION;
818 const indexConfig = [
819 "-c",
820 "core.fsmonitor=false",
821 "-c",
822 "sparse.expectFilesOutsideOfPatterns=false",
823 ];
824 const gitCommonDir = yield* resolveGitCommonDir(input.cwd);
825 const indexId = yield* crypto.randomUUIDv4().pipe(Effect.orDie);
826 const tempIndexPath = path.join(gitCommonDir, `t3-checkpoint-index-${indexId}`);
827 const commitEnv: NodeJS.ProcessEnv = {
828 ...process.env,
829 GIT_INDEX_FILE: tempIndexPath,
830 GIT_AUTHOR_NAME: "T3 Code",
831 GIT_AUTHOR_EMAIL: "t3code@users.noreply.github.com",
832 GIT_COMMITTER_NAME: "T3 Code",
833 GIT_COMMITTER_EMAIL: "t3code@users.noreply.github.com",
834 };
835
836 // Forced process termination can leave Git's private index lock behind.
837 const cleanupTempIndex = Effect.forEach(
838 [tempIndexPath, `${tempIndexPath}.lock`],
839 (indexFile) => fileSystem.remove(indexFile, { force: true }).pipe(Effect.ignore),
840 { discard: true },
841 );
842
843 yield* Effect.gen(function* () {
844 const headExists = yield* hasHeadCommit(input.cwd);
845 const sparseConfig = yield* execute({
846 operation,
847 cwd: input.cwd,
848 args: ["config", "--bool", "core.sparseCheckout"],
849 allowNonZeroExit: true,
850 });
851 let sparseCheckout = sparseConfig.stdout.trim() === "true";
852 if (sparseCheckout) {
853 const help = yield* execute({
854 operation,
855 cwd: input.cwd,
856 args: ["add", "-h"],
857 allowNonZeroExit: true,
858 });
859 sparseCheckout = /--(?:\[no-\])?sparse\b/.test(`${help.stdout}${help.stderr}`);
860 }
861 if (headExists) {
862 const reusedIndex = yield* Effect.gen(function* () {
863 const indexPath = yield* execute({
864 operation,
865 cwd: input.cwd,
866 args: ["rev-parse", "--path-format=absolute", "--git-path", "index"],
867 });
868 const { mtime } = yield* fileSystem.stat(indexPath.stdout.trim());
869 if (Option.isNone(mtime)) return false;
870 // Stay below the source timestamp even if Date rounded up, preserving Git's racy check.
871 const indexTime = Math.floor((mtime.value.getTime() - 1) / 1000);
872 if (indexTime <= 0) return false;
873 yield* fileSystem.copyFile(indexPath.stdout.trim(), tempIndexPath);
874 // Retain stat data only where the copied index already matches HEAD.
875 yield* execute({
876 operation,
877 cwd: input.cwd,
878 args: [...indexConfig, "read-tree", "--reset", "HEAD"],
879 env: commitEnv,
880 });
881 // read-tree can rewrite the index, so restore its racy timestamp afterward.
882 yield* fileSystem.utimes(tempIndexPath, indexTime, indexTime);
883 let specialFlags = false;
884 let recordStart = true;
885 let skipped = false;
886 let skippedRecord: number[] = [];
887 const skippedPaths: string[] = [];
888 yield* vcsProcess.run({
889 operation,
890 command: "git",
891 cwd: input.cwd,
892 args: [...indexConfig, "ls-files", "--full-name", "--sparse", "-v", "-z"],
893 env: commitEnv,
894 maxOutputBytes: 4_096,
895 outputMode: "truncate",
896 // Inspect every tag; retain only skipped file paths for checking sparse rules.
897 onStdoutChunk: (chunk) => {
898 for (const byte of chunk) {
899 if (recordStart) skipped = byte === 83;
900 if (skipped && sparseCheckout) {
901 if (byte !== 0) skippedRecord.push(byte);
902 else {
903 if (skippedRecord.at(-1) !== 47) {
904 const name = Buffer.from(skippedRecord).subarray(2);
905 if (!NodeBuffer.isUtf8(name)) specialFlags = true;
906 else skippedPaths.push(name.toString("utf8"));
907 }
908 skippedRecord = [];
909 }
910 }
911 if (
912 recordStart &&
913 ((byte >= 97 && byte <= 122) || (!sparseCheckout && byte === 83))
914 ) {
915 specialFlags = true;
916 }
917 recordStart = byte === 0;
918 }
919 },
920 });
921 if (skippedPaths.length > 0 && !specialFlags) {
922 const selected = yield* execute({
923 operation,
924 cwd: input.cwd,
925 args: [...indexConfig, "sparse-checkout", "check-rules", "-z"],
926 stdin: skippedPaths.join("\0") + "\0",
927 env: commitEnv,
928 maxOutputBytes: 1,
929 outputMode: "truncate",
930 });
931 // Any selected skipped file has a manual flag, not a sparse exclusion.
932 specialFlags = selected.stdout.length > 0 || selected.stdoutTruncated;
933 }
934 // Sparse Git clears skip-worktree for present files. Manual flags still need a reset.
935 return !specialFlags;
936 }).pipe(Effect.orElseSucceed(() => false));
937 if (!reusedIndex) {
938 if (sparseCheckout) {
939 const cone = yield* execute({
940 operation,
941 cwd: input.cwd,
942 args: ["config", "--bool", "core.sparseCheckoutCone"],
943 allowNonZeroExit: true,
944 });
945 // Rebuilding a non-cone index loses exclusions; do not publish false deletions.
946 if (cone.stdout.trim() !== "true") {
947 return yield* new VcsProcessExitError({
948 operation,
949 command: "git read-tree",
950 cwd: input.cwd,
951 exitCode: 1,
952 detail: "Cannot rebuild a checkpoint index for non-cone sparse checkout.",
953 });
954 }
955 }
956 yield* cleanupTempIndex;
957 yield* execute({
958 operation,
959 cwd: input.cwd,
960 // A fresh sparse index represents excluded directories without marking them deleted.
961 args: sparseCheckout
962 ? [...indexConfig, "-c", "index.sparse=true", "read-tree", "--reset", "HEAD"]
963 : ["read-tree", "HEAD"],
964 env: commitEnv,
965 });
966 }
967 }
968
969 const stageFiles = (exclusions: ReadonlyArray<string>) =>
970 execute({
971 operation,
972 cwd: input.cwd,
973 // Preserve absent skipped entries, but capture present nonignored files outside the cone.
974 args: [
975 ...indexConfig,
976 ...durableWrite,
977 "add",
978 ...(sparseCheckout ? ["--sparse"] : []),
979 "-A",
980 "--",
981 ".",
982 ...exclusions,
983 ],
984 env: commitEnv,
985 });
986 yield* stageFiles([]).pipe(
987 Effect.catchTags({
988 VcsProcessExitError: (error) =>
989 Effect.gen(function* () {
990 // Git cannot stage an embedded repository until it has a commit. Discover these
991 // only after staging fails so ordinary checkpoints do not need another file scan.
992 const untracked = yield* execute({
993 operation,
994 cwd: input.cwd,
995 args: ["ls-files", "--others", "--exclude-standard", "-z", "--", "."],
996 env: commitEnv,
997 maxOutputBytes: WORKSPACE_FILES_MAX_OUTPUT_BYTES,
998 });
999 if (untracked.stdoutTruncated) return yield* error;
1000 const candidates = splitNullSeparatedGitStdoutPaths(untracked).filter((entry) =>
1001 entry.endsWith("/"),
1002 );
1003 // Refuse excessive recovery work before probing any nested repositories.
1004 if (candidates.length > CHECKPOINT_RECOVERY_MAX_CANDIDATES) return yield* error;
1005 // Discover each child's repository instead of inheriting the server's Git bindings.
1006 const nestedRepoEnv: NodeJS.ProcessEnv = {
1007 ...process.env,
1008 GIT_DIR: undefined,
1009 GIT_WORK_TREE: undefined,
1010 GIT_COMMON_DIR: undefined,
1011 GIT_INDEX_FILE: undefined,
1012 GIT_OBJECT_DIRECTORY: undefined,
1013 GIT_ALTERNATE_OBJECT_DIRECTORIES: undefined,
1014 };
1015 const exclusions: Array<string> = [];
1016 for (const entry of candidates) {
1017 const nestedCwd = path.join(input.cwd, entry);
1018 if (
1019 (yield* fileSystem
1020 .exists(path.join(nestedCwd, ".git"))
1021 .pipe(Effect.mapError(() => error))) &&
1022 !(yield* hasHeadCommit(nestedCwd, nestedRepoEnv))
1023 ) {
1024 exclusions.push(`:(exclude,literal)${entry}`);
1025 }
1026 }
1027 if (exclusions.length === 0) return yield* error;
1028 return yield* stageFiles(exclusions);
1029 }).pipe(
1030 // One budget covers discovery, queued Git admission, probes, and the staging retry.
1031 Effect.timeoutOrElse({
1032 duration: CHECKPOINT_RECOVERY_TIMEOUT,
1033 orElse: () => Effect.fail(error),
1034 }),
1035 ),
1036 }),
1037 );
1038
1039 const writeTreeResult = yield* execute({
1040 operation,
1041 cwd: input.cwd,
1042 args: [...indexConfig, ...durableWrite, "write-tree"],
1043 env: commitEnv,
1044 });
1045 const treeOid = writeTreeResult.stdout.trim();
1046 if (treeOid.length === 0) {
1047 return yield* new VcsProcessExitError({
1048 operation,
1049 command: "git write-tree",
1050 cwd: input.cwd,
1051 exitCode: 0,
1052 detail: "git write-tree returned an empty tree oid.",
1053 });
1054 }
1055
1056 const message = `t3 checkpoint ref=${input.checkpointRef}`;
1057 // HEAD is the parent so listAuthoredPaths can see how HEAD moved between checkpoints.
1058 const commitTreeResult = yield* execute({
1059 operation,
1060 cwd: input.cwd,
1061 args: [
1062 ...durableWrite,
1063 "commit-tree",
1064 treeOid,
1065 ...(headExists ? ["-p", "HEAD"] : []),
1066 "-m",
1067 message,
1068 ],
1069 env: commitEnv,
1070 });
1071 const commitOid = commitTreeResult.stdout.trim();
1072 if (commitOid.length === 0) {
1073 return yield* new VcsProcessExitError({
1074 operation,
1075 command: "git commit-tree",
1076 cwd: input.cwd,
1077 exitCode: 0,
1078 detail: "git commit-tree returned an empty commit oid.",
1079 });
1080 }
1081
1082 yield* execute({
1083 operation,
1084 cwd: input.cwd,
1085 args: [...durableWrite, "update-ref", input.checkpointRef, commitOid],
1086 });
1087 }).pipe(Effect.ensuring(cleanupTempIndex));
1088 }),
1089
1090 hasCheckpointRef: (input) =>
1091 resolveCheckpointCommit(input.cwd, input.checkpointRef).pipe(
1092 Effect.map((commit) => commit !== null),
1093 ),
1094
1095 restoreCheckpoint: Effect.fn("GitVcsDriver.checkpoints.restoreCheckpoint")(function* (input) {
1096 const operation = "GitVcsDriver.checkpoints.restoreCheckpoint";
1097
1098 let commitOid = yield* resolveCheckpointCommit(input.cwd, input.checkpointRef);
1099
1100 if (!commitOid && input.fallbackToHead === true) {
1101 commitOid = yield* resolveHeadCommit(input.cwd);
1102 }
1103
1104 if (!commitOid) {
1105 return false;
1106 }
1107
1108 const tracked = yield* execute({
1109 operation,
1110 cwd: input.cwd,
1111 args: ["ls-files", "--cached", `--with-tree=${commitOid}`, "-z", "--", "."],
1112 });
1113 // An empty index and checkpoint have nothing for git restore's pathspec to match.
1114 if (tracked.stdout.length > 0) {
1115 yield* execute({
1116 operation,
1117 cwd: input.cwd,
1118 args: ["restore", "--source", commitOid, "--worktree", "--staged", "--", "."],
1119 });
1120 }
1121 // Restoring away the last tracked file can remove a nested workspace directory.
1122 const recreateWorkspace = fileSystem.makeDirectory(input.cwd, { recursive: true }).pipe(
1123 Effect.mapError(
1124 (cause) =>
1125 new VcsProcessExitError({
1126 operation,
1127 command: "git restore",
1128 cwd: input.cwd,
1129 exitCode: 0,
1130 detail: `Could not recreate the checkpoint workspace: ${cause.message}`,
1131 }),
1132 ),
1133 );
1134 yield* recreateWorkspace;
1135 const cleaned = yield* execute({
1136 operation,
1137 cwd: input.cwd,
1138 args: ["clean", "-fd", "--", "."],
1139 allowNonZeroExit: true,
1140 });
1141 if (cleaned.exitCode !== 0) {
1142 // Git can remove every child, then fail trying to remove './' itself.
1143 const emptiedWorkspace =
1144 cleaned.exitCode === 1 &&
1145 /^warning: failed to remove \.\/: [^\n]+$/.test(cleaned.stderr.trim()) &&
1146 (yield* fileSystem.readDirectory(input.cwd).pipe(
1147 Effect.map((entries) => entries.length === 0),
1148 Effect.orElseSucceed(() => false),
1149 ));
1150 if (!emptiedWorkspace)
1151 return yield* new VcsProcessExitError({
1152 operation,
1153 command: "git clean",
1154 cwd: input.cwd,
1155 exitCode: cleaned.exitCode,
1156 detail: cleaned.stderr.trim() || "Could not clean the checkpoint workspace.",
1157 });
1158 }
1159 // On Windows the same clean can remove './' and exit 0.
1160 yield* recreateWorkspace;
1161
1162 const headExists = yield* hasHeadCommit(input.cwd);
1163 if (headExists) {
1164 yield* execute({
1165 operation,
1166 cwd: input.cwd,
1167 args: ["reset", "--quiet", "--", "."],
1168 });
1169 }
1170
1171 return true;
1172 }),
1173
1174 diffCheckpoints: Effect.fn("GitVcsDriver.checkpoints.diffCheckpoints")(function* (input) {
1175 const operation = "GitVcsDriver.checkpoints.diffCheckpoints";
1176 if (input.filePaths?.length === 0) return "";
1177 yield* Effect.annotateCurrentSpan({
1178 "checkpoint.cwd": input.cwd,
1179 "checkpoint.from_ref": input.fromCheckpointRef,
1180 "checkpoint.to_ref": input.toCheckpointRef,
1181 "checkpoint.ignore_whitespace": input.ignoreWhitespace,
1182 "checkpoint.format": input.format ?? "patch",
1183 "checkpoint.fallback_from_to_head": input.fallbackFromToHead,
1184 });
1185
1186 let fromRevision: string = input.fromCheckpointRef;
1187 if (input.fallbackFromToHead === true) {
1188 const resolvedFromCommit = yield* resolveCheckpointCommit(
1189 input.cwd,
1190 input.fromCheckpointRef,
1191 );
1192 if (resolvedFromCommit) {
1193 fromRevision = resolvedFromCommit;
1194 } else {
1195 const headCommit = yield* resolveHeadCommit(input.cwd);
1196 if (!headCommit) {
1197 return yield* new VcsProcessExitError({
1198 operation,
1199 command: "git diff",
1200 cwd: input.cwd,
1201 exitCode: 1,
1202 detail: "Checkpoint ref is unavailable for diff operation.",
1203 });
1204 }
1205 fromRevision = headCommit;
1206 }
1207 }
1208
1209 const result = yield* execute({
1210 operation,
1211 cwd: input.cwd,
1212 args: [
1213 "diff",
1214 ...(input.format === "numstat" ? ["--numstat", "-z"] : ["--patch"]),
1215 "--no-color",
1216 "--no-ext-diff",
1217 "--no-textconv",
1218 ...PATCH_RENDER_PREFIX_ARGS,
1219 ...(input.ignoreWhitespace ? ["--ignore-all-space"] : []),
1220 `${fromRevision}^{commit}`,
1221 `${input.toCheckpointRef}^{commit}`,
1222 // Paths are repository-relative, while cwd can be a subdirectory.
1223 ...(input.filePaths
1224 ? ["--", ...input.filePaths.map((file) => `:(top,literal)${file}`)]
1225 : []),
1226 ],
1227 allowNonZeroExit: true,
1228 maxOutputBytes: CHECKPOINT_DIFF_MAX_OUTPUT_BYTES,
1229 outputMode: input.format === "numstat" ? "error" : "truncate",
1230 });
1231
1232 if (result.exitCode !== 0) {
1233 return yield* new VcsProcessExitError({
1234 operation,
1235 command: "git diff",
1236 cwd: input.cwd,
1237 exitCode: result.exitCode,
1238 detail: result.stderr.trim() || "Checkpoint ref is unavailable for diff operation.",
1239 });
1240 }
1241
1242 return result.stdout;
1243 }),
1244
1245 listAuthoredPaths: Effect.fn("GitVcsDriver.checkpoints.listAuthoredPaths")(function* (input) {
1246 const operation = "GitVcsDriver.checkpoints.listAuthoredPaths";
1247 const heads = yield* execute({
1248 operation,
1249 cwd: input.cwd,
1250 args: [
1251 "log",
1252 "--no-walk=unsorted",
1253 "--format=%P %ct",
1254 `${input.fromCheckpointRef}^{commit}`,
1255 `${input.toCheckpointRef}^{commit}`,
1256 ],
1257 });
1258 // Each line is "<parent> <committer time>". The parent is the HEAD at capture.
1259 const [fromLine = "", toLine = ""] = heads.stdout.trimEnd().split("\n");
1260 const [startHead = "", capturedAt = ""] = fromLine.split(" ");
1261 const [endHead = ""] = toLine.split(" ");
1262 if (startHead === "" || endHead === "" || startHead === endHead) {
1263 return null;
1264 }
1265
1266 const listPaths = (args: ReadonlyArray<string>) =>
1267 execute({
1268 operation,
1269 cwd: input.cwd,
1270 args: [...args, "--name-only", "-z", "--no-renames", "--no-ext-diff"],
1271 maxOutputBytes: CHECKPOINT_DIFF_MAX_OUTPUT_BYTES,
1272 outputMode: "error",
1273 }).pipe(Effect.map((result) => result.stdout.split("\0")));
1274 // Remerge diff lists a merge's paths only where the result differs from Git's
1275 // automatic merge, so a merge keeps its conflict fixes and drops clean upstream changes.
1276 const pathLists = yield* Effect.all(
1277 [
1278 listPaths(["diff", startHead, `${input.fromCheckpointRef}^{commit}`]),
1279 listPaths(["diff", endHead, `${input.toCheckpointRef}^{commit}`]),
1280 listPaths(["log", "--format=", "--diff-merges=remerge", `${endHead}..${startHead}`]),
1281 listPaths([
1282 "log",
1283 "--format=",
1284 "--diff-merges=remerge",
1285 `--since=@${capturedAt}`,
1286 `${startHead}..${endHead}`,
1287 ]),
1288 ],
1289 { concurrency: "unbounded" },
1290 );
1291 return new Set(pathLists.flat().filter((path) => path.length > 0));
1292 }),
1293
1294 deleteCheckpointRefs: Effect.fn("GitVcsDriver.checkpoints.deleteCheckpointRefs")(
1295 function* (input) {
1296 yield* Effect.forEach(
1297 input.checkpointRefs,
1298 (checkpointRef) =>
1299 execute({
1300 operation: "GitVcsDriver.checkpoints.deleteCheckpointRefs",
1301 cwd: input.cwd,
1302 args: ["update-ref", "-d", checkpointRef],
1303 allowNonZeroExit: true,
1304 }),
1305 { discard: true },
1306 );
1307 },
1308 ),
1309 };
1310
1311 return {
1312 capabilities,
1313 execute,
1314 checkpoints,
1315 detectRepository,
1316 isInsideWorkTree,
1317 listWorkspaceFiles,
1318 listRemotes,
1319 filterIgnoredPaths,
1320 initRepository,
1321 };
1322});
1323
1324export const makeVcsDriver = Effect.gen(function* () {
1325 const driver = yield* makeVcsDriverShape();
1326 return VcsDriver.VcsDriver.of(driver);
1327});
1328
1329export const make = Effect.gen(function* () {
1330 const git = yield* makeGitVcsDriverCore();
1331 return GitVcsDriver.of(git);
1332});
1333
1334export const layerVcs = Layer.effect(VcsDriver.VcsDriver, makeVcsDriver);
1335export const layer = Layer.effect(GitVcsDriver, make);