1import * as Cache from "effect/Cache";
2import * as Data from "effect/Data";
3import * as Crypto from "effect/Crypto";
4import * as DateTime from "effect/DateTime";
5import * as Duration from "effect/Duration";
6import * as Effect from "effect/Effect";
7import * as Hex from "effect/encoding/Hex";
8import * as Exit from "effect/Exit";
9import * as FileSystem from "effect/FileSystem";
10import * as Option from "effect/Option";
11import * as Path from "effect/Path";
12import * as PlatformError from "effect/PlatformError";
13import * as Ref from "effect/Ref";
14import * as Result from "effect/Result";
15import * as Schema from "effect/Schema";
16import * as Scope from "effect/Scope";
17import * as Semaphore from "effect/Semaphore";
18import * as Stream from "effect/Stream";
19import { ChildProcess, ChildProcessSpawner } from "effect/process";
20
21import {
22 GitCommandError,
23 type GitCommandFailureReason,
24 T3_PROJECT_FILE_NAME,
25 type ReviewDiffFileContentsInput,
26 type ReviewDiffPreviewInput,
27 type ReviewDiffFileStat,
28 type ReviewDiffPreviewSource,
29 type VcsRef,
30} from "@t3tools/contracts";
31import { dedupeRemoteBranchesWithLocalMatches, normalizeGitRemoteUrl } from "@t3tools/shared/git";
32import * as HostProcess from "@t3tools/shared/HostProcess";
33import { compactTraceAttributes } from "@t3tools/shared/observability";
34import { decodeJsonResult } from "@t3tools/shared/schemaJson";
35import { resolveSpawnCommand } from "@t3tools/shared/shell";
36import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile";
37import { resolveProjectFileBackedSetting } from "@t3tools/shared/projectSettings";
38import { gitCommandDuration, gitCommandsTotal, withMetrics } from "../observability/Metrics.ts";
39import * as GitVcsDriver from "./GitVcsDriver.ts";
40import { resolveWorktreesDirectory } from "../worktreesDirectory.ts";
41import {
42 parseRemoteNames,
43 parseRemoteNamesInGitOrder,
44 parseRemoteRefWithRemoteNames,
45} from "../git/remoteRefs.ts";
46import * as ServerConfig from "../config.ts";
47
48const DEFAULT_TIMEOUT_MS = 30_000;
49const gitProcesses = Semaphore.makeUnsafe(8);
50// `git worktree add` checks out the full tree, so on large repositories it can
51// take well beyond the default 30s (e.g. a 375k-file repo takes ~40s on an idle
52// machine). Give it generous headroom while still bounding a genuinely hung git.
53const WORKTREE_ADD_TIMEOUT_MS = 300_000;
54const WORKTREE_REMOVE_TIMEOUT_MS = Duration.toMillis(Duration.minutes(5));
55const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000;
56const OUTPUT_TRUNCATED_MARKER = "\n\n[truncated]";
57const PREPARED_COMMIT_PATCH_MAX_OUTPUT_BYTES = 49_000;
58const RANGE_COMMIT_SUMMARY_MAX_OUTPUT_BYTES = 19_000;
59const RANGE_DIFF_SUMMARY_MAX_OUTPUT_BYTES = 19_000;
60const RANGE_DIFF_PATCH_MAX_OUTPUT_BYTES = 59_000;
61const REVIEW_DIFF_PATCH_MAX_OUTPUT_BYTES = 120_000;
62const REVIEW_METADATA_MAX_OUTPUT_BYTES = 16 * 1024 * 1024;
63// Every status refresh adds the untracked files to a temporary index, and git add's cost grows
64// much faster than the file count (50k files took about 30 seconds). Past this many, the
65// totals are reported incomplete instead.
66const REVIEW_UNTRACKED_MAX_FILES = 5_000;
67const REVIEW_DIFF_FILE_MAX_OUTPUT_BYTES = 1024 * 1024;
68// Patches the clients render are parsed against git's default a/ and b/ path
69// prefixes. A repository or global diff.noprefix or diff.mnemonicPrefix would
70// otherwise leak into the patch and leave every parsed file unnamed.
71export const PATCH_RENDER_PREFIX_ARGS = ["--src-prefix=a/", "--dst-prefix=b/"] as const;
72// Shared by review previews and status totals, so the Changes row matches the Changes view.
73const REVIEW_DIFF_ARGS = [
74 "diff",
75 "--find-renames",
76 "--no-color",
77 "--no-ext-diff",
78 "--no-textconv",
79 "--minimal",
80 ...PATCH_RENDER_PREFIX_ARGS,
81];
82const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15);
83const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5);
84
85const STATUS_UPSTREAM_REFRESH_FAILURE_BASE_COOLDOWN = Duration.seconds(30);
86const STATUS_UPSTREAM_REFRESH_FAILURE_MAX_COOLDOWN = Duration.minutes(15);
87const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048;
88const REPOSITORY_PATHS_CACHE_CAPACITY = 2_048;
89const REPOSITORY_PATHS_CACHE_TTL = Duration.minutes(10);
90const REPOSITORY_PATHS_REFRESH_COALESCE_TTL = Duration.seconds(5);
91const NON_REPOSITORY_PATHS_CACHE_TTL = Duration.seconds(1);
92const LIST_REFS_SNAPSHOT_CACHE_CAPACITY = 64;
93const LIST_REFS_SNAPSHOT_CACHE_TTL = Duration.minutes(2);
94const LIST_REFS_REFRESH_COALESCE_TTL = Duration.seconds(5);
95const LIST_REFS_REFRESH_FAILURE_COOLDOWN = Duration.seconds(30);
96const STATUS_DEFAULT_BRANCH_CACHE_TTL = Duration.minutes(5);
97const STATUS_ORIGIN_EXISTS_CACHE_TTL = Duration.minutes(5);
98const STATUS_UPSTREAM_REFRESH_ENV = Object.freeze({
99 GCM_INTERACTIVE: "never",
100 GIT_ASKPASS: "",
101 GIT_TERMINAL_PROMPT: "0",
102 SSH_ASKPASS: "",
103 SSH_ASKPASS_REQUIRE: "never",
104} satisfies NodeJS.ProcessEnv);
105const DEFAULT_BASE_BRANCH_CANDIDATES = ["main", "master"] as const;
106const GIT_LIST_BRANCHES_DEFAULT_LIMIT = 100;
107const NON_REPOSITORY_STATUS_DETAILS = Object.freeze<GitVcsDriver.GitStatusDetails>({
108 isRepo: false,
109 hasOriginRemote: false,
110 isDefaultBranch: false,
111 branch: null,
112 upstreamRef: null,
113 hasWorkingTreeChanges: false,
114 workingTree: { files: [], insertions: 0, deletions: 0 },
115 hasUpstream: false,
116 aheadCount: 0,
117 behindCount: 0,
118 aheadOfDefaultCount: 0,
119});
120const NON_REPOSITORY_REMOTE_STATUS_DETAILS = Object.freeze<GitVcsDriver.GitRemoteStatusDetails>({
121 isRepo: false,
122 defaultBranch: null,
123 isDefaultBranch: false,
124 branch: null,
125 upstreamRef: null,
126 hasUpstream: false,
127 aheadCount: 0,
128 behindCount: 0,
129 aheadOfDefaultCount: 0,
130});
131
132type TraceTailState = {
133 processedChars: number;
134 remainder: string;
135};
136
137class StatusRemoteRefreshCacheKey extends Data.Class<{
138 gitCommonDir: string;
139 remoteName: string;
140}> {}
141
142function statusUpstreamRefreshFailureCooldown(consecutiveFailures: number): Duration.Duration {
143 const exponent = Math.max(0, consecutiveFailures - 1);
144 const cooldownMs =
145 Duration.toMillis(STATUS_UPSTREAM_REFRESH_FAILURE_BASE_COOLDOWN) * Math.pow(2, exponent);
146 return Duration.min(Duration.millis(cooldownMs), STATUS_UPSTREAM_REFRESH_FAILURE_MAX_COOLDOWN);
147}
148
149class GitRefsSnapshotCacheKey extends Data.Class<{
150 gitCommonDir: string;
151 epoch: number;
152}> {}
153
154class GitRefsRefreshCacheKey extends Data.Class<{
155 gitCommonDir: string;
156 generation: number;
157}> {}
158
159interface GitRepositoryPaths {
160 readonly gitCommonDir: string;
161 readonly worktreeRoot: string | null;
162 readonly currentBranch: string | null;
163}
164
165interface GitRefsSnapshot {
166 readonly localBranches: ReadonlyArray<VcsRef>;
167 readonly remoteBranches: ReadonlyArray<VcsRef>;
168 readonly hasPrimaryRemote: boolean;
169}
170
171interface ExecuteGitOptions {
172 stdin?: string | undefined;
173 timeoutMs?: number | null | undefined;
174 allowNonZeroExit?: boolean | undefined;
175 fallbackErrorDetail?: string | undefined;
176 env?: NodeJS.ProcessEnv | undefined;
177 maxOutputBytes?: number | undefined;
178 appendTruncationMarker?: boolean | undefined;
179 progress?: GitVcsDriver.ExecuteGitProgress | undefined;
180}
181
182function parseBranchAb(value: string): { ahead: number; behind: number } {
183 const match = value.match(/^\+(\d+)\s+-(\d+)$/);
184 if (!match) return { ahead: 0, behind: 0 };
185 return {
186 ahead: Number(match[1] ?? "0"),
187 behind: Number(match[2] ?? "0"),
188 };
189}
190
191function parseNumstatEntries(
192 stdout: string,
193): Array<{ path: string; insertions: number; deletions: number }> {
194 const entries: Array<{ path: string; insertions: number; deletions: number }> = [];
195 for (const line of stdout.split(/\r?\n/g)) {
196 if (line.trim().length === 0) continue;
197 const [addedRaw, deletedRaw, ...pathParts] = line.split("\t");
198 const rawPath =
199 pathParts.length > 1 ? (pathParts.at(-1) ?? "").trim() : pathParts.join("\t").trim();
200 if (rawPath.length === 0) continue;
201 const added = Number.parseInt(addedRaw ?? "0", 10);
202 const deleted = Number.parseInt(deletedRaw ?? "0", 10);
203 const renameArrowIndex = rawPath.indexOf(" => ");
204 const normalizedPath =
205 renameArrowIndex >= 0 ? rawPath.slice(renameArrowIndex + " => ".length).trim() : rawPath;
206 entries.push({
207 path: normalizedPath.length > 0 ? normalizedPath : rawPath,
208 insertions: Number.isFinite(added) ? added : 0,
209 deletions: Number.isFinite(deleted) ? deleted : 0,
210 });
211 }
212 return entries;
213}
214
215// -z preserves tabs/newlines in paths and gives renames two separate path fields.
216function parseReviewNumstat(stdout: string): ReviewDiffFileStat[] {
217 const fields = stdout.split("\0");
218 const files: ReviewDiffFileStat[] = [];
219 for (let index = 0; index < fields.length; index++) {
220 const field = fields[index]!;
221 const match = /^(\d+|-)\t(\d+|-)\t([\s\S]*)$/.exec(field);
222 if (!match) continue;
223 const previousPath = match[3] === "" ? fields[++index]! : null;
224 const path = previousPath !== null ? fields[++index]! : match[3]!;
225 files.push({
226 path,
227 previousPath,
228 additions: match[1] === "-" ? 0 : Number(match[1]),
229 deletions: match[2] === "-" ? 0 : Number(match[2]),
230 });
231 }
232 return files;
233}
234
235function parsePorcelainPath(line: string): string | null {
236 if (line.startsWith("? ") || line.startsWith("! ")) {
237 const simple = line.slice(2).trim();
238 return simple.length > 0 ? simple : null;
239 }
240
241 if (!(line.startsWith("1 ") || line.startsWith("2 ") || line.startsWith("u "))) {
242 return null;
243 }
244
245 const tabIndex = line.indexOf("\t");
246 if (tabIndex >= 0) {
247 const fromTab = line.slice(tabIndex + 1);
248 const [filePath] = fromTab.split("\t");
249 return filePath?.trim().length ? filePath.trim() : null;
250 }
251
252 const parts = line.trim().split(/\s+/g);
253 const filePath = parts.at(-1) ?? "";
254 return filePath.length > 0 ? filePath : null;
255}
256
257function filterBranchesForListQuery(
258 refs: ReadonlyArray<VcsRef>,
259 query?: string,
260): ReadonlyArray<VcsRef> {
261 if (!query) {
262 return refs;
263 }
264
265 const normalizedQuery = query.toLowerCase();
266 return refs.filter((refName) => refName.name.toLowerCase().includes(normalizedQuery));
267}
268
269function paginateBranches(input: {
270 refs: ReadonlyArray<VcsRef>;
271 cursor?: number | undefined;
272 limit?: number | undefined;
273}): {
274 refs: ReadonlyArray<VcsRef>;
275 nextCursor: number | null;
276 totalCount: number;
277} {
278 const cursor = input.cursor ?? 0;
279 const limit = input.limit ?? GIT_LIST_BRANCHES_DEFAULT_LIMIT;
280 const totalCount = input.refs.length;
281 const refs = input.refs.slice(cursor, cursor + limit);
282 const nextCursor = cursor + refs.length < totalCount ? cursor + refs.length : null;
283
284 return {
285 refs,
286 nextCursor,
287 totalCount,
288 };
289}
290
291function parseWorktreeBranchPaths(stdout: string): ReadonlyMap<string, string> {
292 const worktreePaths = new Map<string, string>();
293 let currentPath: string | null = null;
294 let currentBranch: string | null = null;
295 let currentPrunable = false;
296
297 const flush = () => {
298 if (currentPath !== null && currentBranch !== null && !currentPrunable) {
299 worktreePaths.set(currentBranch, currentPath);
300 }
301 currentPath = null;
302 currentBranch = null;
303 currentPrunable = false;
304 };
305
306 for (const field of stdout.split("\0")) {
307 if (field === "") {
308 flush();
309 } else if (field.startsWith("worktree ")) {
310 currentPath = field.slice("worktree ".length);
311 } else if (field.startsWith("branch refs/heads/")) {
312 currentBranch = field.slice("branch refs/heads/".length);
313 } else if (field === "prunable" || field.startsWith("prunable ")) {
314 currentPrunable = true;
315 }
316 }
317 flush();
318
319 return worktreePaths;
320}
321
322function splitNullSeparatedPaths(input: string, truncated: boolean): string[] {
323 const parts = input.split("\0");
324 if (parts.length === 0) return [];
325
326 if (truncated && parts[parts.length - 1]?.length) {
327 parts.pop();
328 }
329
330 return parts.filter((value) => value.length > 0);
331}
332
333export function splitNullSeparatedGitStdoutPaths(
334 result: Pick<GitVcsDriver.ExecuteGitResult, "stdout" | "stdoutTruncated">,
335): string[] {
336 return splitNullSeparatedPaths(result.stdout, result.stdoutTruncated);
337}
338
339function sanitizeRemoteName(value: string): string {
340 const sanitized = value
341 .trim()
342 .replace(/[^A-Za-z0-9._-]+/g, "-")
343 .replace(/^-+|-+$/g, "");
344 return sanitized.length > 0 ? sanitized : "fork";
345}
346
347function parseRemoteFetchUrls(stdout: string): Map<string, string> {
348 const remotes = new Map<string, string>();
349 for (const line of stdout.split("\n")) {
350 const trimmed = line.trim();
351 if (trimmed.length === 0) continue;
352 const match = /^(\S+)\s+(\S+)\s+\((fetch|push)\)$/.exec(trimmed);
353 if (!match) continue;
354 const [, remoteName = "", remoteUrl = "", direction = ""] = match;
355 if (direction !== "fetch" || remoteName.length === 0 || remoteUrl.length === 0) {
356 continue;
357 }
358 remotes.set(remoteName, remoteUrl);
359 }
360 return remotes;
361}
362
363function parseUpstreamRefWithRemoteNames(
364 upstreamRef: string,
365 remoteNames: ReadonlyArray<string>,
366): { upstreamRef: string; remoteName: string; branchName: string } | null {
367 const parsed = parseRemoteRefWithRemoteNames(upstreamRef, remoteNames);
368 if (!parsed) {
369 return null;
370 }
371
372 return {
373 upstreamRef,
374 remoteName: parsed.remoteName,
375 branchName: parsed.branchName,
376 };
377}
378
379function parseUpstreamRefByFirstSeparator(
380 upstreamRef: string,
381): { upstreamRef: string; remoteName: string; branchName: string } | null {
382 const separatorIndex = upstreamRef.indexOf("/");
383 if (separatorIndex <= 0 || separatorIndex === upstreamRef.length - 1) {
384 return null;
385 }
386
387 const remoteName = upstreamRef.slice(0, separatorIndex).trim();
388 const branchName = upstreamRef.slice(separatorIndex + 1).trim();
389 if (remoteName.length === 0 || branchName.length === 0) {
390 return null;
391 }
392
393 return {
394 upstreamRef,
395 remoteName,
396 branchName,
397 };
398}
399
400function parseTrackingBranchByUpstreamRef(stdout: string, upstreamRef: string): string | null {
401 for (const line of stdout.split("\n")) {
402 const trimmedLine = line.trim();
403 if (trimmedLine.length === 0) {
404 continue;
405 }
406 const [branchNameRaw, upstreamBranchRaw = ""] = trimmedLine.split("\t");
407 const branchName = branchNameRaw?.trim() ?? "";
408 const candidateUpstreamRef = upstreamBranchRaw.trim();
409 if (branchName.length === 0 || candidateUpstreamRef.length === 0) {
410 continue;
411 }
412 if (candidateUpstreamRef === upstreamRef) {
413 return branchName;
414 }
415 }
416
417 return null;
418}
419
420function deriveLocalBranchNameFromRemoteRef(branchName: string): string | null {
421 const separatorIndex = branchName.indexOf("/");
422 if (separatorIndex <= 0 || separatorIndex === branchName.length - 1) {
423 return null;
424 }
425 const localBranch = branchName.slice(separatorIndex + 1).trim();
426 return localBranch.length > 0 ? localBranch : null;
427}
428
429function gitCommandContext(
430 input: Pick<GitVcsDriver.ExecuteGitInput, "operation" | "cwd" | "args">,
431) {
432 return {
433 operation: input.operation,
434 command: "git",
435 cwd: input.cwd,
436 argumentCount: input.args.length,
437 } as const;
438}
439
440// Git states the actual cause on stderr, but stderr never leaves this module:
441// it echoes argv and remote URLs, which can carry credentials. Matching it
442// against fixed patterns yields a tag the caller can act on and a message that
443// quotes none of the matched text. Patterns run in order, specific first.
444const GIT_FAILURE_REASON_PATTERNS: ReadonlyArray<readonly [RegExp, GitCommandFailureReason]> = [
445 [/would clobber existing tag/i, "tag_would_be_clobbered"],
446 [/is already (?:used by worktree at|checked out at)/i, "branch_checked_out_in_worktree"],
447 [/a branch named .+ already exists/i, "branch_already_exists"],
448 // ssh names the methods it tried, so the parenthesized list varies:
449 // `(publickey)`, `(publickey,password)`, `(keyboard-interactive)`.
450 [
451 /(?:authentication failed|could not read Username|could not read Password|permission denied \([a-z-]+(?:,[a-z-]+)*\)|permission denied, please try again)/i,
452 "authentication_failed",
453 ],
454 // Distinct from a credential failure: the remote answered and the key it
455 // presented is untrusted, so pointing the user at credentials would misdirect.
456 [/host key verification failed/i, "host_key_unverified"],
457 [
458 /(?:could not read from remote repository|does not appear to be a git repository|repository .+ not found)/i,
459 "remote_unreachable",
460 ],
461 // Quoted-path forms only: an unquoted `fatal: <thing> already exists` also
462 // covers tag and ref collisions, which are not path collisions.
463 [/fatal: '[^']+' already exists|destination path .+ already exists/i, "path_already_exists"],
464];
465
466// Hooks write to the same stream git does, and nothing distinguishes their
467// text from git's: a pre-push hook echoing "authentication failed" would
468// otherwise be read as a credential failure. Git prefixes its own diagnostics
469// and states a push rejection on a `! [rejected]` line, so only those are
470// classified. `remote:` is deliberately excluded — git prefixes every byte the
471// server sends that way, remote hook output included, so trusting it would
472// reintroduce the same false positive from the other end of the connection.
473const GIT_DIAGNOSTIC_LINE_PATTERN = /^(?:fatal|error):|^!\s|^\s+!\s/;
474// ssh reports the refusal itself, unprefixed, and git only adds a generic
475// "Could not read from remote repository" after it. Dropping ssh's line would
476// leave that generic one to be read as an unreachable remote when the real
477// cause is credentials, so these specific refusals are classified too.
478const SSH_TRANSPORT_REFUSAL_PATTERN =
479 /Permission denied \((?:publickey|password|keyboard-interactive)|Permission denied, please try again|Host key verification failed/i;
480
481function classifyGitFailure(stderr: string): GitCommandFailureReason | null {
482 const diagnostics = stderr
483 .split(/\r?\n/)
484 .filter(
485 (line) =>
486 GIT_DIAGNOSTIC_LINE_PATTERN.test(line) ||
487 // A remote hook can echo ssh's wording; only the local ssh's line counts.
488 (!/^remote:/.test(line) && SSH_TRANSPORT_REFUSAL_PATTERN.test(line)),
489 )
490 .join("\n");
491 if (diagnostics.length === 0) return null;
492 if (isNonRepositoryGitStderr(diagnostics)) return "not_a_repository";
493 for (const [pattern, reason] of GIT_FAILURE_REASON_PATTERNS) {
494 if (pattern.test(diagnostics)) return reason;
495 }
496 return null;
497}
498
499function parseDefaultBranchFromRemoteHeadRef(value: string, remoteName: string): string | null {
500 const trimmed = value.trim();
501 const prefix = `refs/remotes/${remoteName}/`;
502 if (!trimmed.startsWith(prefix)) {
503 return null;
504 }
505 const refName = trimmed.slice(prefix.length).trim();
506 return refName.length > 0 ? refName : null;
507}
508
509function isMissingGitCwdError(error: GitCommandError): boolean {
510 if (!(error.cause instanceof PlatformError.PlatformError)) {
511 return false;
512 }
513
514 const reason = error.cause.reason;
515 if (reason._tag === "NotFound") {
516 return reason.pathOrDescriptor === error.cwd;
517 }
518
519 return (
520 reason._tag === "BadResource" &&
521 reason.pathOrDescriptor === error.cwd &&
522 typeof reason.cause === "object" &&
523 reason.cause !== null &&
524 "code" in reason.cause &&
525 reason.cause.code === "ENOTDIR"
526 );
527}
528
529function isNonRepositoryGitStderr(stderr: string): boolean {
530 return stderr.toLowerCase().includes("not a git repository");
531}
532function isUnbornHeadStderr(stderr: string): boolean {
533 const normalized = stderr.toLowerCase();
534 return (
535 normalized.includes("bad revision 'head'") ||
536 (normalized.includes("unknown revision") && normalized.includes("path not in the working tree"))
537 );
538}
539
540// Matches `git worktree remove` on a path git no longer tracks: "is not a
541// working tree" when the registration is gone, "cannot remove working tree"
542// when older gits fail validation on a registered-but-deleted directory.
543function isMissingWorktreeStderr(stderr: string): boolean {
544 const normalized = stderr.toLowerCase();
545 return (
546 normalized.includes("is not a working tree") ||
547 normalized.includes("cannot remove working tree")
548 );
549}
550
551// Fetch stderr can contain remote credentials. Only fixed diagnoses may enter
552// persisted errors; unrecognized output keeps the generic failure message.
553function fetchFailureDetail(stderr: string): string | undefined {
554 const lines = stderr.split(/\r?\n/).map((line) => line.trim());
555 if (
556 lines.some((line) =>
557 /^(?:fatal: (?:Authentication failed|could not read (?:Username|Password))\b|\S+: Permission denied \(publickey)/i.test(
558 line,
559 ),
560 )
561 ) {
562 return "Git could not authenticate with the remote. Check Git credentials or SSH access on the server, then retry.";
563 }
564 if (
565 lines.some((line) =>
566 /^(?:(?:fatal: |ssh: )?Could not resolve host(?:name)?\b|fatal: unable to access .+: (?:Could not resolve host|Failed to connect)\b|ssh: connect to host \S+ port \d+: (?:Connection timed out|Connection refused|Network is unreachable)\b)/i.test(
567 line,
568 ),
569 )
570 ) {
571 return "Git could not reach the remote. Check the server's network connection and remote host, then retry.";
572 }
573 if (
574 lines.some((line) =>
575 /^(?:remote: Repository not found\.?$|fatal: repository .+ not found$|fatal: .+ does not appear to be a git repository$)/i.test(
576 line,
577 ),
578 )
579 ) {
580 return "Git could not access the remote repository. Check the remote URL and repository permissions on the server.";
581 }
582 if (isRefLockFailure(stderr)) {
583 return "Git could not update a local reference. Another Git operation or a stale lock may be blocking the fetch; check the repository on the server, then retry.";
584 }
585 return undefined;
586}
587
588// Matches a fetch that lost a ref update to another Git process. Git 2.53+
589// reports it as "fetching ref <ref> failed: ..."; older Git says "cannot lock ref".
590function isRefLockFailure(stderr: string): boolean {
591 return stderr
592 .split(/\r?\n/)
593 .some((line) =>
594 /^(?:(?:error|fatal): cannot lock ref\b|fatal: Unable to create ['"].+\.lock['"]:|error: fetching ref \S+ failed: (?:incorrect old value provided|reference already exists)$)/i.test(
595 line.trim(),
596 ),
597 );
598}
599
600interface Trace2Monitor {
601 readonly env: NodeJS.ProcessEnv;
602 readonly flush: Effect.Effect<void, never>;
603}
604
605const nowUnixNano = DateTime.now.pipe(
606 Effect.map((now) => BigInt(DateTime.toEpochMillis(now)) * 1_000_000n),
607);
608
609const addCurrentSpanEvent = (name: string, attributes: Record<string, unknown>) =>
610 Effect.gen(function* () {
611 const span = yield* Effect.currentSpan;
612 const timestamp = yield* nowUnixNano;
613 yield* Effect.sync(() => {
614 span.event(name, timestamp, compactTraceAttributes(attributes));
615 });
616 }).pipe(
617 Effect.catchTags({
618 NoSuchElementError: () => Effect.void,
619 }),
620 );
621
622function trace2ChildKey(record: Record<string, unknown>): string | null {
623 const childId = record.child_id;
624 if (typeof childId === "number" || typeof childId === "string") {
625 return String(childId);
626 }
627 const hookName = record.hook_name;
628 return typeof hookName === "string" && hookName.trim().length > 0 ? hookName.trim() : null;
629}
630
631const Trace2Record = Schema.Record(Schema.String, Schema.Unknown);
632const decodeTrace2Record = decodeJsonResult(Trace2Record);
633
634// Untraced because it runs on every git spawn and returns at once without hook
635// callbacks. Its errors fail the runGitCommand span.
636const createTrace2Monitor = Effect.fnUntraced(function* (
637 input: Pick<GitVcsDriver.ExecuteGitInput, "operation" | "cwd" | "args">,
638 progress: GitVcsDriver.ExecuteGitProgress | undefined,
639): Effect.fn.Return<
640 Trace2Monitor,
641 PlatformError.PlatformError,
642 Scope.Scope | FileSystem.FileSystem | Path.Path
643> {
644 if (!progress?.onHookStarted && !progress?.onHookFinished) {
645 return {
646 env: {},
647 flush: Effect.void,
648 };
649 }
650
651 const fs = yield* FileSystem.FileSystem;
652 const path = yield* Path.Path;
653 const traceFilePath = yield* fs.makeTempFileScoped({
654 prefix: `t3code-git-trace2-${process.pid}-`,
655 suffix: ".json",
656 });
657 const hookStartByChildKey = new Map<string, { hookName: string; startedAtMs: number }>();
658 const traceTailState = yield* Ref.make<TraceTailState>({
659 processedChars: 0,
660 remainder: "",
661 });
662
663 const handleTraceLine = Effect.fn("handleTraceLine")(function* (line: string) {
664 const trimmedLine = line.trim();
665 if (trimmedLine.length === 0) {
666 return;
667 }
668
669 const traceRecord = decodeTrace2Record(trimmedLine);
670 if (Result.isFailure(traceRecord)) {
671 yield* Effect.logDebug(
672 `GitVcsDriver.trace2: failed to parse trace line for ${input.operation} in ${input.cwd} (${input.args.length} arguments)`,
673 traceRecord.failure,
674 );
675 return;
676 }
677
678 if (traceRecord.success.child_class !== "hook") {
679 return;
680 }
681
682 const event = traceRecord.success.event;
683 const childKey = trace2ChildKey(traceRecord.success);
684 if (childKey === null) {
685 return;
686 }
687 const started = hookStartByChildKey.get(childKey);
688 const hookNameFromEvent =
689 typeof traceRecord.success.hook_name === "string" ? traceRecord.success.hook_name.trim() : "";
690 const hookName = hookNameFromEvent.length > 0 ? hookNameFromEvent : (started?.hookName ?? "");
691 if (hookName.length === 0) {
692 return;
693 }
694
695 if (event === "child_start") {
696 const now = yield* DateTime.now;
697 hookStartByChildKey.set(childKey, { hookName, startedAtMs: DateTime.toEpochMillis(now) });
698 yield* addCurrentSpanEvent("git.hook.started", {
699 hookName,
700 });
701 if (progress.onHookStarted) {
702 yield* progress.onHookStarted(hookName);
703 }
704 return;
705 }
706
707 if (event === "child_exit") {
708 hookStartByChildKey.delete(childKey);
709 const code = traceRecord.success.exitCode;
710 const exitCode = typeof code === "number" && Number.isInteger(code) ? code : null;
711 const now = yield* DateTime.now;
712 const durationMs = started
713 ? Math.max(0, DateTime.toEpochMillis(now) - started.startedAtMs)
714 : null;
715 yield* addCurrentSpanEvent("git.hook.finished", {
716 hookName: started?.hookName ?? hookName,
717 exitCode,
718 durationMs,
719 });
720 if (progress.onHookFinished) {
721 yield* progress.onHookFinished({
722 hookName: started?.hookName ?? hookName,
723 exitCode,
724 durationMs,
725 });
726 }
727 }
728 });
729
730 const deltaMutex = yield* Semaphore.make(1);
731 const readTraceDelta = deltaMutex.withPermit(
732 fs.readFileString(traceFilePath).pipe(
733 Effect.flatMap((contents) =>
734 Effect.uninterruptible(
735 Ref.modify(traceTailState, ({ processedChars, remainder }) => {
736 if (contents.length <= processedChars) {
737 return [[], { processedChars, remainder }];
738 }
739
740 const appended = contents.slice(processedChars);
741 const combined = remainder + appended;
742 const lines = combined.split("\n");
743 const nextRemainder = lines.pop() ?? "";
744
745 return [
746 lines.map((line) => line.replace(/\r$/, "")),
747 {
748 processedChars: contents.length,
749 remainder: nextRemainder,
750 },
751 ];
752 }).pipe(
753 Effect.flatMap((lines) => Effect.forEach(lines, handleTraceLine, { discard: true })),
754 ),
755 ),
756 ),
757 Effect.ignore({ log: true }),
758 ),
759 );
760 const traceFileName = path.basename(traceFilePath);
761 yield* Stream.runForEach(fs.watch(traceFilePath), (event) => {
762 const eventPath = event.path;
763 const isTargetTraceEvent =
764 eventPath === traceFilePath ||
765 eventPath === traceFileName ||
766 path.basename(eventPath) === traceFileName;
767 if (!isTargetTraceEvent) return Effect.void;
768 return readTraceDelta;
769 }).pipe(Effect.ignoreCause({ log: true }), Effect.forkScoped);
770
771 const finalizeTrace2Monitor = Effect.fn("finalizeTrace2Monitor")(function* () {
772 yield* readTraceDelta;
773 const finalLine = yield* Ref.modify(traceTailState, ({ processedChars, remainder }) => [
774 remainder.trim(),
775 {
776 processedChars,
777 remainder: "",
778 },
779 ]);
780 if (finalLine.length > 0) {
781 yield* handleTraceLine(finalLine);
782 }
783 });
784
785 yield* Effect.addFinalizer(finalizeTrace2Monitor);
786
787 return {
788 env: {
789 GIT_TRACE2_EVENT: traceFilePath,
790 },
791 flush: readTraceDelta,
792 };
793});
794
795const GIT_CHECKOUT_PROGRESS_LINE = /Updating files:\s+(\d+)%\s+\((\d+)\/(\d+)\)/;
796
797/** Parses `Updating files: 78% (2104/2700)` from git's stderr progress output. */
798export function parseGitCheckoutProgressLine(
799 line: string,
800): { percent: number; completed: number; total: number } | null {
801 const match = GIT_CHECKOUT_PROGRESS_LINE.exec(line);
802 if (!match) return null;
803 const percent = Number(match[1]);
804 const completed = Number(match[2]);
805 const total = Number(match[3]);
806 if (!Number.isFinite(percent) || !Number.isFinite(completed) || !Number.isFinite(total)) {
807 return null;
808 }
809 return { percent: Math.max(0, Math.min(100, percent)), completed, total };
810}
811
812const OUTPUT_LINE_SEPARATOR = /\r\n|\r|\n/;
813
814const collectOutput = Effect.fnUntraced(function* (
815 input: Pick<GitVcsDriver.ExecuteGitInput, "operation" | "cwd" | "args">,
816 stream: Stream.Stream<Uint8Array, PlatformError.PlatformError>,
817 maxOutputBytes: number,
818 appendTruncationMarker: boolean,
819 onLine: ((line: string) => Effect.Effect<void, never>) | undefined,
820 keepLineCallbacksAfterTruncation = false,
821): Effect.fn.Return<{ readonly text: string; readonly truncated: boolean }, GitCommandError> {
822 const decoder = new TextDecoder();
823 // With callbacks continuing past the cap, lines are decoded by their own
824 // decoder from the first byte so no character is ever split at the cap.
825 const lineDecoder = keepLineCallbacksAfterTruncation && onLine ? new TextDecoder() : null;
826 let bytes = 0;
827 let text = "";
828 let lineBuffer = "";
829 let truncated = false;
830 // A separator-free stream past the cap must not grow the line buffer
831 // without bound; a line longer than this is not one the callbacks want.
832 const maxPendingLineBytes = 64 * 1024;
833
834 // Git redraws progress with a bare `\r` between updates and only ends the
835 // line once the step is done, so `\r` has to count as a line break here.
836 const emitCompleteLines = Effect.fnUntraced(function* (flush: boolean) {
837 let separator = OUTPUT_LINE_SEPARATOR.exec(lineBuffer);
838 while (separator) {
839 const line = lineBuffer.slice(0, separator.index);
840 lineBuffer = lineBuffer.slice(separator.index + separator[0].length);
841 if (line.length > 0 && onLine) {
842 yield* onLine(line);
843 }
844 separator = OUTPUT_LINE_SEPARATOR.exec(lineBuffer);
845 }
846
847 if (flush) {
848 const trailing = lineBuffer;
849 lineBuffer = "";
850 if (trailing.length > 0 && onLine) {
851 yield* onLine(trailing);
852 }
853 }
854 });
855
856 const processChunk = Effect.fnUntraced(function* (chunk: Uint8Array) {
857 if (appendTruncationMarker && truncated) {
858 if (lineDecoder) {
859 lineBuffer += lineDecoder.decode(chunk, { stream: true });
860 yield* emitCompleteLines(false);
861 if (lineBuffer.length > maxPendingLineBytes) lineBuffer = "";
862 }
863 return;
864 }
865 const nextBytes = bytes + chunk.byteLength;
866 if (!appendTruncationMarker && nextBytes > maxOutputBytes) {
867 return yield* new GitCommandError({
868 ...gitCommandContext(input),
869 detail: `Git output exceeded ${maxOutputBytes} bytes and was truncated.`,
870 outputLength: nextBytes,
871 });
872 }
873
874 const chunkToDecode =
875 appendTruncationMarker && nextBytes > maxOutputBytes
876 ? chunk.subarray(0, Math.max(0, maxOutputBytes - bytes))
877 : chunk;
878 bytes += chunkToDecode.byteLength;
879 truncated = appendTruncationMarker && nextBytes > maxOutputBytes;
880
881 const decoded = decoder.decode(chunkToDecode, { stream: !truncated });
882 text += decoded;
883 lineBuffer += lineDecoder ? lineDecoder.decode(chunk, { stream: true }) : decoded;
884 yield* emitCompleteLines(false);
885 });
886
887 yield* Stream.runForEach(stream, processChunk).pipe(
888 Effect.catchTags({
889 PlatformError: (cause) =>
890 new GitCommandError({
891 ...gitCommandContext(input),
892 detail: "Failed to read Git process output.",
893 cause,
894 }),
895 }),
896 );
897
898 const remainder = truncated ? "" : decoder.decode();
899 text += remainder;
900 lineBuffer += remainder;
901 if (lineDecoder) lineBuffer += lineDecoder.decode();
902 yield* emitCompleteLines(true);
903 return {
904 text,
905 truncated,
906 };
907});
908
909/**
910 * Git on Windows cannot create or delete a path longer than MAX_PATH (260)
911 * unless `core.longpaths` is set. The OS-level LongPathsEnabled setting does not
912 * cover it, and git leaves it off by default, so deep worktrees fail to check
913 * out and fail midway through removal.
914 *
915 * Passed through `GIT_CONFIG_*` rather than `-c` so argv stays the same on every
916 * platform and nothing is written to the user's config. The entry is appended
917 * after any inherited ones. Windows env names are case-insensitive, so the
918 * inherited count is found regardless of casing and updated under its own name.
919 * A count git would reject is left alone so git still reports it.
920 */
921export const windowsLongPathConfigEnv = (
922 platform: NodeJS.Platform,
923 env: NodeJS.ProcessEnv,
924): NodeJS.ProcessEnv => {
925 if (platform !== "win32") {
926 return {};
927 }
928 const countKey =
929 Object.keys(env).find((key) => key.toUpperCase() === "GIT_CONFIG_COUNT") ?? "GIT_CONFIG_COUNT";
930 const inherited = env[countKey];
931 if (
932 inherited !== undefined &&
933 inherited !== "" &&
934 // Git parses the count with strtoul: leading whitespace, an optional sign
935 // (only -0 is non-negative), digits, and nothing after them.
936 /^[ \t\r\n\v\f]*(?:\+?\d+|-0+)/.exec(inherited)?.[0] !== inherited
937 ) {
938 return {};
939 }
940 const count = inherited === undefined || inherited === "" ? 0 : Number.parseInt(inherited, 10);
941 return {
942 [countKey]: String(count + 1),
943 [`GIT_CONFIG_KEY_${count}`]: "core.longpaths",
944 [`GIT_CONFIG_VALUE_${count}`]: "true",
945 };
946};
947
948export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* () {
949 const fileSystem = yield* FileSystem.FileSystem;
950 const path = yield* Path.Path;
951 const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
952 const { worktreesDir } = yield* ServerConfig.ServerConfig;
953 const crypto = yield* Crypto.Crypto;
954 const hostPlatform = yield* HostProcess.Platform;
955
956 const executeRaw: GitVcsDriver.GitVcsDriver["Service"]["execute"] = Effect.fnUntraced(
957 function* (input) {
958 const commandInput = {
959 ...input,
960 args: [...input.args],
961 } as const;
962 const timeoutMs = input.timeoutMs === undefined ? DEFAULT_TIMEOUT_MS : input.timeoutMs;
963 const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
964 const appendTruncationMarker = input.appendTruncationMarker ?? false;
965
966 const runGitCommand = Effect.fn("runGitCommand")(function* () {
967 const trace2Monitor = yield* createTrace2Monitor(commandInput, input.progress).pipe(
968 Effect.provideService(Path.Path, path),
969 Effect.provideService(FileSystem.FileSystem, fileSystem),
970 Effect.mapError(
971 (cause) =>
972 new GitCommandError({
973 ...gitCommandContext(commandInput),
974 detail: "Failed to create Git trace monitor.",
975 cause,
976 }),
977 ),
978 );
979 const env = {
980 ...process.env,
981 // Status polling runs beside the user's own git commands; without this,
982 // `git status` takes index.lock to save its refreshed index.
983 GIT_OPTIONAL_LOCKS: "0",
984 ...input.env,
985 ...trace2Monitor.env,
986 };
987 const spawnEnv = { ...env, ...windowsLongPathConfigEnv(hostPlatform, env) };
988 const resolved = yield* resolveSpawnCommand("git", [], { env: spawnEnv });
989 // A git.cmd wrapper would need cmd.exe, which cuts multi-line commit
990 // messages at the first newline; leave that case to Node's lookup.
991 const executable = resolved.shell ? "git" : resolved.command;
992 const child = yield* commandSpawner
993 .spawn(
994 ChildProcess.make(executable, commandInput.args, {
995 cwd: commandInput.cwd,
996 env: spawnEnv,
997 }),
998 )
999 .pipe(
1000 Effect.mapError(
1001 (cause) =>
1002 new GitCommandError({
1003 ...gitCommandContext(commandInput),
1004 detail: "Failed to spawn Git process.",
1005 cause,
1006 }),
1007 ),
1008 );
1009
1010 const [stdout, stderr, exitCode] = yield* Effect.all(
1011 [
1012 collectOutput(
1013 commandInput,
1014 child.stdout,
1015 maxOutputBytes,
1016 appendTruncationMarker,
1017 input.progress?.onStdoutLine,
1018 input.keepLineCallbacksAfterTruncation,
1019 ),
1020 collectOutput(
1021 commandInput,
1022 child.stderr,
1023 maxOutputBytes,
1024 appendTruncationMarker,
1025 input.progress?.onStderrLine,
1026 input.keepLineCallbacksAfterTruncation,
1027 ),
1028 child.exitCode.pipe(
1029 Effect.mapError(
1030 (cause) =>
1031 new GitCommandError({
1032 ...gitCommandContext(commandInput),
1033 detail: "Failed to read Git process exit code.",
1034 cause,
1035 }),
1036 ),
1037 ),
1038 input.stdin === undefined
1039 ? Effect.void
1040 : Stream.run(Stream.encodeText(Stream.make(input.stdin)), child.stdin).pipe(
1041 Effect.mapError(
1042 (cause) =>
1043 new GitCommandError({
1044 ...gitCommandContext(commandInput),
1045 detail: "Failed to write Git process input.",
1046 cause,
1047 }),
1048 ),
1049 ),
1050 ],
1051 { concurrency: "unbounded" },
1052 ).pipe(Effect.map(([stdout, stderr, exitCode]) => [stdout, stderr, exitCode] as const));
1053 yield* trace2Monitor.flush;
1054
1055 if (!input.allowNonZeroExit && exitCode !== 0) {
1056 const reason = classifyGitFailure(stderr.text);
1057 return yield* new GitCommandError({
1058 ...gitCommandContext(commandInput),
1059 ...(reason === null ? {} : { reason }),
1060 detail: "Git command exited with a non-zero status.",
1061 exitCode,
1062 stdoutLength: stdout.text.length,
1063 stderrLength: stderr.text.length,
1064 });
1065 }
1066
1067 return {
1068 exitCode,
1069 stdout: stdout.text,
1070 stderr: stderr.text,
1071 stdoutTruncated: stdout.truncated,
1072 stderrTruncated: stderr.truncated,
1073 } satisfies GitVcsDriver.ExecuteGitResult;
1074 });
1075
1076 const execution = runGitCommand().pipe(Effect.scoped);
1077 if (timeoutMs === null) {
1078 return yield* execution;
1079 }
1080
1081 return yield* execution.pipe(
1082 Effect.timeoutOption(timeoutMs),
1083 Effect.flatMap((result) =>
1084 Effect.fromOption(
1085 result,
1086 () =>
1087 new GitCommandError({
1088 ...gitCommandContext(commandInput),
1089 detail: "Git command timed out.",
1090 }),
1091 ),
1092 ),
1093 );
1094 },
1095 );
1096
1097 const execute: GitVcsDriver.GitVcsDriver["Service"]["execute"] = (input) =>
1098 executeRaw(input).pipe(
1099 withMetrics({
1100 counter: gitCommandsTotal,
1101 timer: gitCommandDuration,
1102 attributes: {
1103 operation: input.operation,
1104 },
1105 }),
1106 (execution) =>
1107 input.timeoutMs === null || (input.timeoutMs ?? DEFAULT_TIMEOUT_MS) > DEFAULT_TIMEOUT_MS
1108 ? execution
1109 : gitProcesses.withPermits(1)(execution),
1110 Effect.withSpan(input.operation, {
1111 kind: "client",
1112 attributes: {
1113 "git.operation": input.operation,
1114 "git.cwd": input.cwd,
1115 "git.args_count": input.args.length,
1116 },
1117 }),
1118 );
1119
1120 const executeGit = (
1121 operation: string,
1122 cwd: string,
1123 args: readonly string[],
1124 options: ExecuteGitOptions = {},
1125 ): Effect.Effect<GitVcsDriver.ExecuteGitResult, GitCommandError> =>
1126 execute({
1127 operation,
1128 cwd,
1129 args,
1130 ...(options.stdin !== undefined ? { stdin: options.stdin } : {}),
1131 ...(options.env !== undefined ? { env: options.env } : {}),
1132 allowNonZeroExit: true,
1133 ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
1134 ...(options.maxOutputBytes !== undefined ? { maxOutputBytes: options.maxOutputBytes } : {}),
1135 ...(options.appendTruncationMarker !== undefined
1136 ? { appendTruncationMarker: options.appendTruncationMarker }
1137 : {}),
1138 ...(options.progress ? { progress: options.progress } : {}),
1139 }).pipe(
1140 Effect.flatMap((result) => {
1141 if (options.allowNonZeroExit || result.exitCode === 0) {
1142 return Effect.succeed(result);
1143 }
1144 const reason = classifyGitFailure(result.stderr);
1145 return Effect.fail(
1146 new GitCommandError({
1147 ...gitCommandContext({ operation, cwd, args }),
1148 ...(reason === null ? {} : { reason }),
1149 detail: options.fallbackErrorDetail ?? "Git command exited with a non-zero status.",
1150 ...(result.exitCode === null ? {} : { exitCode: result.exitCode }),
1151 stdoutLength: result.stdout.length,
1152 stderrLength: result.stderr.length,
1153 }),
1154 );
1155 }),
1156 );
1157
1158 const executeGitWithStableDiagnostics = (
1159 operation: string,
1160 cwd: string,
1161 args: readonly string[],
1162 options: ExecuteGitOptions = {},
1163 ): Effect.Effect<GitVcsDriver.ExecuteGitResult, GitCommandError> =>
1164 executeGit(operation, cwd, args, {
1165 ...options,
1166 env: {
1167 ...options.env,
1168 LC_ALL: "C",
1169 },
1170 });
1171
1172 const runGit = (
1173 operation: string,
1174 cwd: string,
1175 args: readonly string[],
1176 options: ExecuteGitOptions = {},
1177 ): Effect.Effect<void, GitCommandError> =>
1178 executeGit(operation, cwd, args, options).pipe(Effect.asVoid);
1179
1180 const runGitStdout = (
1181 operation: string,
1182 cwd: string,
1183 args: readonly string[],
1184 allowNonZeroExit = false,
1185 ): Effect.Effect<string, GitCommandError> =>
1186 executeGit(operation, cwd, args, { allowNonZeroExit }).pipe(
1187 Effect.map((result) => result.stdout),
1188 );
1189
1190 /**
1191 * Runs `git worktree list --porcelain -z`. Git before 2.36 rejects `-z`, so a failed run
1192 * retries the line form and returns it NUL-delimited, the same shape as `-z` output.
1193 * The line form cannot carry paths that contain newlines.
1194 */
1195 const executeWorktreeList = Effect.fn("executeWorktreeList")(function* (
1196 operation: string,
1197 cwd: string,
1198 gitArgs: readonly string[],
1199 options: ExecuteGitOptions = {},
1200 ) {
1201 const args = [...gitArgs, "worktree", "list", "--porcelain"];
1202 const result = yield* executeGit(operation, cwd, [...args, "-z"], {
1203 ...options,
1204 allowNonZeroExit: true,
1205 });
1206 if (result.exitCode === 0) return result;
1207 const lineResult = yield* executeGit(operation, cwd, args, options);
1208 return { ...lineResult, stdout: lineResult.stdout.replaceAll("\n", "\0") };
1209 });
1210
1211 const runGitStdoutWithOptions = (
1212 operation: string,
1213 cwd: string,
1214 args: readonly string[],
1215 options: ExecuteGitOptions = {},
1216 ): Effect.Effect<string, GitCommandError> =>
1217 executeGit(operation, cwd, args, options).pipe(
1218 Effect.map((result) =>
1219 result.stdoutTruncated ? `${result.stdout}${OUTPUT_TRUNCATED_MARKER}` : result.stdout,
1220 ),
1221 );
1222
1223 const branchExists = (cwd: string, refName: string): Effect.Effect<boolean, GitCommandError> =>
1224 executeGit(
1225 "GitVcsDriver.branchExists",
1226 cwd,
1227 ["show-ref", "--verify", "--quiet", `refs/heads/${refName}`],
1228 {
1229 allowNonZeroExit: true,
1230 timeoutMs: 5_000,
1231 },
1232 ).pipe(Effect.map((result) => result.exitCode === 0));
1233
1234 const resolveAvailableBranchName = Effect.fn("resolveAvailableBranchName")(function* (
1235 cwd: string,
1236 desiredBranch: string,
1237 ) {
1238 const isDesiredTaken = yield* branchExists(cwd, desiredBranch);
1239 if (!isDesiredTaken) {
1240 return desiredBranch;
1241 }
1242
1243 for (let suffix = 1; suffix <= 100; suffix += 1) {
1244 const candidate = `${desiredBranch}-${suffix}`;
1245 const isCandidateTaken = yield* branchExists(cwd, candidate);
1246 if (!isCandidateTaken) {
1247 return candidate;
1248 }
1249 }
1250
1251 return yield* new GitCommandError({
1252 ...gitCommandContext({
1253 operation: "GitVcsDriver.renameBranch",
1254 cwd,
1255 args: ["branch", "-m", "--", desiredBranch],
1256 }),
1257 detail: `Could not find an available branch name for '${desiredBranch}'.`,
1258 });
1259 });
1260
1261 const resolveCurrentUpstream = Effect.fn("resolveCurrentUpstream")(function* (cwd: string) {
1262 const upstreamRef = yield* runGitStdout(
1263 "GitVcsDriver.resolveCurrentUpstream",
1264 cwd,
1265 ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"],
1266 true,
1267 ).pipe(Effect.map((stdout) => stdout.trim()));
1268
1269 if (upstreamRef.length === 0 || upstreamRef === "@{upstream}") {
1270 return null;
1271 }
1272
1273 const remoteNames = yield* runGitStdout("GitVcsDriver.listRemoteNames", cwd, ["remote"]).pipe(
1274 Effect.map(parseRemoteNames),
1275 Effect.orElseSucceed((): ReadonlyArray<string> => []),
1276 );
1277 return (
1278 parseUpstreamRefWithRemoteNames(upstreamRef, remoteNames) ??
1279 parseUpstreamRefByFirstSeparator(upstreamRef)
1280 );
1281 });
1282
1283 const fetchRemoteForStatus = (
1284 gitCommonDir: string,
1285 remoteName: string,
1286 ): Effect.Effect<void, GitCommandError> => {
1287 const fetchCwd =
1288 path.basename(gitCommonDir) === ".git" ? path.dirname(gitCommonDir) : gitCommonDir;
1289 // `--no-auto-gc` (a synonym of `--no-auto-maintenance` that older Git also knows) keeps
1290 // this poll from starting `git gc --auto`. When that gc fails, for example on a repository
1291 // with missing objects, Git retries it on every fetch and leaves a full-size `tmp_pack_*`
1292 // behind each time, so a background poll could fill the disk.
1293 return executeGit(
1294 "GitVcsDriver.fetchRemoteForStatus",
1295 fetchCwd,
1296 ["--git-dir", gitCommonDir, "fetch", "--quiet", "--no-tags", "--no-auto-gc", remoteName],
1297 {
1298 env: STATUS_UPSTREAM_REFRESH_ENV,
1299 fallbackErrorDetail: "Background Git fetch exited with a non-zero status.",
1300 timeoutMs: Duration.toMillis(STATUS_UPSTREAM_REFRESH_TIMEOUT),
1301 },
1302 ).pipe(Effect.asVoid);
1303 };
1304
1305 const resolveRepositoryPathsUncached = Effect.fn("resolveRepositoryPathsUncached")(function* (
1306 cwd: string,
1307 ) {
1308 const commonDirResult = yield* executeGitWithStableDiagnostics(
1309 "GitVcsDriver.resolveRepositoryPaths.commonDir",
1310 cwd,
1311 ["rev-parse", "--git-common-dir"],
1312 {
1313 timeoutMs: 5_000,
1314 allowNonZeroExit: true,
1315 },
1316 );
1317 if (commonDirResult.exitCode !== 0) {
1318 const stderr = commonDirResult.stderr.trim();
1319 if (isNonRepositoryGitStderr(stderr)) {
1320 return null;
1321 }
1322 return yield* new GitCommandError({
1323 ...gitCommandContext({
1324 operation: "GitVcsDriver.resolveRepositoryPaths.commonDir",
1325 cwd,
1326 args: ["rev-parse", "--git-common-dir"],
1327 }),
1328 detail: "Failed to resolve the Git common directory.",
1329 exitCode: commonDirResult.exitCode,
1330 stdoutLength: commonDirResult.stdout.length,
1331 stderrLength: commonDirResult.stderr.length,
1332 });
1333 }
1334
1335 const commonDirOutput = commonDirResult.stdout.trim();
1336 const resolvedGitCommonDir = path.isAbsolute(commonDirOutput)
1337 ? path.normalize(commonDirOutput)
1338 : path.resolve(cwd, commonDirOutput);
1339 const gitCommonDir = yield* fileSystem
1340 .realPath(resolvedGitCommonDir)
1341 .pipe(Effect.orElseSucceed(() => resolvedGitCommonDir));
1342 const [worktreeRootResult, currentBranchResult] = yield* Effect.all(
1343 [
1344 executeGit(
1345 "GitVcsDriver.resolveRepositoryPaths.worktreeRoot",
1346 cwd,
1347 ["rev-parse", "--show-toplevel"],
1348 {
1349 timeoutMs: 5_000,
1350 allowNonZeroExit: true,
1351 },
1352 ),
1353 executeGit(
1354 "GitVcsDriver.resolveRepositoryPaths.currentBranch",
1355 cwd,
1356 ["symbolic-ref", "--quiet", "--short", "HEAD"],
1357 {
1358 timeoutMs: 5_000,
1359 allowNonZeroExit: true,
1360 },
1361 ),
1362 ],
1363 { concurrency: 2 },
1364 );
1365 const worktreeRootOutput = worktreeRootResult.stdout.trim();
1366 const worktreeRoot =
1367 worktreeRootResult.exitCode === 0 && worktreeRootOutput.length > 0
1368 ? path.normalize(
1369 path.isAbsolute(worktreeRootOutput)
1370 ? worktreeRootOutput
1371 : path.resolve(cwd, worktreeRootOutput),
1372 )
1373 : null;
1374 const currentBranchOutput = currentBranchResult.stdout.trim();
1375 const currentBranch =
1376 currentBranchResult.exitCode === 0 && currentBranchOutput.length > 0
1377 ? currentBranchOutput
1378 : null;
1379
1380 return {
1381 gitCommonDir,
1382 worktreeRoot,
1383 currentBranch,
1384 } satisfies GitRepositoryPaths;
1385 });
1386
1387 const repositoryPathsCache = yield* Cache.makeWith(
1388 (cwd: string) => resolveRepositoryPathsUncached(cwd),
1389 {
1390 capacity: REPOSITORY_PATHS_CACHE_CAPACITY,
1391 timeToLive: Exit.match({
1392 onSuccess: (repositoryPaths) =>
1393 repositoryPaths === null ? NON_REPOSITORY_PATHS_CACHE_TTL : REPOSITORY_PATHS_CACHE_TTL,
1394 onFailure: () => Duration.zero,
1395 }),
1396 },
1397 );
1398 const repositoryPathsRefreshCache = yield* Cache.makeWith(
1399 (cwd: string) =>
1400 Cache.invalidate(repositoryPathsCache, cwd).pipe(
1401 Effect.andThen(Cache.get(repositoryPathsCache, cwd)),
1402 ),
1403 {
1404 capacity: REPOSITORY_PATHS_CACHE_CAPACITY,
1405 timeToLive: Exit.match({
1406 onSuccess: (repositoryPaths) =>
1407 repositoryPaths === null
1408 ? NON_REPOSITORY_PATHS_CACHE_TTL
1409 : REPOSITORY_PATHS_REFRESH_COALESCE_TTL,
1410 onFailure: () => Duration.zero,
1411 }),
1412 },
1413 );
1414 const normalizeRepositoryPathsCacheKey = (cwd: string) => path.normalize(path.resolve(cwd));
1415 const resolveRepositoryPaths = (cwd: string, refresh = false) => {
1416 const cacheKey = normalizeRepositoryPathsCacheKey(cwd);
1417 return Cache.get(refresh ? repositoryPathsRefreshCache : repositoryPathsCache, cacheKey);
1418 };
1419
1420 const defaultBranchCache = yield* Cache.makeWith(
1421 (gitCommonDir: string) =>
1422 Effect.gen(function* () {
1423 const path = yield* Path.Path;
1424 const fetchCwd =
1425 path.basename(gitCommonDir) === ".git" ? path.dirname(gitCommonDir) : gitCommonDir;
1426 return yield* executeGit(
1427 "GitVcsDriver.statusDetails.defaultBranch",
1428 fetchCwd,
1429 ["--git-dir", gitCommonDir, "symbolic-ref", "refs/remotes/origin/HEAD"],
1430 { allowNonZeroExit: true },
1431 ).pipe(
1432 Effect.map((result) => {
1433 if (result.exitCode !== 0) return null;
1434 return parseDefaultBranchFromRemoteHeadRef(result.stdout, "origin");
1435 }),
1436 );
1437 }),
1438 {
1439 capacity: 2_048,
1440 timeToLive: Exit.match({
1441 onSuccess: () => STATUS_DEFAULT_BRANCH_CACHE_TTL,
1442 onFailure: () => Duration.zero,
1443 }),
1444 },
1445 );
1446 const originExistsCache = yield* Cache.makeWith(
1447 (gitCommonDir: string) =>
1448 Effect.gen(function* () {
1449 const path = yield* Path.Path;
1450 const fetchCwd =
1451 path.basename(gitCommonDir) === ".git" ? path.dirname(gitCommonDir) : gitCommonDir;
1452 return yield* executeGit(
1453 "GitVcsDriver.statusDetails.originExists",
1454 fetchCwd,
1455 ["--git-dir", gitCommonDir, "remote", "get-url", "origin"],
1456 { allowNonZeroExit: true },
1457 ).pipe(Effect.map((result) => result.exitCode === 0));
1458 }),
1459 {
1460 capacity: 2_048,
1461 timeToLive: Exit.match({
1462 onSuccess: () => STATUS_ORIGIN_EXISTS_CACHE_TTL,
1463 onFailure: () => Duration.zero,
1464 }),
1465 },
1466 );
1467 const invalidateStatusStaticCaches = (cwd: string) =>
1468 Effect.gen(function* () {
1469 const repositoryPaths = yield* resolveRepositoryPaths(cwd).pipe(
1470 Effect.catchTags({ GitCommandError: () => Effect.succeed(null) }),
1471 );
1472 const cacheKey = repositoryPaths?.gitCommonDir ?? normalizeRepositoryPathsCacheKey(cwd);
1473 yield* Cache.invalidate(defaultBranchCache, cacheKey);
1474 yield* Cache.invalidate(originExistsCache, cacheKey);
1475 });
1476
1477 const resolveGitCommonDir = Effect.fn("resolveGitCommonDir")(function* (cwd: string) {
1478 const repositoryPaths = yield* resolveRepositoryPaths(cwd);
1479 if (repositoryPaths !== null) {
1480 return repositoryPaths.gitCommonDir;
1481 }
1482 return yield* new GitCommandError({
1483 ...gitCommandContext({
1484 operation: "GitVcsDriver.resolveGitCommonDir",
1485 cwd,
1486 args: ["rev-parse", "--git-common-dir"],
1487 }),
1488 detail: "Cannot resolve a Git common directory outside a repository.",
1489 });
1490 });
1491
1492 const statusRemoteRefreshFailureCounts = new Map<string, number>();
1493 const statusRemoteRefreshFailureKey = (cacheKey: StatusRemoteRefreshCacheKey) =>
1494 `${cacheKey.gitCommonDir}\0${cacheKey.remoteName}`;
1495 const recordStatusRemoteRefreshFailure = (cacheKey: StatusRemoteRefreshCacheKey) => {
1496 const key = statusRemoteRefreshFailureKey(cacheKey);
1497 const nextCount = (statusRemoteRefreshFailureCounts.get(key) ?? 0) + 1;
1498 statusRemoteRefreshFailureCounts.delete(key);
1499 statusRemoteRefreshFailureCounts.set(key, nextCount);
1500 if (statusRemoteRefreshFailureCounts.size > STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY) {
1501 const oldestKey = statusRemoteRefreshFailureCounts.keys().next().value;
1502 if (oldestKey !== undefined) {
1503 statusRemoteRefreshFailureCounts.delete(oldestKey);
1504 }
1505 }
1506 };
1507 const clearStatusRemoteRefreshFailures = (cacheKey: StatusRemoteRefreshCacheKey) => {
1508 statusRemoteRefreshFailureCounts.delete(statusRemoteRefreshFailureKey(cacheKey));
1509 };
1510 const refreshStatusRemoteCacheEntry = Effect.fn("refreshStatusRemoteCacheEntry")(function* (
1511 cacheKey: StatusRemoteRefreshCacheKey,
1512 ) {
1513 return yield* fetchRemoteForStatus(cacheKey.gitCommonDir, cacheKey.remoteName).pipe(
1514 Effect.tap(() => Effect.sync(() => clearStatusRemoteRefreshFailures(cacheKey))),
1515 Effect.tapError(() => Effect.sync(() => recordStatusRemoteRefreshFailure(cacheKey))),
1516 Effect.tapCause((cause) => Effect.logWarning("Background Git fetch failed", cause)),
1517 Effect.as(true as const),
1518 );
1519 });
1520
1521 const statusRemoteRefreshCache = yield* Cache.makeWith(refreshStatusRemoteCacheEntry, {
1522 capacity: STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY,
1523 // A failed background fetch is intentionally cached and exponentially
1524 // backed off. Status reads swallow this failure and use the last fetched
1525 // refs, so repeated thread mounts cannot turn a slow or unavailable remote
1526 // into a repository-wide Git subprocess storm.
1527 timeToLive: (exit, cacheKey) =>
1528 Exit.isSuccess(exit)
1529 ? STATUS_UPSTREAM_REFRESH_INTERVAL
1530 : statusUpstreamRefreshFailureCooldown(
1531 statusRemoteRefreshFailureCounts.get(statusRemoteRefreshFailureKey(cacheKey)) ?? 1,
1532 ),
1533 });
1534
1535 const refreshStatusUpstreamIfStale = Effect.fn("refreshStatusUpstreamIfStale")(function* (
1536 cwd: string,
1537 ) {
1538 const upstream = yield* resolveCurrentUpstream(cwd);
1539 if (!upstream) return;
1540 const gitCommonDir = yield* resolveGitCommonDir(cwd);
1541 // The cache loader logs failed attempts; cache hits keep using the last fetched refs.
1542 yield* Cache.get(
1543 statusRemoteRefreshCache,
1544 new StatusRemoteRefreshCacheKey({
1545 gitCommonDir,
1546 remoteName: upstream.remoteName,
1547 }),
1548 ).pipe(Effect.ignore);
1549 });
1550
1551 const resolveDefaultBranchName = (
1552 cwd: string,
1553 remoteName: string,
1554 ): Effect.Effect<string | null, GitCommandError> =>
1555 executeGit(
1556 "GitVcsDriver.resolveDefaultBranchName",
1557 cwd,
1558 ["symbolic-ref", `refs/remotes/${remoteName}/HEAD`],
1559 { allowNonZeroExit: true },
1560 ).pipe(
1561 Effect.map((result) => {
1562 if (result.exitCode !== 0) {
1563 return null;
1564 }
1565 return parseDefaultBranchFromRemoteHeadRef(result.stdout, remoteName);
1566 }),
1567 );
1568
1569 const remoteBranchExists: GitVcsDriver.GitVcsDriver["Service"]["remoteBranchExists"] = (input) =>
1570 executeGit(
1571 "GitVcsDriver.remoteBranchExists",
1572 input.cwd,
1573 ["show-ref", "--verify", "--quiet", `refs/remotes/${input.remoteName}/${input.refName}`],
1574 {
1575 allowNonZeroExit: true,
1576 },
1577 ).pipe(Effect.map((result) => result.exitCode === 0));
1578
1579 const remoteExists: GitVcsDriver.GitVcsDriver["Service"]["remoteExists"] = (input) =>
1580 executeGit("GitVcsDriver.remoteExists", input.cwd, ["remote", "get-url", input.remoteName], {
1581 allowNonZeroExit: true,
1582 }).pipe(Effect.map((result) => result.exitCode === 0));
1583
1584 const originRemoteExists = (cwd: string): Effect.Effect<boolean, GitCommandError> =>
1585 remoteExists({ cwd, remoteName: "origin" });
1586
1587 const listRemoteNames = (cwd: string): Effect.Effect<ReadonlyArray<string>, GitCommandError> =>
1588 runGitStdout("GitVcsDriver.listRemoteNames", cwd, ["remote"]).pipe(
1589 Effect.map(parseRemoteNamesInGitOrder),
1590 );
1591
1592 const resolvePublishBranchName = Effect.fn("resolvePublishBranchName")(function* (
1593 cwd: string,
1594 branchName: string,
1595 ) {
1596 const remoteNames = yield* listRemoteNames(cwd).pipe(Effect.orElseSucceed(() => []));
1597 const parsedRemoteRef = parseRemoteRefWithRemoteNames(branchName, remoteNames);
1598 return parsedRemoteRef?.branchName ?? branchName;
1599 });
1600
1601 const resolvePrimaryRemoteName = Effect.fn("resolvePrimaryRemoteName")(function* (cwd: string) {
1602 if (yield* originRemoteExists(cwd)) {
1603 return "origin";
1604 }
1605 const remotes = yield* listRemoteNames(cwd);
1606 const [firstRemote] = remotes;
1607 if (firstRemote) {
1608 return firstRemote;
1609 }
1610 return yield* new GitCommandError({
1611 ...gitCommandContext({
1612 operation: "GitVcsDriver.resolvePrimaryRemoteName",
1613 cwd,
1614 args: ["remote"],
1615 }),
1616 detail: "No git remote is configured for this repository.",
1617 });
1618 });
1619
1620 const resolvePushRemoteName = Effect.fn("resolvePushRemoteName")(function* (
1621 cwd: string,
1622 refName: string,
1623 ) {
1624 const branchPushRemote = yield* runGitStdout(
1625 "GitVcsDriver.resolvePushRemoteName.branchPushRemote",
1626 cwd,
1627 ["config", "--get", `branch.${refName}.pushRemote`],
1628 true,
1629 ).pipe(Effect.map((stdout) => stdout.trim()));
1630 if (branchPushRemote.length > 0) {
1631 return branchPushRemote;
1632 }
1633
1634 const pushDefaultRemote = yield* runGitStdout(
1635 "GitVcsDriver.resolvePushRemoteName.remotePushDefault",
1636 cwd,
1637 ["config", "--get", "remote.pushDefault"],
1638 true,
1639 ).pipe(Effect.map((stdout) => stdout.trim()));
1640 if (pushDefaultRemote.length > 0) {
1641 return pushDefaultRemote;
1642 }
1643
1644 return yield* resolvePrimaryRemoteName(cwd).pipe(Effect.orElseSucceed(() => null));
1645 });
1646
1647 const ensureRemote: GitVcsDriver.GitVcsDriver["Service"]["ensureRemote"] = Effect.fn(
1648 "ensureRemote",
1649 )(function* (input) {
1650 const preferredName = sanitizeRemoteName(input.preferredName);
1651 const normalizedTargetUrl = normalizeGitRemoteUrl(input.url);
1652 const remoteFetchUrls = yield* runGitStdout(
1653 "GitVcsDriver.ensureRemote.listRemoteUrls",
1654 input.cwd,
1655 ["remote", "-v"],
1656 ).pipe(Effect.map((stdout) => parseRemoteFetchUrls(stdout)));
1657
1658 for (const [remoteName, remoteUrl] of remoteFetchUrls.entries()) {
1659 if (normalizeGitRemoteUrl(remoteUrl) === normalizedTargetUrl) {
1660 return remoteName;
1661 }
1662 }
1663
1664 let remoteName = preferredName;
1665 let suffix = 1;
1666 while (remoteFetchUrls.has(remoteName)) {
1667 remoteName = `${preferredName}-${suffix}`;
1668 suffix += 1;
1669 }
1670
1671 yield* runGit("GitVcsDriver.ensureRemote.add", input.cwd, [
1672 "remote",
1673 "add",
1674 remoteName,
1675 input.url,
1676 ]);
1677 return remoteName;
1678 });
1679
1680 // `allowRemoteOfCurrent` lets the review diff compare the default branch with its remote copy.
1681 const resolveBaseBranchForNoUpstream = Effect.fn("resolveBaseBranchForNoUpstream")(function* (
1682 cwd: string,
1683 refName: string,
1684 options?: { readonly allowRemoteOfCurrent?: boolean },
1685 ) {
1686 const configuredBaseBranch = yield* runGitStdout(
1687 "GitVcsDriver.resolveBaseBranchForNoUpstream.config",
1688 cwd,
1689 ["config", "--get", `branch.${refName}.gh-merge-base`],
1690 true,
1691 ).pipe(Effect.map((stdout) => stdout.trim()));
1692
1693 const primaryRemoteName = yield* resolvePrimaryRemoteName(cwd).pipe(
1694 Effect.orElseSucceed(() => null),
1695 );
1696 const defaultBranch =
1697 primaryRemoteName === null ? null : yield* resolveDefaultBranchName(cwd, primaryRemoteName);
1698 const candidates = [
1699 configuredBaseBranch.length > 0 ? configuredBaseBranch : null,
1700 defaultBranch,
1701 ...DEFAULT_BASE_BRANCH_CANDIDATES,
1702 ];
1703
1704 for (const candidate of candidates) {
1705 if (!candidate) {
1706 continue;
1707 }
1708
1709 const remotePrefix =
1710 primaryRemoteName && primaryRemoteName !== "origin" ? `${primaryRemoteName}/` : null;
1711 const normalizedCandidate = candidate.startsWith("origin/")
1712 ? candidate.slice("origin/".length)
1713 : remotePrefix && candidate.startsWith(remotePrefix)
1714 ? candidate.slice(remotePrefix.length)
1715 : candidate;
1716 if (normalizedCandidate.length === 0) {
1717 continue;
1718 }
1719 if (normalizedCandidate === refName) {
1720 if (
1721 options?.allowRemoteOfCurrent &&
1722 primaryRemoteName &&
1723 (yield* remoteBranchExists({
1724 cwd,
1725 remoteName: primaryRemoteName,
1726 refName: normalizedCandidate,
1727 }))
1728 ) {
1729 return `${primaryRemoteName}/${normalizedCandidate}`;
1730 }
1731 continue;
1732 }
1733
1734 if (
1735 primaryRemoteName &&
1736 (yield* remoteBranchExists({
1737 cwd,
1738 remoteName: primaryRemoteName,
1739 refName: normalizedCandidate,
1740 }))
1741 ) {
1742 return `${primaryRemoteName}/${normalizedCandidate}`;
1743 }
1744
1745 if (yield* branchExists(cwd, normalizedCandidate)) {
1746 return normalizedCandidate;
1747 }
1748 }
1749
1750 return null;
1751 });
1752
1753 const computeAheadCountAgainstBase = Effect.fn("computeAheadCountAgainstBase")(function* (
1754 cwd: string,
1755 refName: string,
1756 ) {
1757 const baseRef = yield* resolveBaseBranchForNoUpstream(cwd, refName);
1758 if (!baseRef) {
1759 return 0;
1760 }
1761
1762 const result = yield* executeGit(
1763 "GitVcsDriver.computeAheadCountAgainstBase",
1764 cwd,
1765 ["rev-list", "--count", `${baseRef}..HEAD`],
1766 { allowNonZeroExit: true },
1767 );
1768 if (result.exitCode !== 0) {
1769 return 0;
1770 }
1771
1772 const parsed = Number.parseInt(result.stdout.trim(), 10);
1773 return Number.isFinite(parsed) ? Math.max(0, parsed) : 0;
1774 });
1775
1776 const readStatusDetailsRemote = Effect.fn("readStatusDetailsRemote")(function* (cwd: string) {
1777 const branchResult = yield* executeGitWithStableDiagnostics(
1778 "GitVcsDriver.statusDetailsRemote.branch",
1779 cwd,
1780 ["rev-parse", "--abbrev-ref", "HEAD"],
1781 { allowNonZeroExit: true },
1782 ).pipe(
1783 Effect.catchTags({
1784 GitCommandError: (error) =>
1785 isMissingGitCwdError(error) ? Effect.succeed(null) : Effect.fail(error),
1786 }),
1787 );
1788
1789 if (branchResult === null) {
1790 return NON_REPOSITORY_REMOTE_STATUS_DETAILS;
1791 }
1792 let branch: string | null;
1793 if (branchResult.exitCode !== 0) {
1794 if (isNonRepositoryGitStderr(branchResult.stderr)) {
1795 return NON_REPOSITORY_REMOTE_STATUS_DETAILS;
1796 }
1797 if (!isUnbornHeadStderr(branchResult.stderr)) {
1798 return yield* new GitCommandError({
1799 ...gitCommandContext({
1800 operation: "GitVcsDriver.statusDetailsRemote.branch",
1801 cwd,
1802 args: ["rev-parse", "--abbrev-ref", "HEAD"],
1803 }),
1804 detail: "Git branch lookup failed.",
1805 exitCode: branchResult.exitCode,
1806 stdoutLength: branchResult.stdout.length,
1807 stderrLength: branchResult.stderr.length,
1808 });
1809 }
1810
1811 const branchValue = yield* runGitStdout(
1812 "GitVcsDriver.statusDetailsRemote.unbornBranch",
1813 cwd,
1814 ["symbolic-ref", "--quiet", "--short", "HEAD"],
1815 );
1816 branch = branchValue.trim() || null;
1817 } else {
1818 const branchValue = branchResult.stdout.trim();
1819 branch = branchValue.length > 0 && branchValue !== "HEAD" ? branchValue : null;
1820 }
1821 const upstream = yield* resolveCurrentUpstream(cwd);
1822 const upstreamRef = upstream?.upstreamRef ?? null;
1823 let aheadCount = 0;
1824 let behindCount = 0;
1825
1826 if (upstreamRef) {
1827 const divergence = yield* executeGit(
1828 "GitVcsDriver.statusDetailsRemote.divergence",
1829 cwd,
1830 ["rev-list", "--left-right", "--count", `HEAD...${upstreamRef}`],
1831 { allowNonZeroExit: true },
1832 );
1833 if (divergence.exitCode === 0) {
1834 const [aheadRaw, behindRaw] = divergence.stdout.trim().split(/\s+/);
1835 const parsedAhead = Number.parseInt(aheadRaw ?? "0", 10);
1836 const parsedBehind = Number.parseInt(behindRaw ?? "0", 10);
1837 aheadCount = Number.isFinite(parsedAhead) ? Math.max(0, parsedAhead) : 0;
1838 behindCount = Number.isFinite(parsedBehind) ? Math.max(0, parsedBehind) : 0;
1839 }
1840 } else if (branch) {
1841 aheadCount = yield* computeAheadCountAgainstBase(cwd, branch).pipe(
1842 Effect.orElseSucceed(() => 0),
1843 );
1844 }
1845
1846 const defaultBranch = yield* resolveDefaultBranchName(cwd, "origin");
1847 const isDefaultBranch =
1848 branch !== null &&
1849 (branch === defaultBranch ||
1850 (defaultBranch === null && (branch === "main" || branch === "master")));
1851 const aheadOfDefaultCount =
1852 branch && !isDefaultBranch
1853 ? upstreamRef === null
1854 ? aheadCount
1855 : yield* computeAheadCountAgainstBase(cwd, branch).pipe(Effect.orElseSucceed(() => 0))
1856 : 0;
1857
1858 return {
1859 isRepo: true,
1860 defaultBranch,
1861 isDefaultBranch,
1862 branch,
1863 upstreamRef,
1864 hasUpstream: upstreamRef !== null,
1865 aheadCount,
1866 behindCount,
1867 aheadOfDefaultCount,
1868 };
1869 });
1870
1871 const readStatusDetailsLocal = Effect.fn("readStatusDetailsLocal")(function* (
1872 cwd: string,
1873 options?: GitVcsDriver.GitLocalStatusOptions,
1874 ) {
1875 const includeDivergence = options?.includeDivergence !== false;
1876 const statusArgs = [
1877 "status",
1878 "--porcelain=2",
1879 "--branch",
1880 ...(includeDivergence ? [] : ["--no-ahead-behind"]),
1881 ];
1882 const indexResult = yield* executeGitWithStableDiagnostics(
1883 "GitVcsDriver.statusDetails.indexPath",
1884 cwd,
1885 ["rev-parse", "--git-path", "index"],
1886 { allowNonZeroExit: true },
1887 ).pipe(
1888 Effect.catchTags({
1889 GitCommandError: (error) =>
1890 isMissingGitCwdError(error) ? Effect.succeed(null) : Effect.fail(error),
1891 }),
1892 );
1893 if (indexResult === null) return NON_REPOSITORY_STATUS_DETAILS;
1894 if (indexResult.exitCode === 0) {
1895 const lockPath = `${path.resolve(cwd, indexResult.stdout.trim())}.lock`;
1896 const lockError = new GitCommandError({
1897 operation: "GitVcsDriver.statusDetails.indexPath",
1898 command: "git",
1899 cwd,
1900 detail: "Git index is locked. Status will resume when the index lock is removed.",
1901 });
1902 // Status can succeed while locked, repeatedly running LFS clean filters without caching.
1903 if (
1904 yield* fileSystem.exists(lockPath).pipe(
1905 Effect.mapError(
1906 (cause) =>
1907 new GitCommandError({
1908 ...lockError,
1909 detail: "Failed to check the Git index lock.",
1910 cause,
1911 }),
1912 ),
1913 )
1914 ) {
1915 return yield* lockError;
1916 }
1917 }
1918 const statusResult = yield* executeGitWithStableDiagnostics(
1919 "GitVcsDriver.statusDetails.status",
1920 cwd,
1921 statusArgs,
1922 {
1923 allowNonZeroExit: true,
1924 },
1925 ).pipe(
1926 Effect.catchTags({
1927 GitCommandError: (error) =>
1928 isMissingGitCwdError(error) ? Effect.succeed(null) : Effect.fail(error),
1929 }),
1930 );
1931
1932 if (statusResult === null) {
1933 return NON_REPOSITORY_STATUS_DETAILS;
1934 }
1935
1936 if (statusResult.exitCode !== 0) {
1937 if (isNonRepositoryGitStderr(statusResult.stderr)) {
1938 return NON_REPOSITORY_STATUS_DETAILS;
1939 }
1940 return yield* new GitCommandError({
1941 ...gitCommandContext({
1942 operation: "GitVcsDriver.statusDetails.status",
1943 cwd,
1944 args: statusArgs,
1945 }),
1946 detail: "Git status failed.",
1947 exitCode: statusResult.exitCode,
1948 stdoutLength: statusResult.stdout.length,
1949 stderrLength: statusResult.stderr.length,
1950 });
1951 }
1952
1953 const repositoryPaths = yield* resolveRepositoryPaths(cwd).pipe(
1954 Effect.catchTags({ GitCommandError: () => Effect.succeed(null) }),
1955 );
1956 const statusCacheKey = repositoryPaths?.gitCommonDir;
1957 const [numstatStdout, defaultBranch, hasPrimaryRemote] = yield* Effect.all(
1958 [
1959 // Plumbing, because porcelain `git diff` rewrites the index even with
1960 // GIT_OPTIONAL_LOCKS=0. -M keeps porcelain's rename detection.
1961 executeGitWithStableDiagnostics(
1962 "GitVcsDriver.statusDetails.numstat",
1963 cwd,
1964 ["diff-index", "-M", "--numstat", "HEAD", "--"],
1965 { allowNonZeroExit: true },
1966 ).pipe(
1967 Effect.flatMap((result) => {
1968 if (result.exitCode === 0) return Effect.succeed(result.stdout);
1969 if (isUnbornHeadStderr(result.stderr)) {
1970 return Effect.map(
1971 Effect.all([
1972 runGitStdout("GitVcsDriver.statusDetails.numstat.unborn", cwd, [
1973 "diff-files",
1974 "--numstat",
1975 ]),
1976 runGitStdout("GitVcsDriver.statusDetails.numstat.unborn.staged", cwd, [
1977 "diff",
1978 "--cached",
1979 "--numstat",
1980 ]),
1981 ]),
1982 ([unstagedStdout, stagedStdout]) => {
1983 const staged = parseNumstatEntries(stagedStdout);
1984 const unstaged = parseNumstatEntries(unstagedStdout);
1985 const map = new Map<string, { insertions: number; deletions: number }>();
1986 for (const entry of [...staged, ...unstaged]) {
1987 const existing = map.get(entry.path) ?? {
1988 insertions: 0,
1989 deletions: 0,
1990 };
1991 existing.insertions += entry.insertions;
1992 existing.deletions += entry.deletions;
1993 map.set(entry.path, existing);
1994 }
1995 return Array.from(map.entries())
1996 .map(([p, s]) => `${s.insertions}\t${s.deletions}\t${p}`)
1997 .join("\n");
1998 },
1999 );
2000 }
2001 return Effect.fail(
2002 new GitCommandError({
2003 ...gitCommandContext({
2004 operation: "GitVcsDriver.statusDetails.numstat",
2005 cwd,
2006 args: ["diff-index", "-M", "--numstat", "HEAD", "--"],
2007 }),
2008 detail: "git diff-index HEAD --numstat failed.",
2009 exitCode: result.exitCode,
2010 stdoutLength: result.stdout.length,
2011 stderrLength: result.stderr.length,
2012 }),
2013 );
2014 }),
2015 ),
2016 statusCacheKey
2017 ? Cache.get(defaultBranchCache, statusCacheKey).pipe(Effect.orElseSucceed(() => null))
2018 : resolveDefaultBranchName(cwd, "origin").pipe(Effect.orElseSucceed(() => null)),
2019 statusCacheKey
2020 ? Cache.get(originExistsCache, statusCacheKey).pipe(Effect.orElseSucceed(() => false))
2021 : originRemoteExists(cwd).pipe(Effect.orElseSucceed(() => false)),
2022 ],
2023 { concurrency: "unbounded" },
2024 );
2025 const statusStdout = statusResult.stdout;
2026
2027 let refName: string | null = null;
2028 let upstreamRef: string | null = null;
2029 let aheadCount = 0;
2030 let behindCount = 0;
2031 let aheadOfDefaultCount = 0;
2032 let hasWorkingTreeChanges = false;
2033 const changedFilesWithoutNumstat = new Set<string>();
2034
2035 for (const line of statusStdout.split(/\r?\n/g)) {
2036 if (line.startsWith("# branch.head ")) {
2037 const value = line.slice("# branch.head ".length).trim();
2038 refName = value.startsWith("(") ? null : value;
2039 continue;
2040 }
2041 if (line.startsWith("# branch.upstream ")) {
2042 const value = line.slice("# branch.upstream ".length).trim();
2043 upstreamRef = value.length > 0 ? value : null;
2044 continue;
2045 }
2046 if (includeDivergence && line.startsWith("# branch.ab ")) {
2047 const value = line.slice("# branch.ab ".length).trim();
2048 const parsed = parseBranchAb(value);
2049 aheadCount = parsed.ahead;
2050 behindCount = parsed.behind;
2051 continue;
2052 }
2053 if (line.trim().length > 0 && !line.startsWith("#")) {
2054 hasWorkingTreeChanges = true;
2055 const pathValue = parsePorcelainPath(line);
2056 if (pathValue) changedFilesWithoutNumstat.add(pathValue);
2057 }
2058 }
2059
2060 const fallbackAheadCount =
2061 includeDivergence && !upstreamRef && refName
2062 ? yield* computeAheadCountAgainstBase(cwd, refName).pipe(Effect.orElseSucceed(() => 0))
2063 : null;
2064
2065 if (fallbackAheadCount !== null) {
2066 aheadCount = fallbackAheadCount;
2067 behindCount = 0;
2068 }
2069
2070 const isDefaultBranch =
2071 refName !== null &&
2072 (refName === defaultBranch ||
2073 (defaultBranch === null && (refName === "main" || refName === "master")));
2074 if (includeDivergence && refName && !isDefaultBranch) {
2075 aheadOfDefaultCount =
2076 fallbackAheadCount !== null
2077 ? fallbackAheadCount
2078 : yield* computeAheadCountAgainstBase(cwd, refName).pipe(Effect.orElseSucceed(() => 0));
2079 }
2080
2081 const numstatEntries = parseNumstatEntries(numstatStdout);
2082 const fileStatMap = new Map<string, { insertions: number; deletions: number }>();
2083 for (const entry of numstatEntries) {
2084 fileStatMap.set(entry.path, { insertions: entry.insertions, deletions: entry.deletions });
2085 }
2086
2087 let insertions = 0;
2088 let deletions = 0;
2089 const files = Array.from(fileStatMap.entries())
2090 .map(([filePath, stat]) => {
2091 insertions += stat.insertions;
2092 deletions += stat.deletions;
2093 return { path: filePath, insertions: stat.insertions, deletions: stat.deletions };
2094 })
2095 .toSorted((a, b) => a.path.localeCompare(b.path));
2096
2097 for (const filePath of changedFilesWithoutNumstat) {
2098 if (fileStatMap.has(filePath)) continue;
2099 files.push({ path: filePath, insertions: 0, deletions: 0 });
2100 }
2101 files.sort((a, b) => a.path.localeCompare(b.path));
2102
2103 const branchChanges = options?.includeBranchChanges
2104 ? yield* readBranchChangeTotals(repositoryPaths?.worktreeRoot ?? cwd, refName).pipe(
2105 Effect.orElseSucceed(() => undefined),
2106 )
2107 : undefined;
2108
2109 return {
2110 isRepo: true,
2111 hasOriginRemote: hasPrimaryRemote,
2112 isDefaultBranch,
2113 branch: refName,
2114 upstreamRef,
2115 hasWorkingTreeChanges,
2116 workingTree: {
2117 files,
2118 insertions,
2119 deletions,
2120 },
2121 ...(branchChanges ? { branchChanges } : {}),
2122 hasUpstream: upstreamRef !== null,
2123 aheadCount,
2124 behindCount,
2125 aheadOfDefaultCount,
2126 };
2127 });
2128
2129 const statusDetailsLocal: GitVcsDriver.GitVcsDriver["Service"]["statusDetailsLocal"] = Effect.fn(
2130 "statusDetailsLocal",
2131 )(function* (cwd, options) {
2132 return yield* readStatusDetailsLocal(cwd, options);
2133 });
2134
2135 const statusDetails: GitVcsDriver.GitVcsDriver["Service"]["statusDetails"] = Effect.fn(
2136 "statusDetails",
2137 )(function* (cwd) {
2138 yield* refreshStatusUpstreamIfStale(cwd).pipe(
2139 Effect.catchTags({
2140 GitCommandError: (error) =>
2141 isMissingGitCwdError(error) ? Effect.void : Effect.fail(error),
2142 }),
2143 Effect.ignoreCause({ log: true }),
2144 );
2145 return yield* readStatusDetailsLocal(cwd);
2146 });
2147
2148 const statusDetailsRemote: GitVcsDriver.GitVcsDriver["Service"]["statusDetailsRemote"] =
2149 Effect.fn("statusDetailsRemote")(function* (cwd, options) {
2150 if (options?.refreshUpstream !== false) {
2151 yield* refreshStatusUpstreamIfStale(cwd).pipe(
2152 Effect.catchTags({
2153 GitCommandError: (error) =>
2154 isMissingGitCwdError(error) ? Effect.void : Effect.fail(error),
2155 }),
2156 Effect.ignoreCause({ log: true }),
2157 );
2158 }
2159 return yield* readStatusDetailsRemote(cwd);
2160 });
2161
2162 const status: GitVcsDriver.GitVcsDriver["Service"]["status"] = (input) =>
2163 statusDetails(input.cwd).pipe(
2164 Effect.map((details) => ({
2165 isRepo: details.isRepo,
2166 hasPrimaryRemote: details.hasOriginRemote,
2167 isDefaultRef: details.isDefaultBranch,
2168 refName: details.branch,
2169 hasWorkingTreeChanges: details.hasWorkingTreeChanges,
2170 workingTree: details.workingTree,
2171 hasUpstream: details.hasUpstream,
2172 aheadCount: details.aheadCount,
2173 behindCount: details.behindCount,
2174 aheadOfDefaultCount: details.aheadOfDefaultCount,
2175 pr: null,
2176 })),
2177 );
2178
2179 const stageCommitChanges = Effect.fnUntraced(function* (
2180 cwd: string,
2181 filePaths?: readonly string[],
2182 env?: NodeJS.ProcessEnv,
2183 ) {
2184 const config = env
2185 ? ["-c", "core.splitIndex=false", "-c", "splitIndex.sharedIndexExpire=never"]
2186 : [];
2187 if (filePaths && filePaths.length > 0) {
2188 if (env) {
2189 const head = yield* executeGit(
2190 "GitVcsDriver.prepareCommitContext.head",
2191 cwd,
2192 ["rev-parse", "--verify", "--quiet", "HEAD"],
2193 { allowNonZeroExit: true },
2194 );
2195 // Even with GIT_INDEX_FILE, reset clears the repository's merge state.
2196 yield* runGit(
2197 "GitVcsDriver.prepareCommitContext.readTree",
2198 cwd,
2199 [...config, "read-tree", ...(head.exitCode === 0 ? ["HEAD"] : ["--empty"])],
2200 { env },
2201 );
2202 } else {
2203 yield* runGit("GitVcsDriver.commit.reset", cwd, ["reset"]).pipe(
2204 Effect.catchTags({ GitCommandError: () => Effect.void }),
2205 );
2206 }
2207 yield* runGit(
2208 "GitVcsDriver.commit.addSelected",
2209 cwd,
2210 [...config, "--literal-pathspecs", "add", "-A", "--", ...filePaths],
2211 env ? { env } : {},
2212 );
2213 } else {
2214 yield* runGit(
2215 "GitVcsDriver.commit.addAll",
2216 cwd,
2217 [...config, "add", "-A"],
2218 env ? { env } : {},
2219 );
2220 }
2221 });
2222
2223 const prepareCommitContext: GitVcsDriver.GitVcsDriver["Service"]["prepareCommitContext"] =
2224 Effect.fn("prepareCommitContext")(function* (cwd, filePaths) {
2225 return yield* Effect.scoped(
2226 Effect.gen(function* () {
2227 const indexValue = yield* runGitStdout(
2228 "GitVcsDriver.prepareCommitContext.indexPath",
2229 cwd,
2230 ["rev-parse", "--git-path", "index"],
2231 );
2232 const indexPath = path.resolve(cwd, indexValue.trim());
2233 const directory = yield* fileSystem.makeTempDirectoryScoped({
2234 prefix: "t3code-commit-index-",
2235 });
2236 const tempIndexPath = path.join(directory, "index");
2237 const env = { GIT_INDEX_FILE: tempIndexPath } satisfies NodeJS.ProcessEnv;
2238 const config = [
2239 "-c",
2240 "core.splitIndex=false",
2241 "-c",
2242 "splitIndex.sharedIndexExpire=never",
2243 ];
2244 if (yield* fileSystem.exists(indexPath)) {
2245 const { mtime } = yield* fileSystem.stat(indexPath);
2246 yield* fileSystem.copyFile(indexPath, tempIndexPath);
2247 const indexTime = Option.isSome(mtime)
2248 ? Math.max(0, Math.floor(mtime.value.getTime() / 1000))
2249 : 0;
2250 yield* fileSystem.utimes(tempIndexPath, indexTime, indexTime);
2251 yield* runGit(
2252 "GitVcsDriver.prepareCommitContext.expandSplitIndex",
2253 cwd,
2254 [...config, "update-index", "--no-split-index"],
2255 { env },
2256 );
2257 } else {
2258 yield* runGit(
2259 "GitVcsDriver.prepareCommitContext.emptyIndex",
2260 cwd,
2261 [...config, "read-tree", "--empty"],
2262 { env },
2263 );
2264 }
2265 yield* stageCommitChanges(cwd, filePaths, env);
2266
2267 const stagedSummary = yield* runGitStdoutWithOptions(
2268 "GitVcsDriver.prepareCommitContext.stagedSummary",
2269 cwd,
2270 ["diff", "--cached", "--name-status"],
2271 { env },
2272 ).pipe(Effect.map((stdout) => stdout.trim()));
2273 if (stagedSummary.length === 0) {
2274 return null;
2275 }
2276
2277 const stagedPatch = yield* runGitStdoutWithOptions(
2278 "GitVcsDriver.prepareCommitContext.stagedPatch",
2279 cwd,
2280 ["diff", "--no-ext-diff", "--cached", "--patch", "--minimal"],
2281 {
2282 env,
2283 maxOutputBytes: PREPARED_COMMIT_PATCH_MAX_OUTPUT_BYTES,
2284 appendTruncationMarker: true,
2285 },
2286 );
2287
2288 return {
2289 stagedSummary,
2290 stagedPatch,
2291 };
2292 }),
2293 ).pipe(
2294 Effect.catchTags({
2295 PlatformError: (cause) =>
2296 new GitCommandError({
2297 operation: "GitVcsDriver.prepareCommitContext",
2298 command: "git",
2299 cwd,
2300 detail: "Failed to prepare the temporary commit index.",
2301 cause,
2302 }),
2303 }),
2304 );
2305 });
2306
2307 const commit: GitVcsDriver.GitVcsDriver["Service"]["commit"] = Effect.fn("commit")(function* (
2308 cwd,
2309 subject,
2310 body,
2311 options?: GitVcsDriver.GitCommitOptions,
2312 ) {
2313 if (options?.stage !== undefined) {
2314 yield* stageCommitChanges(cwd, options.stage.filePaths);
2315 }
2316 const args = ["commit", "-m", subject];
2317 const trimmedBody = body.trim();
2318 if (trimmedBody.length > 0) {
2319 args.push("-m", trimmedBody);
2320 }
2321 const progress =
2322 options?.progress?.onOutputLine === undefined
2323 ? options?.progress
2324 : {
2325 ...options.progress,
2326 onStdoutLine: (line: string) =>
2327 options.progress?.onOutputLine?.({ stream: "stdout", text: line }) ?? Effect.void,
2328 onStderrLine: (line: string) =>
2329 options.progress?.onOutputLine?.({ stream: "stderr", text: line }) ?? Effect.void,
2330 };
2331 yield* executeGit("GitVcsDriver.commit.commit", cwd, args, {
2332 ...(options?.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
2333 ...(progress ? { progress } : {}),
2334 }).pipe(Effect.asVoid);
2335 const commitSha = yield* runGitStdout("GitVcsDriver.commit.revParseHead", cwd, [
2336 "rev-parse",
2337 "HEAD",
2338 ]).pipe(Effect.map((stdout) => stdout.trim()));
2339
2340 return { commitSha };
2341 });
2342
2343 const pushCurrentBranch: GitVcsDriver.GitVcsDriver["Service"]["pushCurrentBranch"] = Effect.fn(
2344 "pushCurrentBranch",
2345 )(function* (cwd, fallbackBranch, options) {
2346 const details = yield* statusDetails(cwd);
2347 const branch = details.branch ?? fallbackBranch;
2348 if (!branch) {
2349 return yield* new GitCommandError({
2350 ...gitCommandContext({
2351 operation: "GitVcsDriver.pushCurrentBranch",
2352 cwd,
2353 args: ["push"],
2354 }),
2355 detail: "Cannot push from detached HEAD.",
2356 });
2357 }
2358
2359 const requestedRemoteName = options?.remoteName?.trim() || null;
2360 if (requestedRemoteName) {
2361 const publishBranch = yield* resolvePublishBranchName(cwd, branch);
2362 yield* runGit(
2363 "GitVcsDriver.pushCurrentBranch.pushWithRequestedRemote",
2364 cwd,
2365 ["push", "-u", requestedRemoteName, `HEAD:refs/heads/${publishBranch}`],
2366 { timeoutMs: null },
2367 );
2368 return {
2369 status: "pushed" as const,
2370 branch,
2371 upstreamBranch: `${requestedRemoteName}/${publishBranch}`,
2372 setUpstream: true,
2373 };
2374 }
2375
2376 const hasNoLocalDelta = details.aheadCount === 0 && details.behindCount === 0;
2377 if (hasNoLocalDelta) {
2378 if (details.hasUpstream) {
2379 return {
2380 status: "skipped_up_to_date" as const,
2381 branch,
2382 ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}),
2383 };
2384 }
2385
2386 const comparableBaseBranch = yield* resolveBaseBranchForNoUpstream(cwd, branch).pipe(
2387 Effect.orElseSucceed(() => null),
2388 );
2389 if (comparableBaseBranch) {
2390 const publishRemoteName = yield* resolvePushRemoteName(cwd, branch).pipe(
2391 Effect.orElseSucceed(() => null),
2392 );
2393 if (!publishRemoteName) {
2394 return {
2395 status: "skipped_up_to_date" as const,
2396 branch,
2397 };
2398 }
2399
2400 const hasRemoteBranch = yield* remoteBranchExists({
2401 cwd,
2402 remoteName: publishRemoteName,
2403 refName: branch,
2404 }).pipe(Effect.orElseSucceed(() => false));
2405 if (hasRemoteBranch) {
2406 return {
2407 status: "skipped_up_to_date" as const,
2408 branch,
2409 };
2410 }
2411 }
2412 }
2413
2414 if (!details.hasUpstream) {
2415 const publishRemoteName = yield* resolvePushRemoteName(cwd, branch);
2416 if (!publishRemoteName) {
2417 return yield* new GitCommandError({
2418 ...gitCommandContext({
2419 operation: "GitVcsDriver.pushCurrentBranch",
2420 cwd,
2421 args: ["push"],
2422 }),
2423 detail: "Cannot push because no git remote is configured for this repository.",
2424 });
2425 }
2426 const publishBranch = yield* resolvePublishBranchName(cwd, branch);
2427 yield* runGit(
2428 "GitVcsDriver.pushCurrentBranch.pushWithUpstream",
2429 cwd,
2430 ["push", "-u", publishRemoteName, `HEAD:refs/heads/${publishBranch}`],
2431 { timeoutMs: null },
2432 );
2433 return {
2434 status: "pushed" as const,
2435 branch,
2436 upstreamBranch: `${publishRemoteName}/${publishBranch}`,
2437 setUpstream: true,
2438 };
2439 }
2440
2441 const currentUpstream = yield* resolveCurrentUpstream(cwd).pipe(
2442 Effect.orElseSucceed(() => null),
2443 );
2444 if (currentUpstream) {
2445 // A branch tracking a differently named ref was cut from it, the way
2446 // `git checkout -b feature origin/dev` and our own worktree flow leave
2447 // it. That upstream is the branch's base, not its publish target, and
2448 // pushing HEAD onto it would write feature commits to a shared branch
2449 // (bare `git push` refuses this under push.default=simple). The one
2450 // same-repo tracking setup that legitimately differs is a git-mangled
2451 // alias such as local `upstream/effect-atom` for my-org/upstream's
2452 // `effect-atom`: the branch name ends in the upstream head while the
2453 // upstream ref ends in the branch name.
2454 const isAliasOfUpstreamHead =
2455 branch === currentUpstream.branchName ||
2456 (branch.endsWith(`/${currentUpstream.branchName}`) &&
2457 currentUpstream.upstreamRef.endsWith(`/${branch}`));
2458 if (!isAliasOfUpstreamHead) {
2459 const publishRemoteName = yield* resolvePushRemoteName(cwd, branch).pipe(
2460 Effect.orElseSucceed(() => null),
2461 );
2462 const remoteName = publishRemoteName ?? currentUpstream.remoteName;
2463 const publishBranch = yield* resolvePublishBranchName(cwd, branch);
2464 // `-u` retargets the upstream to the published branch, so keep the
2465 // base recorded first; base resolution reads gh-merge-base before the
2466 // upstream ref.
2467 const configuredMergeBase = yield* runGitStdout(
2468 "GitVcsDriver.pushCurrentBranch.readMergeBase",
2469 cwd,
2470 ["config", "--get", `branch.${branch}.gh-merge-base`],
2471 true,
2472 ).pipe(Effect.map((stdout) => stdout.trim()));
2473 if (configuredMergeBase.length === 0) {
2474 yield* runGit("GitVcsDriver.pushCurrentBranch.recordMergeBase", cwd, [
2475 "config",
2476 `branch.${branch}.gh-merge-base`,
2477 currentUpstream.branchName,
2478 ]);
2479 }
2480 yield* runGit(
2481 "GitVcsDriver.pushCurrentBranch.pushOwnBranch",
2482 cwd,
2483 ["push", "-u", remoteName, `HEAD:refs/heads/${publishBranch}`],
2484 { timeoutMs: null },
2485 );
2486 return {
2487 status: "pushed" as const,
2488 branch,
2489 upstreamBranch: `${remoteName}/${publishBranch}`,
2490 setUpstream: true,
2491 };
2492 }
2493
2494 yield* runGit(
2495 "GitVcsDriver.pushCurrentBranch.pushUpstream",
2496 cwd,
2497 ["push", currentUpstream.remoteName, `HEAD:refs/heads/${currentUpstream.branchName}`],
2498 { timeoutMs: null },
2499 );
2500 return {
2501 status: "pushed" as const,
2502 branch,
2503 upstreamBranch: currentUpstream.upstreamRef,
2504 setUpstream: false,
2505 };
2506 }
2507
2508 yield* runGit("GitVcsDriver.pushCurrentBranch.push", cwd, ["push"], { timeoutMs: null });
2509 return {
2510 status: "pushed" as const,
2511 branch,
2512 ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}),
2513 setUpstream: false,
2514 };
2515 });
2516
2517 const pullCurrentBranch: GitVcsDriver.GitVcsDriver["Service"]["pullCurrentBranch"] = Effect.fn(
2518 "pullCurrentBranch",
2519 )(function* (cwd) {
2520 const details = yield* statusDetails(cwd);
2521 const refName = details.branch;
2522 if (!refName) {
2523 return yield* new GitCommandError({
2524 ...gitCommandContext({
2525 operation: "GitVcsDriver.pullCurrentBranch",
2526 cwd,
2527 args: ["pull", "--ff-only"],
2528 }),
2529 detail: "Cannot pull from detached HEAD.",
2530 });
2531 }
2532 if (!details.hasUpstream) {
2533 return yield* new GitCommandError({
2534 ...gitCommandContext({
2535 operation: "GitVcsDriver.pullCurrentBranch",
2536 cwd,
2537 args: ["pull", "--ff-only"],
2538 }),
2539 detail: "Current branch has no upstream configured. Push with upstream first.",
2540 });
2541 }
2542 const beforeSha = yield* runGitStdout(
2543 "GitVcsDriver.pullCurrentBranch.beforeSha",
2544 cwd,
2545 ["rev-parse", "HEAD"],
2546 true,
2547 ).pipe(Effect.map((stdout) => stdout.trim()));
2548 // After a successful pull, HEAD before and after decides the result, so its output is not
2549 // needed. A large fast-forward's diffstat can exceed the output cap, which must not fail a
2550 // pull Git applied.
2551 yield* executeGit("GitVcsDriver.pullCurrentBranch.pull", cwd, ["pull", "--ff-only"], {
2552 timeoutMs: 30_000,
2553 fallbackErrorDetail: "git pull failed",
2554 appendTruncationMarker: true,
2555 });
2556 const afterSha = yield* runGitStdout(
2557 "GitVcsDriver.pullCurrentBranch.afterSha",
2558 cwd,
2559 ["rev-parse", "HEAD"],
2560 true,
2561 ).pipe(Effect.map((stdout) => stdout.trim()));
2562
2563 const refreshed = yield* statusDetails(cwd);
2564 return {
2565 status: beforeSha.length > 0 && beforeSha === afterSha ? "skipped_up_to_date" : "pulled",
2566 refName,
2567 upstreamRef: refreshed.upstreamRef,
2568 };
2569 });
2570
2571 const readRangeContext: GitVcsDriver.GitVcsDriver["Service"]["readRangeContext"] = Effect.fn(
2572 "readRangeContext",
2573 )(function* (cwd, baseRef) {
2574 const commitRange = `${baseRef}..HEAD`;
2575 // PR diffs start at the common ancestor when the base branch has advanced.
2576 const diffRange = `${baseRef}...HEAD`;
2577 const [commitSummary, diffSummary, diffPatch] = yield* Effect.all(
2578 [
2579 runGitStdoutWithOptions(
2580 "GitVcsDriver.readRangeContext.log",
2581 cwd,
2582 ["log", "--oneline", commitRange],
2583 {
2584 maxOutputBytes: RANGE_COMMIT_SUMMARY_MAX_OUTPUT_BYTES,
2585 appendTruncationMarker: true,
2586 },
2587 ),
2588 runGitStdoutWithOptions(
2589 "GitVcsDriver.readRangeContext.diffStat",
2590 cwd,
2591 ["diff", "--stat", diffRange],
2592 {
2593 maxOutputBytes: RANGE_DIFF_SUMMARY_MAX_OUTPUT_BYTES,
2594 appendTruncationMarker: true,
2595 },
2596 ),
2597 runGitStdoutWithOptions(
2598 "GitVcsDriver.readRangeContext.diffPatch",
2599 cwd,
2600 ["diff", "--no-ext-diff", "--patch", "--minimal", diffRange],
2601 {
2602 maxOutputBytes: RANGE_DIFF_PATCH_MAX_OUTPUT_BYTES,
2603 appendTruncationMarker: true,
2604 },
2605 ),
2606 ],
2607 { concurrency: "unbounded" },
2608 );
2609
2610 return {
2611 commitSummary,
2612 diffSummary,
2613 diffPatch,
2614 };
2615 });
2616
2617 // Use the same temporary index for patch and statistics so unstaged renames agree.
2618 const prepareReviewIndex = Effect.fn("prepareReviewIndex")(function* (
2619 cwd: string,
2620 untrackedPaths: ReadonlyArray<string>,
2621 ) {
2622 const [stagedDeletionsStdout, indexValue] = yield* Effect.all(
2623 [
2624 runGitStdoutWithOptions(
2625 "GitVcsDriver.readUnifiedWorkingTreeReviewDiff.stagedDeletions",
2626 cwd,
2627 ["diff", "--cached", "--name-only", "--diff-filter=D", "-z", "HEAD", "--"],
2628 { allowNonZeroExit: true, maxOutputBytes: REVIEW_METADATA_MAX_OUTPUT_BYTES },
2629 ),
2630 runGitStdout("GitVcsDriver.readUnifiedWorkingTreeReviewDiff.indexPath", cwd, [
2631 "rev-parse",
2632 "--git-path",
2633 "index",
2634 ]),
2635 ],
2636 { concurrency: 2 },
2637 );
2638 const stagedDeletions = new Set(stagedDeletionsStdout.split("\0").filter(Boolean));
2639 const pathsToAdd = untrackedPaths.filter((relativePath) => !stagedDeletions.has(relativePath));
2640 if (pathsToAdd.length === 0) return undefined;
2641
2642 const indexPath = path.isAbsolute(indexValue.trim())
2643 ? indexValue.trim()
2644 : path.resolve(cwd, indexValue.trim());
2645 const tempIndexPath = yield* fileSystem.makeTempFileScoped({
2646 prefix: `t3code-review-index-${process.pid}-`,
2647 });
2648 const indexExists = yield* fileSystem.exists(indexPath);
2649 if (indexExists) {
2650 const { mtime } = yield* fileSystem.stat(indexPath);
2651 yield* fileSystem.copyFile(indexPath, tempIndexPath);
2652 // Node FileSystem.stat truncates bigint timestamps to milliseconds before creating its Date.
2653 // Flooring preserves the source second without making preceding-second files racy.
2654 const indexTime = Option.isSome(mtime)
2655 ? Math.max(0, Math.floor(mtime.value.getTime() / 1000))
2656 : 0;
2657 yield* fileSystem.utimes(tempIndexPath, indexTime, indexTime);
2658 }
2659 const env = { GIT_INDEX_FILE: tempIndexPath } satisfies NodeJS.ProcessEnv;
2660 const tempIndexConfig = [
2661 "-c",
2662 "core.splitIndex=false",
2663 "-c",
2664 "splitIndex.sharedIndexExpire=never",
2665 ];
2666 if (!indexExists) {
2667 yield* executeGit("GitVcsDriver.review.emptyIndex", cwd, ["read-tree", "--empty"], { env });
2668 }
2669 yield* executeGit(
2670 "GitVcsDriver.readUnifiedWorkingTreeReviewDiff.expandSplitIndex",
2671 cwd,
2672 [...tempIndexConfig, "update-index", "--no-split-index"],
2673 { env },
2674 );
2675 yield* executeGit(
2676 "GitVcsDriver.readUnifiedWorkingTreeReviewDiff.addUntracked",
2677 cwd,
2678 [
2679 ...tempIndexConfig,
2680 "--literal-pathspecs",
2681 "add",
2682 "--intent-to-add",
2683 "--pathspec-from-file=-",
2684 "--pathspec-file-nul",
2685 ],
2686 { env, stdin: `${pathsToAdd.join("\0")}\0` },
2687 );
2688 return env;
2689 });
2690
2691 // Before the first commit, review diffs compare with the empty tree instead of HEAD.
2692 const readEmptyTreeHash = Effect.fn("readEmptyTreeHash")(function* (cwd: string) {
2693 const stdout = yield* runGitStdout("GitVcsDriver.review.emptyTree", cwd, [
2694 "hash-object",
2695 "-t",
2696 "tree",
2697 (yield* HostProcess.Platform) === "win32" ? "NUL" : "/dev/null",
2698 ]);
2699 return stdout.trim();
2700 });
2701
2702 // Lists untracked files and adds them to a temporary index, so a diff against any commit
2703 // shows them as new. Returns null when the list is too big to read or to index. Needs a Scope.
2704 const prepareUntrackedReviewIndex = Effect.fn("prepareUntrackedReviewIndex")(function* (
2705 cwd: string,
2706 pathArgs: ReadonlyArray<string>,
2707 onlyPath?: string,
2708 ) {
2709 const untracked = yield* executeGit(
2710 "GitVcsDriver.review.listUntracked",
2711 cwd,
2712 ["ls-files", "--others", "--exclude-standard", "-z", "--", ...pathArgs],
2713 { maxOutputBytes: REVIEW_METADATA_MAX_OUTPUT_BYTES },
2714 ).pipe(
2715 Effect.catchIf(
2716 (error) => error.outputLength === undefined,
2717 () => Effect.succeed(null),
2718 ),
2719 );
2720 if (untracked === null) return null;
2721 const paths = splitNullSeparatedGitStdoutPaths(untracked).filter(
2722 (candidate) => onlyPath === undefined || candidate === onlyPath,
2723 );
2724 if (paths.length === 0) return { env: undefined };
2725 if (paths.length > REVIEW_UNTRACKED_MAX_FILES) return null;
2726 const env = yield* prepareReviewIndex(cwd, paths).pipe(
2727 Effect.catchTags({
2728 PlatformError: (cause) =>
2729 Effect.fail(
2730 new GitCommandError({
2731 operation: "GitVcsDriver.prepareReviewIndex",
2732 cwd,
2733 command: "git diff",
2734 detail: "Could not prepare the review index.",
2735 cause,
2736 }),
2737 ),
2738 }),
2739 );
2740 return { env };
2741 });
2742
2743 // The diff panel's Changes view compares the working tree with merge-base(base, HEAD).
2744 // With no usable base it compares with HEAD, so Changes equals Uncommitted.
2745 const resolveReviewMergeBase = Effect.fn("resolveReviewMergeBase")(function* (
2746 cwd: string,
2747 branch: string | null,
2748 explicitBaseRef?: string,
2749 ) {
2750 const baseRef =
2751 explicitBaseRef ??
2752 (branch
2753 ? yield* resolveBaseBranchForNoUpstream(cwd, branch, { allowRemoteOfCurrent: true }).pipe(
2754 Effect.orElseSucceed(() => null),
2755 )
2756 : null);
2757 if (baseRef === null) return { baseRef, mergeBase: "HEAD" };
2758 const args = ["merge-base", baseRef, "HEAD"];
2759 const result = yield* executeGit("GitVcsDriver.resolveReviewMergeBase", cwd, args, {
2760 allowNonZeroExit: true,
2761 });
2762 const mergeBase = result.stdout.trim();
2763 if (result.exitCode !== 0 || mergeBase.length === 0) {
2764 // Before the first commit there is nothing to compare with, so Changes equals Uncommitted.
2765 const head = yield* executeGit(
2766 "GitVcsDriver.resolveReviewMergeBase.head",
2767 cwd,
2768 ["rev-parse", "--verify", "--quiet", "HEAD"],
2769 { allowNonZeroExit: true },
2770 );
2771 if (explicitBaseRef === undefined && head.exitCode !== 0) {
2772 return { baseRef: null, mergeBase: "HEAD" };
2773 }
2774 return yield* new GitCommandError({
2775 ...gitCommandContext({ operation: "GitVcsDriver.resolveReviewMergeBase", cwd, args }),
2776 detail: `Could not find a common commit between '${baseRef}' and HEAD.`,
2777 exitCode: result.exitCode,
2778 });
2779 }
2780 return { baseRef, mergeBase };
2781 });
2782
2783 // Totals for the thread panel's Changes row. Same base and untracked files as the Changes view.
2784 const readBranchChangeTotals = Effect.fn("readBranchChangeTotals")(function* (
2785 cwd: string,
2786 branch: string | null,
2787 ) {
2788 const { baseRef, mergeBase } = yield* resolveReviewMergeBase(cwd, branch);
2789 const untracked = yield* prepareUntrackedReviewIndex(cwd, []);
2790 if (untracked === null) {
2791 return yield* new GitCommandError({
2792 operation: "GitVcsDriver.readBranchChangeTotals",
2793 command: "git ls-files",
2794 cwd,
2795 detail: "Too many untracked files to count.",
2796 });
2797 }
2798 const readNumstat = (ref: string) =>
2799 executeGit(
2800 "GitVcsDriver.readBranchChangeTotals",
2801 cwd,
2802 [...REVIEW_DIFF_ARGS, "--numstat", "-z", ref, "--"],
2803 {
2804 allowNonZeroExit: true,
2805 maxOutputBytes: REVIEW_METADATA_MAX_OUTPUT_BYTES,
2806 env: untracked.env,
2807 },
2808 );
2809 let result = yield* readNumstat(mergeBase);
2810 if (result.exitCode !== 0 && mergeBase === "HEAD" && isUnbornHeadStderr(result.stderr)) {
2811 result = yield* readNumstat(yield* readEmptyTreeHash(cwd));
2812 }
2813 if (result.exitCode !== 0) {
2814 return yield* new GitCommandError({
2815 operation: "GitVcsDriver.readBranchChangeTotals",
2816 command: "git diff --numstat",
2817 cwd,
2818 detail: "Could not read Changes totals.",
2819 exitCode: result.exitCode,
2820 });
2821 }
2822 let insertions = 0;
2823 let deletions = 0;
2824 for (const file of parseReviewNumstat(result.stdout)) {
2825 insertions += file.additions;
2826 deletions += file.deletions;
2827 }
2828 return { baseRef, insertions, deletions };
2829 }, Effect.scoped);
2830
2831 const getReviewDiffPreview = Effect.fn("getReviewDiffPreview")(function* (
2832 input: ReviewDiffPreviewInput,
2833 ) {
2834 const pathArgs = input.file
2835 ? [input.file.path, ...(input.file.previousPath ? [input.file.previousPath] : [])].map(
2836 (path) => `:(top,literal)${path}`,
2837 )
2838 : [];
2839 const patchLimit = input.file
2840 ? REVIEW_DIFF_FILE_MAX_OUTPUT_BYTES
2841 : REVIEW_DIFF_PATCH_MAX_OUTPUT_BYTES;
2842 const repository = yield* resolveRepositoryPathsUncached(input.cwd).pipe(
2843 Effect.catchTags({
2844 GitCommandError: (error) =>
2845 isMissingGitCwdError(error) ? Effect.succeed(null) : Effect.fail(error),
2846 }),
2847 );
2848 if (!repository?.worktreeRoot) {
2849 return {
2850 cwd: input.cwd,
2851 generatedAt: yield* DateTime.now,
2852 sources: [],
2853 };
2854 }
2855
2856 const cwd = repository.worktreeRoot;
2857 // A per-file request only reads its own source.
2858 const dirtyRef = input.file?.sourceKind === "branch-range" ? null : "HEAD";
2859 const review =
2860 input.file?.sourceKind === "working-tree"
2861 ? { baseRef: input.baseRef ?? null, mergeBase: null }
2862 : yield* resolveReviewMergeBase(cwd, repository.currentBranch, input.baseRef);
2863
2864 const diffArgs = [
2865 ...REVIEW_DIFF_ARGS,
2866 ...(input.ignoreWhitespace ? ["--ignore-all-space"] : []),
2867 ];
2868 const readStats = Effect.fn("GitVcsDriver.getReviewDiffPreview.stat")(function* (
2869 ref: string,
2870 env?: NodeJS.ProcessEnv,
2871 ) {
2872 const args = [...diffArgs, "--numstat", "-z"];
2873 const result = yield* executeGit(
2874 "GitVcsDriver.getReviewDiffPreview.stat",
2875 cwd,
2876 [...args, ref, "--", ...pathArgs],
2877 { allowNonZeroExit: true, maxOutputBytes: REVIEW_METADATA_MAX_OUTPUT_BYTES, env },
2878 );
2879 if (result.exitCode === 0) return { ref, files: parseReviewNumstat(result.stdout) };
2880 if (ref === "HEAD" && isUnbornHeadStderr(result.stderr)) {
2881 const emptyTree = yield* readEmptyTreeHash(cwd);
2882 const stdout = yield* runGitStdoutWithOptions(
2883 "GitVcsDriver.getReviewDiffPreview.unbornStat",
2884 cwd,
2885 [...args, emptyTree, "--", ...pathArgs],
2886 { maxOutputBytes: REVIEW_METADATA_MAX_OUTPUT_BYTES, env },
2887 );
2888 return { ref: emptyTree, files: parseReviewNumstat(stdout) };
2889 }
2890 return yield* new GitCommandError({
2891 operation: "GitVcsDriver.getReviewDiffPreview.stat",
2892 cwd,
2893 command: "git diff --numstat",
2894 detail: "Could not read complete diff statistics.",
2895 exitCode: result.exitCode,
2896 });
2897 });
2898 // One commit argument diffs that commit against the working tree.
2899 const readTrackedDiff = Effect.fn("GitVcsDriver.getReviewDiffPreview.tracked")(function* (
2900 ref: string | null,
2901 env?: NodeJS.ProcessEnv,
2902 ) {
2903 if (ref === null) return { stdout: "", stdoutTruncated: false, files: [] };
2904 const stat = yield* readStats(ref, env);
2905 if (stat.files.length === 0) return { stdout: "", stdoutTruncated: false, files: [] };
2906 const patch = yield* executeGit(
2907 "GitVcsDriver.getReviewDiffPreview.patch",
2908 cwd,
2909 [...diffArgs, "--patch", stat.ref, "--", ...pathArgs],
2910 { maxOutputBytes: patchLimit, appendTruncationMarker: true, env },
2911 );
2912 return { ...patch, files: stat.files };
2913 });
2914 const [dirtyTrackedResult, baseResult] = yield* Effect.gen(function* () {
2915 const untracked = yield* prepareUntrackedReviewIndex(cwd, pathArgs, input.file?.path);
2916 // With no base both sources diff HEAD, so read it once.
2917 const [dirty, base] =
2918 review.mergeBase === dirtyRef
2919 ? yield* readTrackedDiff(dirtyRef, untracked?.env).pipe(
2920 Effect.map((result) => [result, result] as const),
2921 )
2922 : yield* Effect.all(
2923 [
2924 readTrackedDiff(dirtyRef, untracked?.env),
2925 readTrackedDiff(review.mergeBase, untracked?.env),
2926 ],
2927 { concurrency: 2 },
2928 );
2929 if (untracked !== null) return [dirty, base] as const;
2930 // Too many untracked files to list: show tracked changes and mark totals incomplete.
2931 const incomplete = (ref: string | null, result: typeof dirty) =>
2932 ref === null ? result : { ...result, files: undefined, stdoutTruncated: true };
2933 return [incomplete(dirtyRef, dirty), incomplete(review.mergeBase, base)] as const;
2934 }).pipe(Effect.scoped);
2935 const dirtyFiles = dirtyTrackedResult.files;
2936 const baseFiles = baseResult.files;
2937 const dirtyDiff = dirtyTrackedResult.stdout;
2938 const baseDiff = baseResult.stdout;
2939 const hashDiff = (diff: string, files: ReadonlyArray<ReviewDiffFileStat>) =>
2940 crypto.digest("SHA-256", new TextEncoder().encode(JSON.stringify([diff, files]))).pipe(
2941 Effect.map(Hex.encode),
2942 Effect.mapError(
2943 (cause) =>
2944 new GitCommandError({
2945 operation: "GitVcsDriver.getReviewDiffPreview.hash",
2946 command: "crypto.digest SHA-256",
2947 cwd,
2948 detail: "Failed to hash review diff.",
2949 cause,
2950 }),
2951 ),
2952 );
2953 const [dirtyDiffHash, baseDiffHash] = yield* Effect.all([
2954 hashDiff(dirtyDiff, dirtyFiles ?? []),
2955 hashDiff(baseDiff, baseFiles ?? []),
2956 ]);
2957
2958 const sources: ReviewDiffPreviewSource[] = [
2959 {
2960 id: "working-tree",
2961 kind: "working-tree",
2962 title: "Uncommitted",
2963 baseRef: "HEAD",
2964 headRef: null,
2965 diff: dirtyDiff,
2966 ...(dirtyFiles === undefined ? {} : { files: dirtyFiles }),
2967 diffHash: dirtyDiffHash,
2968 truncated: dirtyTrackedResult.stdoutTruncated,
2969 },
2970 {
2971 id: "branch-range",
2972 kind: "branch-range",
2973 title: review.baseRef ? `Changes vs ${review.baseRef}` : "Changes",
2974 baseRef: review.baseRef,
2975 // For display only. The new side is the working tree.
2976 headRef: repository.currentBranch ?? "HEAD",
2977 diff: baseDiff,
2978 ...(baseFiles === undefined ? {} : { files: baseFiles }),
2979 diffHash: baseDiffHash,
2980 truncated: baseResult.stdoutTruncated,
2981 },
2982 ];
2983
2984 return {
2985 cwd: input.cwd,
2986 generatedAt: yield* DateTime.now,
2987 sources,
2988 };
2989 });
2990
2991 const reviewDiffFileError = (
2992 input: ReviewDiffFileContentsInput,
2993 detail: string,
2994 cause?: unknown,
2995 ) =>
2996 new GitCommandError({
2997 operation: "GitVcsDriver.getReviewDiffFileContents",
2998 command: "git",
2999 cwd: input.cwd,
3000 detail,
3001 ...(cause === undefined ? {} : { cause }),
3002 });
3003
3004 const isPathWithinRoot = (root: string, candidate: string) => {
3005 const relative = path.relative(root, candidate);
3006 return (
3007 relative === "" ||
3008 (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative))
3009 );
3010 };
3011
3012 const readReviewFileAtRevision = Effect.fn("readReviewFileAtRevision")(function* (
3013 input: ReviewDiffFileContentsInput,
3014 revision: string,
3015 relativePath: string,
3016 ) {
3017 const result = yield* executeGit(
3018 "GitVcsDriver.getReviewDiffFileContents.revision",
3019 input.cwd,
3020 ["show", `${revision}:${relativePath}`],
3021 { maxOutputBytes: REVIEW_DIFF_FILE_MAX_OUTPUT_BYTES },
3022 );
3023 if (result.stdout.includes("\0")) {
3024 return yield* reviewDiffFileError(input, `Cannot expand binary file '${relativePath}'.`);
3025 }
3026 return result.stdout;
3027 });
3028
3029 const readWorkingTreeReviewFile = Effect.fn("readWorkingTreeReviewFile")(function* (
3030 input: ReviewDiffFileContentsInput,
3031 repositoryRoot: string,
3032 ) {
3033 const fileError = (stage: string, detail: string, cause?: unknown) =>
3034 new GitCommandError({
3035 operation: `GitVcsDriver.getReviewDiffFileContents.workingTree.${stage}`,
3036 command: stage,
3037 cwd: input.cwd,
3038 detail,
3039 ...(cause === undefined ? {} : { cause }),
3040 });
3041 const requestedPath = path.resolve(repositoryRoot, input.newPath);
3042 if (!isPathWithinRoot(repositoryRoot, requestedPath)) {
3043 return yield* fileError(
3044 "path.resolve",
3045 `Diff file '${input.newPath}' resolves outside the review workspace.`,
3046 );
3047 }
3048
3049 const [realRepositoryRoot, realTarget] = yield* Effect.all([
3050 fileSystem.realPath(repositoryRoot),
3051 fileSystem.realPath(requestedPath),
3052 ]).pipe(
3053 Effect.mapError((cause) =>
3054 fileError("fs.realPath", `Could not resolve diff file '${input.newPath}'.`, cause),
3055 ),
3056 );
3057 if (!isPathWithinRoot(realRepositoryRoot, realTarget)) {
3058 return yield* fileError(
3059 "fs.realPath",
3060 `Diff file '${input.newPath}' resolves outside the review workspace.`,
3061 );
3062 }
3063
3064 const info = yield* fileSystem
3065 .stat(realTarget)
3066 .pipe(
3067 Effect.mapError((cause) =>
3068 fileError("fs.stat", `Could not inspect diff file '${input.newPath}'.`, cause),
3069 ),
3070 );
3071 if (info.type !== "File") {
3072 return yield* fileError("fs.stat", `Diff path '${input.newPath}' is not a file.`);
3073 }
3074 if (info.size > BigInt(REVIEW_DIFF_FILE_MAX_OUTPUT_BYTES)) {
3075 return yield* fileError(
3076 "fs.stat",
3077 `Diff file '${input.newPath}' exceeds the 1 MB expansion limit.`,
3078 );
3079 }
3080
3081 const bytes = yield* fileSystem
3082 .readFile(realTarget)
3083 .pipe(
3084 Effect.mapError((cause) =>
3085 fileError("fs.readFile", `Could not read diff file '${input.newPath}'.`, cause),
3086 ),
3087 );
3088 if (bytes.includes(0)) {
3089 return yield* fileError("fs.readFile", `Cannot expand binary file '${input.newPath}'.`);
3090 }
3091 return new TextDecoder("utf-8").decode(bytes);
3092 });
3093
3094 // Both views compare a commit with the working tree: HEAD for Uncommitted,
3095 // merge-base(base, HEAD) for Changes. The new side always comes from disk.
3096 const getReviewDiffFileContents = Effect.fn("getReviewDiffFileContents")(function* (
3097 input: ReviewDiffFileContentsInput,
3098 ) {
3099 const repositoryRoot = yield* runGitStdout(
3100 "GitVcsDriver.getReviewDiffFileContents.repositoryRoot",
3101 input.cwd,
3102 ["rev-parse", "--show-toplevel"],
3103 ).pipe(Effect.map((value) => value.trim()));
3104 if (repositoryRoot.length === 0) {
3105 return yield* reviewDiffFileError(input, "Could not resolve the Git repository root.");
3106 }
3107 const oldRevision =
3108 input.sourceKind === "working-tree"
3109 ? (input.baseRef ?? "HEAD")
3110 : (yield* resolveReviewMergeBase(input.cwd, null, input.baseRef ?? undefined)).mergeBase;
3111 const [oldContents, newContents] = yield* Effect.all(
3112 [
3113 input.changeType === "new"
3114 ? Effect.succeed("")
3115 : readReviewFileAtRevision(input, oldRevision, input.oldPath),
3116 input.changeType === "deleted"
3117 ? Effect.succeed("")
3118 : readWorkingTreeReviewFile(input, repositoryRoot),
3119 ],
3120 { concurrency: 2 },
3121 );
3122 return { oldContents, newContents };
3123 });
3124
3125 const readConfigValue: GitVcsDriver.GitVcsDriver["Service"]["readConfigValue"] = (cwd, key) =>
3126 runGitStdout("GitVcsDriver.readConfigValue", cwd, ["config", "--get", key], true).pipe(
3127 Effect.map((stdout) => stdout.trim()),
3128 Effect.map((trimmed) => (trimmed.length > 0 ? trimmed : null)),
3129 );
3130
3131 const resolveBranchName = Effect.fn("GitVcsDriver.resolveBranchName")(function* (
3132 cwd: string,
3133 refName: string,
3134 ) {
3135 const result = yield* executeGit(
3136 "GitVcsDriver.resolveBranchName.resolveRef",
3137 cwd,
3138 ["rev-parse", "--symbolic-full-name", "--verify", "--end-of-options", refName],
3139 { allowNonZeroExit: true },
3140 );
3141 if (result.exitCode !== 0) {
3142 // Remote-only bases are often recorded by their unqualified branch name.
3143 const remoteName = yield* resolvePrimaryRemoteName(cwd).pipe(
3144 Effect.orElseSucceed(() => null),
3145 );
3146 return remoteName && (yield* remoteBranchExists({ cwd, remoteName, refName }))
3147 ? refName
3148 : null;
3149 }
3150 const ref = result.stdout.trim();
3151 if (ref.startsWith("refs/heads/")) return ref.slice("refs/heads/".length);
3152 if (!ref.startsWith("refs/remotes/")) return null;
3153 const remoteNames = yield* listRemoteNames(cwd);
3154 return (
3155 parseRemoteRefWithRemoteNames(
3156 ref.slice("refs/remotes/".length),
3157 remoteNames.toSorted((left, right) => right.length - left.length),
3158 )?.branchName ?? null
3159 );
3160 });
3161
3162 const readGitRefsSnapshot = Effect.fn("readGitRefsSnapshot")(function* (gitCommonDir: string) {
3163 const fetchCwd =
3164 path.basename(gitCommonDir) === ".git" ? path.dirname(gitCommonDir) : gitCommonDir;
3165 const gitDirArgs = ["--git-dir", gitCommonDir] as const;
3166 const [refsResult, defaultRefResult, worktreeListResult, remoteNamesResult] = yield* Effect.all(
3167 [
3168 executeGitWithStableDiagnostics(
3169 "GitVcsDriver.listRefs.snapshotRefs",
3170 fetchCwd,
3171 [
3172 ...gitDirArgs,
3173 "for-each-ref",
3174 "--format=%(refname)%09%(committerdate:unix)%09%(symref)",
3175 "refs/heads",
3176 "refs/remotes",
3177 ],
3178 {
3179 timeoutMs: 30_000,
3180 maxOutputBytes: 16 * 1024 * 1024,
3181 fallbackErrorDetail: "Git ref snapshot enumeration failed.",
3182 },
3183 ),
3184 executeGit(
3185 "GitVcsDriver.listRefs.defaultRef",
3186 fetchCwd,
3187 [...gitDirArgs, "symbolic-ref", "refs/remotes/origin/HEAD"],
3188 {
3189 timeoutMs: 5_000,
3190 allowNonZeroExit: true,
3191 },
3192 ),
3193 executeWorktreeList("GitVcsDriver.listRefs.worktreeList", fetchCwd, gitDirArgs, {
3194 timeoutMs: 30_000,
3195 allowNonZeroExit: true,
3196 maxOutputBytes: 16 * 1024 * 1024,
3197 }),
3198 executeGit("GitVcsDriver.listRefs.remoteNames", fetchCwd, [...gitDirArgs, "remote"], {
3199 timeoutMs: 5_000,
3200 allowNonZeroExit: true,
3201 }),
3202 ],
3203 { concurrency: 2 },
3204 );
3205
3206 const remoteNames =
3207 remoteNamesResult.exitCode === 0 ? parseRemoteNames(remoteNamesResult.stdout) : [];
3208 if (remoteNamesResult.exitCode !== 0 && remoteNamesResult.stderr.trim().length > 0) {
3209 yield* Effect.logWarning(
3210 `GitVcsDriver.listRefs: remote name lookup returned code ${remoteNamesResult.exitCode} for ${gitCommonDir}: ${remoteNamesResult.stderr.trim()}. Falling back to an empty remote name list.`,
3211 );
3212 }
3213 const defaultBranch =
3214 defaultRefResult.exitCode === 0
3215 ? defaultRefResult.stdout.trim().replace(/^refs\/remotes\/origin\//, "")
3216 : null;
3217 const parsedWorktreeEntries =
3218 worktreeListResult.exitCode === 0
3219 ? [...parseWorktreeBranchPaths(worktreeListResult.stdout)].map(
3220 ([branchName, worktreePath]) =>
3221 [branchName, path.normalize(path.resolve(worktreePath))] as const,
3222 )
3223 : [];
3224 const existingWorktreeEntries = yield* Effect.filter(
3225 parsedWorktreeEntries,
3226 ([, worktreePath]) =>
3227 fileSystem.stat(worktreePath).pipe(
3228 Effect.as(true),
3229 Effect.orElseSucceed(() => false),
3230 ),
3231 { concurrency: 16 },
3232 );
3233 const worktreeMap = new Map(existingWorktreeEntries);
3234 const localBranches: Array<{ readonly ref: VcsRef; readonly lastCommit: number }> = [];
3235 const remoteBranches: Array<{ readonly ref: VcsRef; readonly lastCommit: number }> = [];
3236
3237 for (const line of refsResult.stdout.split("\n")) {
3238 if (line.length === 0) continue;
3239 const [fullRefName, lastCommitRaw, symbolicTarget] = line.split("\t");
3240 if (!fullRefName || symbolicTarget) continue;
3241 const parsedLastCommit = Number.parseInt(lastCommitRaw ?? "0", 10);
3242 const lastCommit = Number.isFinite(parsedLastCommit) ? parsedLastCommit : 0;
3243
3244 if (fullRefName.startsWith("refs/heads/")) {
3245 const name = fullRefName.slice("refs/heads/".length);
3246 localBranches.push({
3247 ref: {
3248 name,
3249 current: false,
3250 isRemote: false,
3251 isDefault: name === defaultBranch,
3252 worktreePath: worktreeMap.get(name) ?? null,
3253 },
3254 lastCommit,
3255 });
3256 continue;
3257 }
3258 if (!fullRefName.startsWith("refs/remotes/")) continue;
3259
3260 const name = fullRefName.slice("refs/remotes/".length);
3261 const parsedRemoteRef = parseRemoteRefWithRemoteNames(name, remoteNames);
3262 const remoteBranch: VcsRef = {
3263 name,
3264 current: false,
3265 isRemote: true,
3266 isDefault:
3267 defaultBranch !== null &&
3268 parsedRemoteRef?.remoteName === "origin" &&
3269 parsedRemoteRef.branchName === defaultBranch,
3270 worktreePath: null,
3271 ...(parsedRemoteRef ? { remoteName: parsedRemoteRef.remoteName } : {}),
3272 };
3273 remoteBranches.push({ ref: remoteBranch, lastCommit });
3274 }
3275
3276 const byRecencyThenName = (
3277 left: { readonly ref: VcsRef; readonly lastCommit: number },
3278 right: { readonly ref: VcsRef; readonly lastCommit: number },
3279 ) =>
3280 left.lastCommit !== right.lastCommit
3281 ? right.lastCommit - left.lastCommit
3282 : left.ref.name.localeCompare(right.ref.name);
3283
3284 return {
3285 localBranches: localBranches.toSorted(byRecencyThenName).map(({ ref }) => ref),
3286 remoteBranches: remoteBranches.toSorted(byRecencyThenName).map(({ ref }) => ref),
3287 hasPrimaryRemote: remoteNames.includes("origin"),
3288 } satisfies GitRefsSnapshot;
3289 });
3290
3291 const listRefsEpochByCommonDir = new Map<string, number>();
3292 let listRefsEpochSequence = 0;
3293 const bumpListRefsEpoch = (gitCommonDir: string): number => {
3294 const nextEpoch = ++listRefsEpochSequence;
3295 listRefsEpochByCommonDir.delete(gitCommonDir);
3296 listRefsEpochByCommonDir.set(gitCommonDir, nextEpoch);
3297 if (listRefsEpochByCommonDir.size > LIST_REFS_SNAPSHOT_CACHE_CAPACITY) {
3298 const oldestKey = listRefsEpochByCommonDir.keys().next().value;
3299 if (oldestKey !== undefined) {
3300 listRefsEpochByCommonDir.delete(oldestKey);
3301 }
3302 }
3303 return nextEpoch;
3304 };
3305 const listRefsGenerationByCommonDir = new Map<string, number>();
3306 let listRefsGenerationSequence = 0;
3307 const setListRefsGeneration = (gitCommonDir: string, generation: number): number => {
3308 listRefsGenerationByCommonDir.delete(gitCommonDir);
3309 listRefsGenerationByCommonDir.set(gitCommonDir, generation);
3310 if (listRefsGenerationByCommonDir.size > LIST_REFS_SNAPSHOT_CACHE_CAPACITY) {
3311 const oldestKey = listRefsGenerationByCommonDir.keys().next().value;
3312 if (oldestKey !== undefined) {
3313 listRefsGenerationByCommonDir.delete(oldestKey);
3314 }
3315 }
3316 return generation;
3317 };
3318 const currentListRefsGeneration = (gitCommonDir: string): number => {
3319 const current = listRefsGenerationByCommonDir.get(gitCommonDir);
3320 return current === undefined
3321 ? setListRefsGeneration(gitCommonDir, ++listRefsGenerationSequence)
3322 : setListRefsGeneration(gitCommonDir, current);
3323 };
3324 const bumpListRefsGeneration = (gitCommonDir: string): number =>
3325 setListRefsGeneration(gitCommonDir, ++listRefsGenerationSequence);
3326 const listRefsSnapshotCache = yield* Cache.makeWith(
3327 (cacheKey: GitRefsSnapshotCacheKey) => readGitRefsSnapshot(cacheKey.gitCommonDir),
3328 {
3329 capacity: LIST_REFS_SNAPSHOT_CACHE_CAPACITY,
3330 timeToLive: (exit) => (Exit.isSuccess(exit) ? LIST_REFS_SNAPSHOT_CACHE_TTL : Duration.zero),
3331 },
3332 );
3333 const listRefsRefreshSnapshotCache = yield* Cache.makeWith(
3334 (cacheKey: GitRefsRefreshCacheKey) =>
3335 Effect.suspend(() => {
3336 const epoch = bumpListRefsEpoch(cacheKey.gitCommonDir);
3337 return Cache.get(
3338 listRefsSnapshotCache,
3339 new GitRefsSnapshotCacheKey({ gitCommonDir: cacheKey.gitCommonDir, epoch }),
3340 );
3341 }),
3342 {
3343 capacity: LIST_REFS_SNAPSHOT_CACHE_CAPACITY,
3344 timeToLive: (exit) =>
3345 Exit.isSuccess(exit) ? LIST_REFS_REFRESH_COALESCE_TTL : LIST_REFS_REFRESH_FAILURE_COOLDOWN,
3346 },
3347 );
3348 const resolveListRefsSnapshot = Effect.fn("resolveListRefsSnapshot")(function* (
3349 gitCommonDir: string,
3350 refresh: boolean,
3351 ) {
3352 while (true) {
3353 const generation = currentListRefsGeneration(gitCommonDir);
3354 const currentEpoch = listRefsEpochByCommonDir.get(gitCommonDir);
3355 const snapshot =
3356 refresh || currentEpoch === undefined
3357 ? // The refresh cache owns the complete snapshot read, rather than only the
3358 // epoch bump. Slow repositories therefore remain singleflight for the
3359 // entire Git scan even when more refresh requests arrive after the
3360 // coalescing TTL would otherwise have elapsed.
3361 yield* Cache.get(
3362 listRefsRefreshSnapshotCache,
3363 new GitRefsRefreshCacheKey({ gitCommonDir, generation }),
3364 )
3365 : yield* Cache.get(
3366 listRefsSnapshotCache,
3367 new GitRefsSnapshotCacheKey({ gitCommonDir, epoch: currentEpoch }),
3368 );
3369 if (currentListRefsGeneration(gitCommonDir) === generation) {
3370 return snapshot;
3371 }
3372 }
3373 });
3374 const invalidateListRefsSnapshot = Effect.fn("invalidateListRefsSnapshot")(function* (
3375 cwd: string,
3376 ) {
3377 const repositoryPathsCacheKey = normalizeRepositoryPathsCacheKey(cwd);
3378 const repositoryPaths = yield* Cache.get(repositoryPathsCache, repositoryPathsCacheKey);
3379 if (repositoryPaths === null) return;
3380 const previousGeneration = currentListRefsGeneration(repositoryPaths.gitCommonDir);
3381 bumpListRefsGeneration(repositoryPaths.gitCommonDir);
3382 bumpListRefsEpoch(repositoryPaths.gitCommonDir);
3383 yield* Cache.invalidate(
3384 listRefsRefreshSnapshotCache,
3385 new GitRefsRefreshCacheKey({
3386 gitCommonDir: repositoryPaths.gitCommonDir,
3387 generation: previousGeneration,
3388 }),
3389 );
3390 yield* Cache.invalidate(repositoryPathsRefreshCache, repositoryPathsCacheKey);
3391 yield* Cache.invalidate(repositoryPathsCache, repositoryPathsCacheKey);
3392 });
3393
3394 const listRefs: GitVcsDriver.GitVcsDriver["Service"]["listRefs"] = Effect.fn("listRefs")(
3395 function* (input) {
3396 const repositoryPaths = yield* resolveRepositoryPaths(input.cwd, input.refresh === true).pipe(
3397 Effect.catchTags({
3398 GitCommandError: (error) =>
3399 isMissingGitCwdError(error) ? Effect.succeed(null) : Effect.fail(error),
3400 }),
3401 );
3402 if (repositoryPaths === null) {
3403 return {
3404 refs: [],
3405 isRepo: false,
3406 hasPrimaryRemote: false,
3407 nextCursor: null,
3408 totalCount: 0,
3409 };
3410 }
3411
3412 const snapshot = yield* resolveListRefsSnapshot(
3413 repositoryPaths.gitCommonDir,
3414 input.refresh === true,
3415 );
3416 const hasCurrentWorktreeBranch =
3417 repositoryPaths.worktreeRoot !== null &&
3418 snapshot.localBranches.some((ref) => ref.worktreePath === repositoryPaths.worktreeRoot);
3419 const localBranches = snapshot.localBranches.map((ref) => ({
3420 ...ref,
3421 current: hasCurrentWorktreeBranch
3422 ? ref.worktreePath === repositoryPaths.worktreeRoot
3423 : ref.name === repositoryPaths.currentBranch,
3424 }));
3425 const combinedBranches = input.includeMatchingRemoteRefs
3426 ? [...localBranches, ...snapshot.remoteBranches]
3427 : dedupeRemoteBranchesWithLocalMatches([...localBranches, ...snapshot.remoteBranches]);
3428 // Keep current/default refs on the first page even when the default
3429 // only exists as origin/<default> (remote refs sort after all locals).
3430 const allBranches = combinedBranches.toSorted((left, right) => {
3431 const leftPriority = left.current ? 0 : left.isDefault ? 1 : 2;
3432 const rightPriority = right.current ? 0 : right.isDefault ? 1 : 2;
3433 return leftPriority - rightPriority;
3434 });
3435 const branchesForKind =
3436 input.refKind === "local"
3437 ? allBranches.filter((ref) => !ref.isRemote)
3438 : input.refKind === "remote"
3439 ? allBranches.filter((ref) => ref.isRemote)
3440 : allBranches;
3441 const refs = paginateBranches({
3442 refs: filterBranchesForListQuery(branchesForKind, input.query),
3443 cursor: input.cursor,
3444 limit: input.limit,
3445 });
3446
3447 return {
3448 refs: [...refs.refs],
3449 isRepo: true,
3450 hasPrimaryRemote: snapshot.hasPrimaryRemote,
3451 nextCursor: refs.nextCursor,
3452 totalCount: refs.totalCount,
3453 };
3454 },
3455 );
3456
3457 const createWorktree: GitVcsDriver.GitVcsDriver["Service"]["createWorktree"] = Effect.fn(
3458 "createWorktree",
3459 )(function* (input, options) {
3460 const targetBranch = input.newRefName ?? input.refName;
3461 const sanitizedBranch = targetBranch.replace(/\//g, "-");
3462 const repoName = path.basename(input.cwd);
3463 let worktreePath = input.path;
3464 if (worktreePath == null) {
3465 const parentDir = resolveWorktreesDirectory(
3466 options?.worktreesDirectory ?? "",
3467 worktreesDir,
3468 path,
3469 yield* HostProcess.HomeDirectory,
3470 );
3471 if (parentDir === null) {
3472 return yield* new GitCommandError({
3473 operation: "GitVcsDriver.createWorktree",
3474 command: "git worktree add",
3475 cwd: input.cwd,
3476 detail: `The worktree location "${options?.worktreesDirectory}" must be an absolute folder on this machine, not a drive root. Change it in Settings → Storage.`,
3477 });
3478 }
3479 worktreePath = path.join(parentDir, repoName, sanitizedBranch);
3480 }
3481 const args = input.newRefName
3482 ? ["worktree", "add", "-b", input.newRefName, worktreePath, input.refName]
3483 : ["worktree", "add", worktreePath, input.refName];
3484 const progress = options?.progress;
3485 const onCheckoutProgress = progress?.onCheckoutProgress;
3486
3487 const checkoutWorkers = (yield* readConfigValue(input.cwd, "checkout.workers")) ?? "0";
3488 yield* executeGit(
3489 "GitVcsDriver.createWorktree",
3490 input.cwd,
3491 ["-c", `checkout.workers=${checkoutWorkers}`, ...args],
3492 {
3493 fallbackErrorDetail: "git worktree add failed",
3494 timeoutMs: WORKTREE_ADD_TIMEOUT_MS,
3495 ...(onCheckoutProgress
3496 ? {
3497 // Git only prints checkout progress when stderr is a tty or the
3498 // delay elapsed. GIT_PROGRESS_DELAY=0 forces it through the pipe.
3499 env: { GIT_PROGRESS_DELAY: "0", LC_ALL: "C" },
3500 progress: {
3501 onStderrLine: (line) => {
3502 const parsed = parseGitCheckoutProgressLine(line);
3503 return parsed ? onCheckoutProgress(parsed) : Effect.void;
3504 },
3505 },
3506 }
3507 : {}),
3508 },
3509 );
3510
3511 if (progress?.onWorktreeClaimed) {
3512 yield* progress.onWorktreeClaimed(worktreePath);
3513 }
3514
3515 // `git worktree add` leaves submodules empty, so a repo that keeps agent
3516 // skills, tooling or source in one gets a worktree that is quietly missing
3517 // them. Best-effort: the objects are usually already in the parent's
3518 // `.git/modules`, but a first-ever clone needs the network, and failing to
3519 // populate a submodule must not roll back the caller's thread. Repos with
3520 // hundreds of nested submodules opt out or stop at the top level; the
3521 // caller resolves that from settings, or the checkout's t3.json decides.
3522 const hasSubmodules = yield* fileSystem
3523 .exists(path.join(worktreePath, ".gitmodules"))
3524 .pipe(Effect.orElseSucceed(() => false));
3525 const submoduleMode = !hasSubmodules
3526 ? { value: "none" as const, source: "environment" as const }
3527 : resolveProjectFileBackedSetting(
3528 "worktreeSubmodules",
3529 options?.submodules ?? null,
3530 options?.submodules != null
3531 ? null
3532 : yield* fileSystem.readFileString(path.join(worktreePath, T3_PROJECT_FILE_NAME)).pipe(
3533 Effect.flatMap((contents) => {
3534 const file = parseT3ProjectFile(contents);
3535 return file === null
3536 ? Effect.logWarning("t3.json is invalid; initializing submodules recursively", {
3537 worktreePath,
3538 }).pipe(Effect.as(null))
3539 : Effect.succeed(file);
3540 }),
3541 Effect.orElseSucceed(() => null),
3542 ),
3543 );
3544 if (hasSubmodules && submoduleMode.value === "none" && progress?.onSubmodulesDisabled) {
3545 yield* progress.onSubmodulesDisabled({
3546 source: submoduleMode.source === "t3.json" ? "t3.json" : "settings",
3547 });
3548 }
3549 if (submoduleMode.value !== "none") {
3550 if (progress?.onSubmodulesStarted) {
3551 yield* progress.onSubmodulesStarted();
3552 }
3553 const onSubmoduleLine = progress?.onSubmoduleLine;
3554 yield* runGit(
3555 "GitVcsDriver.createWorktree.updateSubmodules",
3556 worktreePath,
3557 submoduleMode.value === "recursive"
3558 ? ["submodule", "update", "--init", "--recursive"]
3559 : ["submodule", "update", "--init"],
3560 onSubmoduleLine
3561 ? {
3562 env: { LC_ALL: "C" },
3563 progress: { onStdoutLine: onSubmoduleLine, onStderrLine: onSubmoduleLine },
3564 }
3565 : {},
3566 ).pipe(
3567 Effect.matchEffect({
3568 onFailure: (cause) =>
3569 Effect.logWarning("worktree submodule checkout failed; submodule paths are empty", {
3570 worktreePath,
3571 cause,
3572 }).pipe(
3573 Effect.andThen(
3574 progress?.onSubmodulesFinished
3575 ? progress.onSubmodulesFinished({ ok: false, detail: cause.message })
3576 : Effect.void,
3577 ),
3578 ),
3579 onSuccess: () =>
3580 progress?.onSubmodulesFinished
3581 ? progress.onSubmodulesFinished({ ok: true, detail: null })
3582 : Effect.void,
3583 }),
3584 );
3585 }
3586
3587 if (input.newRefName && input.baseRefName) {
3588 // A checkout can start at a commit or tag, but a PR must target a branch.
3589 const baseBranch = yield* resolveBranchName(input.cwd, input.baseRefName);
3590 if (baseBranch) {
3591 yield* runGit("GitVcsDriver.createWorktree.configureBaseRef", input.cwd, [
3592 "config",
3593 `branch.${input.newRefName}.gh-merge-base`,
3594 baseBranch,
3595 ]);
3596 }
3597 }
3598
3599 return {
3600 worktree: {
3601 path: worktreePath,
3602 refName: targetBranch,
3603 },
3604 };
3605 });
3606
3607 const fetchPullRequestBranch: GitVcsDriver.GitVcsDriver["Service"]["fetchPullRequestBranch"] =
3608 Effect.fn("fetchPullRequestBranch")(function* (input) {
3609 const remoteName = yield* resolvePrimaryRemoteName(input.cwd);
3610 yield* executeGit(
3611 "GitVcsDriver.fetchPullRequestBranch",
3612 input.cwd,
3613 [
3614 "fetch",
3615 "--quiet",
3616 "--no-tags",
3617 remoteName,
3618 `+refs/pull/${input.prNumber}/head:refs/heads/${input.branch}`,
3619 ],
3620 {
3621 fallbackErrorDetail: "git fetch pull request branch failed",
3622 },
3623 );
3624 });
3625
3626 const resolveCommit: GitVcsDriver.GitVcsDriver["Service"]["resolveCommit"] = Effect.fn(
3627 "resolveCommit",
3628 )(function* (input) {
3629 const commitSha = yield* runGitStdout("GitVcsDriver.resolveCommit", input.cwd, [
3630 "rev-parse",
3631 "--verify",
3632 `${input.revision}^{commit}`,
3633 ]).pipe(Effect.map((stdout) => stdout.trim()));
3634
3635 return { commitSha };
3636 });
3637
3638 const fetchPullRequestHeadCommit: GitVcsDriver.GitVcsDriver["Service"]["fetchPullRequestHeadCommit"] =
3639 Effect.fn("fetchPullRequestHeadCommit")(function* (input) {
3640 const remoteName = yield* resolvePrimaryRemoteName(input.cwd);
3641 // No refspec destination: the pull head lands in FETCH_HEAD (per worktree) instead of a
3642 // branch, which is the only way to read it while that branch is checked out somewhere.
3643 yield* executeGit(
3644 "GitVcsDriver.fetchPullRequestHeadCommit",
3645 input.cwd,
3646 ["fetch", "--quiet", "--no-tags", remoteName, `refs/pull/${input.prNumber}/head`],
3647 {
3648 fallbackErrorDetail: "git fetch pull request head failed",
3649 },
3650 );
3651
3652 return yield* resolveCommit({ cwd: input.cwd, revision: "FETCH_HEAD" });
3653 });
3654
3655 const refreshCheckedOutBranch: GitVcsDriver.GitVcsDriver["Service"]["refreshCheckedOutBranch"] =
3656 Effect.fn("refreshCheckedOutBranch")(function* (input) {
3657 const { commitSha: headCommit } = yield* resolveCommit({ cwd: input.cwd, revision: "HEAD" });
3658 if (headCommit === input.targetCommit) {
3659 return { headCommit, moved: false, onTarget: true };
3660 }
3661
3662 const worktreeChanges = yield* runGitStdout(
3663 "GitVcsDriver.refreshCheckedOutBranch.status",
3664 input.cwd,
3665 ["status", "--porcelain"],
3666 );
3667 if (worktreeChanges.trim().length > 0) {
3668 return { headCommit, moved: false, onTarget: false };
3669 }
3670
3671 const isAncestor = yield* executeGit(
3672 "GitVcsDriver.refreshCheckedOutBranch.isAncestor",
3673 input.cwd,
3674 ["merge-base", "--is-ancestor", headCommit, input.targetCommit],
3675 { allowNonZeroExit: true },
3676 ).pipe(Effect.map((result) => result.exitCode === 0));
3677 // A rewritten head (rebase, squash, amend) does not descend from the checkout, so it can
3678 // only be taken by resetting. That is lossless exactly when the tree is clean and HEAD
3679 // never left the commit the upstream held before the fetch.
3680 if (!isAncestor && headCommit !== input.resetWhenHeadCommit) {
3681 return { headCommit, moved: false, onTarget: false };
3682 }
3683
3684 if (!isAncestor) {
3685 // The commit being reset away is about to be reachable from nothing. It is only ever a
3686 // commit the remote already held, but "the remote held it" stops being a way back once
3687 // the head it belonged to has been rewritten, so a ref keeps it findable.
3688 yield* executeGit(
3689 "GitVcsDriver.refreshCheckedOutBranch.keepPrevious",
3690 input.cwd,
3691 ["update-ref", "refs/t3code/pre-refresh", headCommit],
3692 { fallbackErrorDetail: "git failed to record the previous checkout commit" },
3693 );
3694 }
3695
3696 yield* executeGit(
3697 "GitVcsDriver.refreshCheckedOutBranch.move",
3698 input.cwd,
3699 // `--merge` rather than `--hard`: the cleanliness check above is a snapshot, and another
3700 // thread may edit a tracked file between it and this move. Git itself refuses a `--merge`
3701 // reset that would overwrite such an edit — the same guarantee `--ff-only` gives the
3702 // other branch — so a race loses nothing; the refresh fails and is reported instead.
3703 isAncestor
3704 ? ["merge", "--ff-only", input.targetCommit]
3705 : ["reset", "--merge", input.targetCommit],
3706 {
3707 timeoutMs: 30_000,
3708 fallbackErrorDetail: "git failed to move the checkout onto the pull request head",
3709 },
3710 );
3711
3712 return { headCommit: input.targetCommit, moved: true, onTarget: true };
3713 });
3714
3715 const fetchRemote: GitVcsDriver.GitVcsDriver["Service"]["fetchRemote"] = Effect.fn("fetchRemote")(
3716 function* (input) {
3717 const args = ["fetch", "--quiet", input.remoteName];
3718 const options = {
3719 env: STATUS_UPSTREAM_REFRESH_ENV,
3720 fallbackErrorDetail: `git fetch ${input.remoteName} failed`,
3721 };
3722 // Another fetch in the same repository, such as the background status
3723 // poll, can win the race for a remote-tracking ref. The loser fails, but
3724 // the ref is free again by the time a rerun needs it, so retry once.
3725 const runFetch = (fetchArgs: readonly string[]) => {
3726 const run = executeGitWithStableDiagnostics(
3727 "GitVcsDriver.fetchRemote",
3728 input.cwd,
3729 fetchArgs,
3730 {
3731 ...options,
3732 allowNonZeroExit: true,
3733 },
3734 );
3735 return run.pipe(
3736 Effect.flatMap((result) =>
3737 result.exitCode !== 0 && isRefLockFailure(result.stderr) ? run : Effect.succeed(result),
3738 ),
3739 );
3740 };
3741 const fetchAll = runFetch(args).pipe(
3742 Effect.flatMap((result) =>
3743 result.exitCode === 0
3744 ? Effect.void
3745 : Effect.fail(
3746 new GitCommandError({
3747 ...gitCommandContext({
3748 operation: "GitVcsDriver.fetchRemote",
3749 cwd: input.cwd,
3750 args,
3751 }),
3752 detail: fetchFailureDetail(result.stderr) ?? options.fallbackErrorDetail,
3753 ...(result.exitCode === null ? {} : { exitCode: result.exitCode }),
3754 stdoutLength: result.stdout.length,
3755 stderrLength: result.stderr.length,
3756 }),
3757 ),
3758 ),
3759 );
3760 if (input.refName === undefined) {
3761 return yield* fetchAll.pipe(Effect.asVoid);
3762 }
3763 const branch =
3764 parseRemoteRefWithRemoteNames(input.refName, [input.remoteName])?.branchName ??
3765 input.refName;
3766 const scopedArgs = [
3767 ...args,
3768 `+refs/heads/${branch}:refs/remotes/${input.remoteName}/${branch}`,
3769 ];
3770 const result = yield* runFetch(scopedArgs);
3771 if (result.exitCode === 0) return;
3772 if (
3773 result.stderr
3774 .split(/\r?\n/)
3775 .includes(`fatal: couldn't find remote ref refs/heads/${branch}`)
3776 ) {
3777 return yield* fetchAll.pipe(Effect.asVoid);
3778 }
3779 return yield* new GitCommandError({
3780 ...gitCommandContext({
3781 operation: "GitVcsDriver.fetchRemote",
3782 cwd: input.cwd,
3783 args: scopedArgs,
3784 }),
3785 detail: fetchFailureDetail(result.stderr) ?? options.fallbackErrorDetail,
3786 ...(result.exitCode === null ? {} : { exitCode: result.exitCode }),
3787 stdoutLength: result.stdout.length,
3788 stderrLength: result.stderr.length,
3789 });
3790 },
3791 );
3792
3793 const resolveRemoteTrackingCommit: GitVcsDriver.GitVcsDriver["Service"]["resolveRemoteTrackingCommit"] =
3794 Effect.fn("resolveRemoteTrackingCommit")(function* (input) {
3795 const remoteNames = yield* listRemoteNames(input.cwd);
3796 const parsedRemoteRef = parseRemoteRefWithRemoteNames(
3797 input.refName,
3798 remoteNames.toSorted((left, right) => right.length - left.length),
3799 );
3800 const remoteRefName =
3801 parsedRemoteRef?.remoteRef ?? `${input.fallbackRemoteName}/${input.refName}`;
3802 const commitSha = yield* runGitStdout("GitVcsDriver.resolveRemoteTrackingCommit", input.cwd, [
3803 "rev-parse",
3804 "--verify",
3805 `refs/remotes/${remoteRefName}^{commit}`,
3806 ]).pipe(Effect.map((stdout) => stdout.trim()));
3807
3808 return { commitSha, remoteRefName };
3809 });
3810
3811 const fetchRemoteBranch: GitVcsDriver.GitVcsDriver["Service"]["fetchRemoteBranch"] = Effect.fn(
3812 "fetchRemoteBranch",
3813 )(function* (input) {
3814 yield* runGit("GitVcsDriver.fetchRemoteBranch.fetch", input.cwd, [
3815 "fetch",
3816 "--quiet",
3817 "--no-tags",
3818 input.remoteName,
3819 `+refs/heads/${input.remoteBranch}:refs/remotes/${input.remoteName}/${input.remoteBranch}`,
3820 ]);
3821
3822 const localBranchAlreadyExists = yield* branchExists(input.cwd, input.localBranch);
3823 const targetRef = `${input.remoteName}/${input.remoteBranch}`;
3824 yield* runGit(
3825 "GitVcsDriver.fetchRemoteBranch.materialize",
3826 input.cwd,
3827 localBranchAlreadyExists
3828 ? ["branch", "--force", input.localBranch, targetRef]
3829 : ["branch", input.localBranch, targetRef],
3830 );
3831 });
3832
3833 const fetchRemoteTrackingBranch: GitVcsDriver.GitVcsDriver["Service"]["fetchRemoteTrackingBranch"] =
3834 Effect.fn("fetchRemoteTrackingBranch")(function* (input) {
3835 yield* runGit("GitVcsDriver.fetchRemoteTrackingBranch", input.cwd, [
3836 "fetch",
3837 "--quiet",
3838 "--no-tags",
3839 input.remoteName,
3840 `+refs/heads/${input.remoteBranch}:refs/remotes/${input.remoteName}/${input.remoteBranch}`,
3841 ]);
3842 });
3843
3844 const setBranchUpstream: GitVcsDriver.GitVcsDriver["Service"]["setBranchUpstream"] = (input) =>
3845 runGit("GitVcsDriver.setBranchUpstream", input.cwd, [
3846 "branch",
3847 "--set-upstream-to",
3848 `${input.remoteName}/${input.remoteBranch}`,
3849 input.branch,
3850 ]);
3851
3852 const removeWorktree: GitVcsDriver.GitVcsDriver["Service"]["removeWorktree"] = Effect.fn(
3853 "removeWorktree",
3854 )(function* (input) {
3855 // Git refuses to remove a worktree with untracked files unless forced, but
3856 // its check honors `status.showUntrackedFiles=no` and would delete them.
3857 const args = ["-c", "status.showUntrackedFiles=normal", "worktree", "remove"];
3858 if (input.force) {
3859 args.push("--force");
3860 }
3861 args.push(input.path);
3862 const result = yield* executeGitWithStableDiagnostics(
3863 "GitVcsDriver.removeWorktree",
3864 input.cwd,
3865 args,
3866 {
3867 // Removing dependency-heavy worktrees is filesystem-bound and can take
3868 // minutes, especially on Windows. Keep it bounded without interrupting
3869 // git midway through cleanup.
3870 timeoutMs: WORKTREE_REMOVE_TIMEOUT_MS,
3871 allowNonZeroExit: true,
3872 },
3873 );
3874 if (result.exitCode === 0) {
3875 return;
3876 }
3877 // Threads can share a worktree path, and worktrees get removed or pruned
3878 // outside the app, so a worktree that is already gone is a no-op rather
3879 // than an error. Prune so no stale registration lingers to block a later
3880 // `worktree add` at the same path.
3881 const alreadyGone =
3882 isMissingWorktreeStderr(result.stderr) &&
3883 !(yield* fileSystem.exists(input.path).pipe(Effect.orElseSucceed(() => false)));
3884 if (alreadyGone) {
3885 yield* pruneWorktrees({ cwd: input.cwd });
3886 return;
3887 }
3888 // Raw stderr stays out of both the wire error and the log (it can carry
3889 // secrets); log bounded diagnostics so a genuine failure is visible
3890 // server-side.
3891 yield* Effect.logWarning(
3892 `GitVcsDriver.removeWorktree: git worktree remove exited with code ${result.exitCode} for ${input.path} (stderr length ${result.stderr.length}).`,
3893 );
3894 return yield* new GitCommandError({
3895 ...gitCommandContext({ operation: "GitVcsDriver.removeWorktree", cwd: input.cwd, args }),
3896 detail: "git worktree remove failed",
3897 ...(result.exitCode === null ? {} : { exitCode: result.exitCode }),
3898 stdoutLength: result.stdout.length,
3899 stderrLength: result.stderr.length,
3900 });
3901 });
3902
3903 const pruneWorktrees: GitVcsDriver.GitVcsDriver["Service"]["pruneWorktrees"] = Effect.fn(
3904 "pruneWorktrees",
3905 )(function* (input) {
3906 yield* executeGit("GitVcsDriver.pruneWorktrees", input.cwd, ["worktree", "prune"], {
3907 timeoutMs: 15_000,
3908 fallbackErrorDetail: "git worktree prune failed",
3909 });
3910 });
3911
3912 const deleteLocalBranch: GitVcsDriver.GitVcsDriver["Service"]["deleteLocalBranch"] = Effect.fn(
3913 "deleteLocalBranch",
3914 )(function* (input) {
3915 yield* executeGit(
3916 "GitVcsDriver.deleteLocalBranch",
3917 input.cwd,
3918 ["branch", input.force === true ? "-D" : "-d", "--", input.refName],
3919 {
3920 timeoutMs: 10_000,
3921 fallbackErrorDetail: "git branch delete failed",
3922 },
3923 );
3924 });
3925
3926 const renameBranch: GitVcsDriver.GitVcsDriver["Service"]["renameBranch"] = Effect.fn(
3927 "renameBranch",
3928 )(function* (input) {
3929 if (input.oldBranch === input.newBranch) {
3930 return { branch: input.newBranch };
3931 }
3932 const targetBranch = input.exactName
3933 ? input.newBranch
3934 : yield* resolveAvailableBranchName(input.cwd, input.newBranch);
3935
3936 yield* executeGit(
3937 "GitVcsDriver.renameBranch",
3938 input.cwd,
3939 ["branch", "-m", "--", input.oldBranch, targetBranch],
3940 {
3941 timeoutMs: 10_000,
3942 fallbackErrorDetail: "git branch rename failed",
3943 },
3944 );
3945
3946 return { branch: targetBranch };
3947 });
3948
3949 const switchRef: GitVcsDriver.GitVcsDriver["Service"]["switchRef"] = Effect.fn("switchRef")(
3950 function* (input) {
3951 const [localInputExists, remoteExists] = yield* Effect.all(
3952 [
3953 executeGit(
3954 "GitVcsDriver.switchRef.localInputExists",
3955 input.cwd,
3956 ["show-ref", "--verify", "--quiet", `refs/heads/${input.refName}`],
3957 {
3958 timeoutMs: 5_000,
3959 allowNonZeroExit: true,
3960 },
3961 ).pipe(Effect.map((result) => result.exitCode === 0)),
3962 executeGit(
3963 "GitVcsDriver.switchRef.remoteExists",
3964 input.cwd,
3965 ["show-ref", "--verify", "--quiet", `refs/remotes/${input.refName}`],
3966 {
3967 timeoutMs: 5_000,
3968 allowNonZeroExit: true,
3969 },
3970 ).pipe(Effect.map((result) => result.exitCode === 0)),
3971 ],
3972 { concurrency: "unbounded" },
3973 );
3974
3975 const localTrackingBranch = remoteExists
3976 ? yield* executeGit(
3977 "GitVcsDriver.switchRef.localTrackingBranch",
3978 input.cwd,
3979 ["for-each-ref", "--format=%(refname:short)\t%(upstream:short)", "refs/heads"],
3980 {
3981 timeoutMs: 5_000,
3982 allowNonZeroExit: true,
3983 },
3984 ).pipe(
3985 Effect.map((result) =>
3986 result.exitCode === 0
3987 ? parseTrackingBranchByUpstreamRef(result.stdout, input.refName)
3988 : null,
3989 ),
3990 )
3991 : null;
3992
3993 const localTrackedBranchCandidate = deriveLocalBranchNameFromRemoteRef(input.refName);
3994 const localTrackedBranchTargetExists =
3995 remoteExists && localTrackedBranchCandidate
3996 ? yield* executeGit(
3997 "GitVcsDriver.switchRef.localTrackedBranchTargetExists",
3998 input.cwd,
3999 ["show-ref", "--verify", "--quiet", `refs/heads/${localTrackedBranchCandidate}`],
4000 {
4001 timeoutMs: 5_000,
4002 allowNonZeroExit: true,
4003 },
4004 ).pipe(Effect.map((result) => result.exitCode === 0))
4005 : false;
4006
4007 const checkoutArgs = localInputExists
4008 ? ["checkout", input.refName]
4009 : remoteExists && !localTrackingBranch && localTrackedBranchTargetExists
4010 ? ["checkout", input.refName]
4011 : remoteExists && !localTrackingBranch
4012 ? ["checkout", "--track", input.refName]
4013 : remoteExists && localTrackingBranch
4014 ? ["checkout", localTrackingBranch]
4015 : ["checkout", input.refName];
4016
4017 // A stale ref must not turn into a path checkout that discards local edits.
4018 yield* executeGit("GitVcsDriver.switchRef.checkout", input.cwd, [...checkoutArgs, "--"], {
4019 timeoutMs: 10_000,
4020 fallbackErrorDetail: "git checkout failed",
4021 });
4022
4023 const refName = yield* runGitStdout("GitVcsDriver.switchRef.currentBranch", input.cwd, [
4024 "branch",
4025 "--show-current",
4026 ]).pipe(Effect.map((stdout) => stdout.trim() || null));
4027
4028 return { refName };
4029 },
4030 );
4031
4032 const createRef: GitVcsDriver.GitVcsDriver["Service"]["createRef"] = Effect.fn("createRef")(
4033 function* (input) {
4034 yield* executeGit("GitVcsDriver.createRef", input.cwd, ["branch", input.refName], {
4035 timeoutMs: 10_000,
4036 fallbackErrorDetail: "git branch create failed",
4037 });
4038 if (input.switchRef) {
4039 yield* switchRef({ cwd: input.cwd, refName: input.refName });
4040 }
4041
4042 return { refName: input.refName };
4043 },
4044 );
4045
4046 const initRepo: GitVcsDriver.GitVcsDriver["Service"]["initRepo"] = (input) =>
4047 executeGit("GitVcsDriver.initRepo", input.cwd, ["init"], {
4048 timeoutMs: 10_000,
4049 fallbackErrorDetail: "git init failed",
4050 }).pipe(Effect.asVoid);
4051
4052 const listLocalBranchNames: GitVcsDriver.GitVcsDriver["Service"]["listLocalBranchNames"] = (
4053 cwd,
4054 ) =>
4055 runGitStdout("GitVcsDriver.listLocalBranchNames", cwd, [
4056 "branch",
4057 "--list",
4058 "--no-column",
4059 "--format=%(refname:short)",
4060 ]).pipe(
4061 Effect.map((stdout) => {
4062 const branchNames: Array<string> = [];
4063 for (const line of stdout.split("\n")) {
4064 const branchName = line.trim();
4065 if (branchName.length > 0) {
4066 branchNames.push(branchName);
4067 }
4068 }
4069 return branchNames;
4070 }),
4071 );
4072
4073 const listWorktreePaths: GitVcsDriver.GitVcsDriver["Service"]["listWorktreePaths"] = (cwd) =>
4074 executeWorktreeList("GitVcsDriver.listWorktreePaths", cwd, []).pipe(
4075 // One record per worktree, each ended by an empty field. A `prunable`
4076 // record's directory is gone, and another checkout may now sit there.
4077 Effect.map(({ stdout }) =>
4078 stdout.split("\0\0").flatMap((record) => {
4079 const fields = record.split("\0");
4080 const worktree = fields.find((field) => field.startsWith("worktree "));
4081 return worktree === undefined || fields.some((field) => field.startsWith("prunable"))
4082 ? []
4083 : [path.resolve(cwd, worktree.slice("worktree ".length))];
4084 }),
4085 ),
4086 );
4087
4088 const withListRefsInvalidation = <A, E>(
4089 cwd: string,
4090 effect: Effect.Effect<A, E>,
4091 ): Effect.Effect<A, E> =>
4092 effect.pipe(
4093 Effect.ensuring(
4094 Effect.all([
4095 invalidateListRefsSnapshot(cwd).pipe(Effect.ignore),
4096 invalidateStatusStaticCaches(cwd).pipe(Effect.ignore),
4097 ]),
4098 ),
4099 );
4100 const initRepoWithListRefsInvalidation: GitVcsDriver.GitVcsDriver["Service"]["initRepo"] = (
4101 input,
4102 ) =>
4103 initRepo(input).pipe(
4104 Effect.ensuring(
4105 Effect.gen(function* () {
4106 const cacheKey = normalizeRepositoryPathsCacheKey(input.cwd);
4107 yield* Cache.invalidate(repositoryPathsRefreshCache, cacheKey);
4108 yield* Cache.invalidate(repositoryPathsCache, cacheKey);
4109 yield* invalidateListRefsSnapshot(input.cwd).pipe(Effect.ignore);
4110 }),
4111 ),
4112 );
4113
4114 return GitVcsDriver.GitVcsDriver.of({
4115 execute,
4116 status,
4117 statusDetails,
4118 statusDetailsLocal,
4119 statusDetailsRemote,
4120 prepareCommitContext,
4121 commit: (cwd, subject, body, options) =>
4122 withListRefsInvalidation(cwd, commit(cwd, subject, body, options)),
4123 pushCurrentBranch: (cwd, fallbackBranch, options) =>
4124 withListRefsInvalidation(cwd, pushCurrentBranch(cwd, fallbackBranch, options)),
4125 pullCurrentBranch: (cwd) => withListRefsInvalidation(cwd, pullCurrentBranch(cwd)),
4126 readRangeContext,
4127 getReviewDiffPreview,
4128 getReviewDiffFileContents,
4129 readConfigValue,
4130 resolveBranchName,
4131 listRefs,
4132 createWorktree: (input, options) =>
4133 withListRefsInvalidation(input.cwd, createWorktree(input, options)),
4134 fetchPullRequestBranch: (input) =>
4135 withListRefsInvalidation(input.cwd, fetchPullRequestBranch(input)),
4136 fetchPullRequestHeadCommit,
4137 resolveCommit,
4138 refreshCheckedOutBranch: (input) =>
4139 withListRefsInvalidation(input.cwd, refreshCheckedOutBranch(input)),
4140 ensureRemote: (input) => withListRefsInvalidation(input.cwd, ensureRemote(input)),
4141 resolvePrimaryRemoteName,
4142 resolveDefaultBranchName,
4143 fetchRemote: (input) => withListRefsInvalidation(input.cwd, fetchRemote(input)),
4144 remoteExists,
4145 remoteBranchExists,
4146 resolveRemoteTrackingCommit,
4147 fetchRemoteBranch: (input) => withListRefsInvalidation(input.cwd, fetchRemoteBranch(input)),
4148 fetchRemoteTrackingBranch: (input) =>
4149 withListRefsInvalidation(input.cwd, fetchRemoteTrackingBranch(input)),
4150 setBranchUpstream: (input) => withListRefsInvalidation(input.cwd, setBranchUpstream(input)),
4151 removeWorktree: (input) => withListRefsInvalidation(input.cwd, removeWorktree(input)),
4152 pruneWorktrees: (input) => withListRefsInvalidation(input.cwd, pruneWorktrees(input)),
4153 deleteLocalBranch: (input) => withListRefsInvalidation(input.cwd, deleteLocalBranch(input)),
4154 renameBranch: (input) => withListRefsInvalidation(input.cwd, renameBranch(input)),
4155 createRef: (input) => withListRefsInvalidation(input.cwd, createRef(input)),
4156 switchRef: (input) => withListRefsInvalidation(input.cwd, switchRef(input)),
4157 initRepo: initRepoWithListRefsInvalidation,
4158 listLocalBranchNames,
4159 listWorktreePaths,
4160 });
4161});