import * as Arr from "effect/Array";import * as Cache from "effect/Cache";import * as Data from "effect/Data";import * as Crypto from "effect/Crypto";import * as DateTime from "effect/DateTime";import * as Duration from "effect/Duration";import * as Effect from "effect/Effect";import * as Encoding from "effect/Encoding";import * as Exit from "effect/Exit";import * as FileSystem from "effect/FileSystem";import * as Option from "effect/Option";import * as Path from "effect/Path";import * as PlatformError from "effect/PlatformError";import * as Ref from "effect/Ref";import * as Result from "effect/Result";import * as Schema from "effect/Schema";import * as Scope from "effect/Scope";import * as Semaphore from "effect/Semaphore";import * as Stream from "effect/Stream";import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { GitCommandError, type ReviewDiffFileContentsInput, type ReviewDiffPreviewInput, type ReviewDiffPreviewSource, type VcsRef,} from "@t3tools/contracts";import { dedupeRemoteBranchesWithLocalMatches, normalizeGitRemoteUrl } from "@t3tools/shared/git";import { compactTraceAttributes } from "@t3tools/shared/observability";import { decodeJsonResult } from "@t3tools/shared/schemaJson";import { gitCommandDuration, gitCommandsTotal, withMetrics } from "../observability/Metrics.ts";import * as GitVcsDriver from "./GitVcsDriver.ts";import { parseRemoteNames, parseRemoteNamesInGitOrder, parseRemoteRefWithRemoteNames,} from "../git/remoteRefs.ts";import { ServerConfig } from "../config.ts"; const DEFAULT_TIMEOUT_MS = 30_000;// `git worktree add` checks out the full tree, so on large repositories it can// take well beyond the default 30s (e.g. a 375k-file repo takes ~40s on an idle// machine). Give it generous headroom while still bounding a genuinely hung git.const WORKTREE_ADD_TIMEOUT_MS = 300_000;const WORKTREE_REMOVE_TIMEOUT_MS = Duration.toMillis(Duration.minutes(5));const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000;const OUTPUT_TRUNCATED_MARKER = "\n\n[truncated]";const PREPARED_COMMIT_PATCH_MAX_OUTPUT_BYTES = 49_000;const RANGE_COMMIT_SUMMARY_MAX_OUTPUT_BYTES = 19_000;const RANGE_DIFF_SUMMARY_MAX_OUTPUT_BYTES = 19_000;const RANGE_DIFF_PATCH_MAX_OUTPUT_BYTES = 59_000;const REVIEW_DIFF_PATCH_MAX_OUTPUT_BYTES = 120_000;const REVIEW_UNTRACKED_DIFF_MAX_OUTPUT_BYTES = 80_000;const REVIEW_DIFF_FILE_MAX_OUTPUT_BYTES = 1024 * 1024;// Patches the clients render are parsed against git's default a/ and b/ path// prefixes. A repository or global diff.noprefix or diff.mnemonicPrefix would// otherwise leak into the patch and leave every parsed file unnamed.export const PATCH_RENDER_PREFIX_ARGS = ["--src-prefix=a/", "--dst-prefix=b/"] as const;const WORKSPACE_FILES_MAX_OUTPUT_BYTES = 120_000;const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15);const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5); const STATUS_UPSTREAM_REFRESH_FAILURE_BASE_COOLDOWN = Duration.seconds(30);const STATUS_UPSTREAM_REFRESH_FAILURE_MAX_COOLDOWN = Duration.minutes(15);const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048;const REPOSITORY_PATHS_CACHE_CAPACITY = 2_048;const REPOSITORY_PATHS_CACHE_TTL = Duration.minutes(10);const REPOSITORY_PATHS_REFRESH_COALESCE_TTL = Duration.seconds(5);const NON_REPOSITORY_PATHS_CACHE_TTL = Duration.seconds(1);const LIST_REFS_SNAPSHOT_CACHE_CAPACITY = 64;const LIST_REFS_SNAPSHOT_CACHE_TTL = Duration.minutes(2);const LIST_REFS_REFRESH_COALESCE_TTL = Duration.seconds(5);const LIST_REFS_REFRESH_FAILURE_COOLDOWN = Duration.seconds(30);const STATUS_DEFAULT_BRANCH_CACHE_TTL = Duration.minutes(5);const STATUS_ORIGIN_EXISTS_CACHE_TTL = Duration.minutes(5);const STATUS_UPSTREAM_REFRESH_ENV = Object.freeze({ GCM_INTERACTIVE: "never", GIT_ASKPASS: "", GIT_TERMINAL_PROMPT: "0", SSH_ASKPASS: "", SSH_ASKPASS_REQUIRE: "never",} satisfies NodeJS.ProcessEnv);const DEFAULT_BASE_BRANCH_CANDIDATES = ["main", "master"] as const;const GIT_LIST_BRANCHES_DEFAULT_LIMIT = 100;const NON_REPOSITORY_STATUS_DETAILS = Object.freeze<GitVcsDriver.GitStatusDetails>({ isRepo: false, hasOriginRemote: false, isDefaultBranch: false, branch: null, upstreamRef: null, hasWorkingTreeChanges: false, workingTree: { files: [], insertions: 0, deletions: 0 }, hasUpstream: false, aheadCount: 0, behindCount: 0, aheadOfDefaultCount: 0,});const NON_REPOSITORY_REMOTE_STATUS_DETAILS = Object.freeze<GitVcsDriver.GitRemoteStatusDetails>({ isRepo: false, defaultBranch: null, isDefaultBranch: false, branch: null, upstreamRef: null, hasUpstream: false, aheadCount: 0, behindCount: 0, aheadOfDefaultCount: 0,}); type TraceTailState = { processedChars: number; remainder: string;}; class StatusRemoteRefreshCacheKey extends Data.Class<{ gitCommonDir: string; remoteName: string;}> {} function statusUpstreamRefreshFailureCooldown(consecutiveFailures: number): Duration.Duration { const exponent = Math.max(0, consecutiveFailures - 1); const cooldownMs = Duration.toMillis(STATUS_UPSTREAM_REFRESH_FAILURE_BASE_COOLDOWN) * Math.pow(2, exponent); return Duration.min(Duration.millis(cooldownMs), STATUS_UPSTREAM_REFRESH_FAILURE_MAX_COOLDOWN);} class GitRefsSnapshotCacheKey extends Data.Class<{ gitCommonDir: string; epoch: number;}> {} class GitRefsRefreshCacheKey extends Data.Class<{ gitCommonDir: string; generation: number;}> {} interface GitRepositoryPaths { readonly gitCommonDir: string; readonly worktreeRoot: string | null; readonly currentBranch: string | null;} interface GitRefsSnapshot { readonly localBranches: ReadonlyArray<VcsRef>; readonly remoteBranches: ReadonlyArray<VcsRef>; readonly hasPrimaryRemote: boolean;} interface ExecuteGitOptions { stdin?: string | undefined; timeoutMs?: number | null | undefined; allowNonZeroExit?: boolean | undefined; fallbackErrorDetail?: string | undefined; env?: NodeJS.ProcessEnv | undefined; maxOutputBytes?: number | undefined; appendTruncationMarker?: boolean | undefined; progress?: GitVcsDriver.ExecuteGitProgress | undefined;} function parseBranchAb(value: string): { ahead: number; behind: number } { const match = value.match(/^\+(\d+)\s+-(\d+)$/); if (!match) return { ahead: 0, behind: 0 }; return { ahead: Number(match[1] ?? "0"), behind: Number(match[2] ?? "0"), };} function parseNumstatEntries( stdout: string,): Array<{ path: string; insertions: number; deletions: number }> { const entries: Array<{ path: string; insertions: number; deletions: number }> = []; for (const line of stdout.split(/\r?\n/g)) { if (line.trim().length === 0) continue; const [addedRaw, deletedRaw, ...pathParts] = line.split("\t"); const rawPath = pathParts.length > 1 ? (pathParts.at(-1) ?? "").trim() : pathParts.join("\t").trim(); if (rawPath.length === 0) continue; const added = Number.parseInt(addedRaw ?? "0", 10); const deleted = Number.parseInt(deletedRaw ?? "0", 10); const renameArrowIndex = rawPath.indexOf(" => "); const normalizedPath = renameArrowIndex >= 0 ? rawPath.slice(renameArrowIndex + " => ".length).trim() : rawPath; entries.push({ path: normalizedPath.length > 0 ? normalizedPath : rawPath, insertions: Number.isFinite(added) ? added : 0, deletions: Number.isFinite(deleted) ? deleted : 0, }); } return entries;} function parsePorcelainPath(line: string): string | null { if (line.startsWith("? ") || line.startsWith("! ")) { const simple = line.slice(2).trim(); return simple.length > 0 ? simple : null; } if (!(line.startsWith("1 ") || line.startsWith("2 ") || line.startsWith("u "))) { return null; } const tabIndex = line.indexOf("\t"); if (tabIndex >= 0) { const fromTab = line.slice(tabIndex + 1); const [filePath] = fromTab.split("\t"); return filePath?.trim().length ? filePath.trim() : null; } const parts = line.trim().split(/\s+/g); const filePath = parts.at(-1) ?? ""; return filePath.length > 0 ? filePath : null;} function filterBranchesForListQuery( refs: ReadonlyArray<VcsRef>, query?: string,): ReadonlyArray<VcsRef> { if (!query) { return refs; } const normalizedQuery = query.toLowerCase(); return refs.filter((refName) => refName.name.toLowerCase().includes(normalizedQuery));} function paginateBranches(input: { refs: ReadonlyArray<VcsRef>; cursor?: number | undefined; limit?: number | undefined;}): { refs: ReadonlyArray<VcsRef>; nextCursor: number | null; totalCount: number;} { const cursor = input.cursor ?? 0; const limit = input.limit ?? GIT_LIST_BRANCHES_DEFAULT_LIMIT; const totalCount = input.refs.length; const refs = input.refs.slice(cursor, cursor + limit); const nextCursor = cursor + refs.length < totalCount ? cursor + refs.length : null; return { refs, nextCursor, totalCount, };} function parseWorktreeBranchPaths(stdout: string): ReadonlyMap<string, string> { const worktreePaths = new Map<string, string>(); let currentPath: string | null = null; let currentBranch: string | null = null; let currentPrunable = false; const flush = () => { if (currentPath !== null && currentBranch !== null && !currentPrunable) { worktreePaths.set(currentBranch, currentPath); } currentPath = null; currentBranch = null; currentPrunable = false; }; for (const field of stdout.split("\0")) { if (field === "") { flush(); } else if (field.startsWith("worktree ")) { currentPath = field.slice("worktree ".length); } else if (field.startsWith("branch refs/heads/")) { currentBranch = field.slice("branch refs/heads/".length); } else if (field === "prunable" || field.startsWith("prunable ")) { currentPrunable = true; } } flush(); return worktreePaths;} function splitNullSeparatedPaths(input: string, truncated: boolean): string[] { const parts = input.split("\0"); if (parts.length === 0) return []; if (truncated && parts[parts.length - 1]?.length) { parts.pop(); } return parts.filter((value) => value.length > 0);} export function splitNullSeparatedGitStdoutPaths( result: Pick<GitVcsDriver.ExecuteGitResult, "stdout" | "stdoutTruncated">,): string[] { return splitNullSeparatedPaths(result.stdout, result.stdoutTruncated);} function sanitizeRemoteName(value: string): string { const sanitized = value .trim() .replace(/[^A-Za-z0-9._-]+/g, "-") .replace(/^-+|-+$/g, ""); return sanitized.length > 0 ? sanitized : "fork";} function parseRemoteFetchUrls(stdout: string): Map<string, string> { const remotes = new Map<string, string>(); for (const line of stdout.split("\n")) { const trimmed = line.trim(); if (trimmed.length === 0) continue; const match = /^(\S+)\s+(\S+)\s+\((fetch|push)\)$/.exec(trimmed); if (!match) continue; const [, remoteName = "", remoteUrl = "", direction = ""] = match; if (direction !== "fetch" || remoteName.length === 0 || remoteUrl.length === 0) { continue; } remotes.set(remoteName, remoteUrl); } return remotes;} function parseUpstreamRefWithRemoteNames( upstreamRef: string, remoteNames: ReadonlyArray<string>,): { upstreamRef: string; remoteName: string; branchName: string } | null { const parsed = parseRemoteRefWithRemoteNames(upstreamRef, remoteNames); if (!parsed) { return null; } return { upstreamRef, remoteName: parsed.remoteName, branchName: parsed.branchName, };} function parseUpstreamRefByFirstSeparator( upstreamRef: string,): { upstreamRef: string; remoteName: string; branchName: string } | null { const separatorIndex = upstreamRef.indexOf("/"); if (separatorIndex <= 0 || separatorIndex === upstreamRef.length - 1) { return null; } const remoteName = upstreamRef.slice(0, separatorIndex).trim(); const branchName = upstreamRef.slice(separatorIndex + 1).trim(); if (remoteName.length === 0 || branchName.length === 0) { return null; } return { upstreamRef, remoteName, branchName, };} function parseTrackingBranchByUpstreamRef(stdout: string, upstreamRef: string): string | null { for (const line of stdout.split("\n")) { const trimmedLine = line.trim(); if (trimmedLine.length === 0) { continue; } const [branchNameRaw, upstreamBranchRaw = ""] = trimmedLine.split("\t"); const branchName = branchNameRaw?.trim() ?? ""; const candidateUpstreamRef = upstreamBranchRaw.trim(); if (branchName.length === 0 || candidateUpstreamRef.length === 0) { continue; } if (candidateUpstreamRef === upstreamRef) { return branchName; } } return null;} function deriveLocalBranchNameFromRemoteRef(branchName: string): string | null { const separatorIndex = branchName.indexOf("/"); if (separatorIndex <= 0 || separatorIndex === branchName.length - 1) { return null; } const localBranch = branchName.slice(separatorIndex + 1).trim(); return localBranch.length > 0 ? localBranch : null;} function gitCommandContext( input: Pick<GitVcsDriver.ExecuteGitInput, "operation" | "cwd" | "args">,) { return { operation: input.operation, command: "git", cwd: input.cwd, argumentCount: input.args.length, } as const;} function parseDefaultBranchFromRemoteHeadRef(value: string, remoteName: string): string | null { const trimmed = value.trim(); const prefix = `refs/remotes/${remoteName}/`; if (!trimmed.startsWith(prefix)) { return null; } const refName = trimmed.slice(prefix.length).trim(); return refName.length > 0 ? refName : null;} function isMissingGitCwdError(error: GitCommandError): boolean { if (!(error.cause instanceof PlatformError.PlatformError)) { return false; } const reason = error.cause.reason; if (reason._tag === "NotFound") { return reason.pathOrDescriptor === error.cwd; } return ( reason._tag === "BadResource" && reason.pathOrDescriptor === error.cwd && typeof reason.cause === "object" && reason.cause !== null && "code" in reason.cause && reason.cause.code === "ENOTDIR" );} function isNonRepositoryGitStderr(stderr: string): boolean { return stderr.toLowerCase().includes("not a git repository");}function isUnbornHeadStderr(stderr: string): boolean { const normalized = stderr.toLowerCase(); return ( normalized.includes("bad revision 'head'") || (normalized.includes("unknown revision") && normalized.includes("path not in the working tree")) );} // Matches `git worktree remove` on a path git no longer tracks: "is not a// working tree" when the registration is gone, "cannot remove working tree"// when older gits fail validation on a registered-but-deleted directory.function isMissingWorktreeStderr(stderr: string): boolean { const normalized = stderr.toLowerCase(); return ( normalized.includes("is not a working tree") || normalized.includes("cannot remove working tree") );} // Fetch stderr can contain remote credentials. Only fixed diagnoses may enter// persisted errors; unrecognized output keeps the generic failure message.function fetchFailureDetail(stderr: string): string | undefined { const normalized = stderr.toLowerCase(); if ( normalized.includes("authentication failed") || normalized.includes("permission denied (publickey") || normalized.includes("could not read username") || normalized.includes("could not read password") || normalized.includes("terminal prompts disabled") ) { return "Git could not authenticate with the remote. Check Git credentials or SSH access on the server, then retry."; } if ( normalized.includes("could not resolve host") || normalized.includes("could not resolve hostname") || normalized.includes("failed to connect") || normalized.includes("connection timed out") || normalized.includes("connection refused") || normalized.includes("network is unreachable") ) { return "Git could not reach the remote. Check the server's network connection and remote host, then retry."; } if ( normalized.includes("repository not found") || normalized.includes("does not appear to be a git repository") ) { return "Git could not access the remote repository. Check the remote URL and repository permissions on the server."; } if ( normalized.includes("cannot lock ref") || (normalized.includes("unable to create") && normalized.includes(".lock")) ) { 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."; } return undefined;} interface Trace2Monitor { readonly env: NodeJS.ProcessEnv; readonly flush: Effect.Effect<void, never>;} const nowUnixNano = DateTime.now.pipe( Effect.map((now) => BigInt(DateTime.toEpochMillis(now)) * 1_000_000n),); const addCurrentSpanEvent = (name: string, attributes: Record<string, unknown>) => Effect.gen(function* () { const span = yield* Effect.currentSpan;Lines 1–500 of 3419. Load more
import * as Arr from "effect/Array"; import * as Cache from "effect/Cache"; import * as Data from "effect/Data"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Encoding from "effect/Encoding"; import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; import * as Ref from "effect/Ref"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { GitCommandError, type ReviewDiffFileContentsInput, type ReviewDiffPreviewInput, type ReviewDiffPreviewSource, type VcsRef, } from "@t3tools/contracts"; import { dedupeRemoteBranchesWithLocalMatches, normalizeGitRemoteUrl } from "@t3tools/shared/git"; import { compactTraceAttributes } from "@t3tools/shared/observability"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; import { gitCommandDuration, gitCommandsTotal, withMetrics } from "../observability/Metrics.ts"; import * as GitVcsDriver from "./GitVcsDriver.ts"; import { parseRemoteNames, parseRemoteNamesInGitOrder, parseRemoteRefWithRemoteNames, } from "../git/remoteRefs.ts"; import { ServerConfig } from "../config.ts"; const DEFAULT_TIMEOUT_MS = 30_000; // `git worktree add` checks out the full tree, so on large repositories it can // take well beyond the default 30s (e.g. a 375k-file repo takes ~40s on an idle // machine). Give it generous headroom while still bounding a genuinely hung git. const WORKTREE_ADD_TIMEOUT_MS = 300_000; const WORKTREE_REMOVE_TIMEOUT_MS = Duration.toMillis(Duration.minutes(5)); const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; const OUTPUT_TRUNCATED_MARKER = "\n\n[truncated]"; const PREPARED_COMMIT_PATCH_MAX_OUTPUT_BYTES = 49_000; const RANGE_COMMIT_SUMMARY_MAX_OUTPUT_BYTES = 19_000; const RANGE_DIFF_SUMMARY_MAX_OUTPUT_BYTES = 19_000; const RANGE_DIFF_PATCH_MAX_OUTPUT_BYTES = 59_000; const REVIEW_DIFF_PATCH_MAX_OUTPUT_BYTES = 120_000; const REVIEW_UNTRACKED_DIFF_MAX_OUTPUT_BYTES = 80_000; const REVIEW_DIFF_FILE_MAX_OUTPUT_BYTES = 1024 * 1024; // Patches the clients render are parsed against git's default a/ and b/ path // prefixes. A repository or global diff.noprefix or diff.mnemonicPrefix would // otherwise leak into the patch and leave every parsed file unnamed. export const PATCH_RENDER_PREFIX_ARGS = ["--src-prefix=a/", "--dst-prefix=b/"] as const; const WORKSPACE_FILES_MAX_OUTPUT_BYTES = 120_000; const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15); const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5); const STATUS_UPSTREAM_REFRESH_FAILURE_BASE_COOLDOWN = Duration.seconds(30); const STATUS_UPSTREAM_REFRESH_FAILURE_MAX_COOLDOWN = Duration.minutes(15); const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048; const REPOSITORY_PATHS_CACHE_CAPACITY = 2_048; const REPOSITORY_PATHS_CACHE_TTL = Duration.minutes(10); const REPOSITORY_PATHS_REFRESH_COALESCE_TTL = Duration.seconds(5); const NON_REPOSITORY_PATHS_CACHE_TTL = Duration.seconds(1); const LIST_REFS_SNAPSHOT_CACHE_CAPACITY = 64; const LIST_REFS_SNAPSHOT_CACHE_TTL = Duration.minutes(2); const LIST_REFS_REFRESH_COALESCE_TTL = Duration.seconds(5); const LIST_REFS_REFRESH_FAILURE_COOLDOWN = Duration.seconds(30); const STATUS_DEFAULT_BRANCH_CACHE_TTL = Duration.minutes(5); const STATUS_ORIGIN_EXISTS_CACHE_TTL = Duration.minutes(5); const STATUS_UPSTREAM_REFRESH_ENV = Object.freeze({ GCM_INTERACTIVE: "never", GIT_ASKPASS: "", GIT_TERMINAL_PROMPT: "0", SSH_ASKPASS: "", SSH_ASKPASS_REQUIRE: "never", } satisfies NodeJS.ProcessEnv); const DEFAULT_BASE_BRANCH_CANDIDATES = ["main", "master"] as const; const GIT_LIST_BRANCHES_DEFAULT_LIMIT = 100; const NON_REPOSITORY_STATUS_DETAILS = Object.freeze<GitVcsDriver.GitStatusDetails>({ isRepo: false, hasOriginRemote: false, isDefaultBranch: false, branch: null, upstreamRef: null, hasWorkingTreeChanges: false, workingTree: { files: [], insertions: 0, deletions: 0 }, hasUpstream: false, aheadCount: 0, behindCount: 0, aheadOfDefaultCount: 0, }); const NON_REPOSITORY_REMOTE_STATUS_DETAILS = Object.freeze<GitVcsDriver.GitRemoteStatusDetails>({ isRepo: false, defaultBranch: null, isDefaultBranch: false, branch: null, upstreamRef: null, hasUpstream: false, aheadCount: 0, behindCount: 0, aheadOfDefaultCount: 0, }); type TraceTailState = { processedChars: number; remainder: string; }; class StatusRemoteRefreshCacheKey extends Data.Class<{ gitCommonDir: string; remoteName: string; }> {} function statusUpstreamRefreshFailureCooldown(consecutiveFailures: number): Duration.Duration { const exponent = Math.max(0, consecutiveFailures - 1); const cooldownMs = Duration.toMillis(STATUS_UPSTREAM_REFRESH_FAILURE_BASE_COOLDOWN) * Math.pow(2, exponent); return Duration.min(Duration.millis(cooldownMs), STATUS_UPSTREAM_REFRESH_FAILURE_MAX_COOLDOWN); } class GitRefsSnapshotCacheKey extends Data.Class<{ gitCommonDir: string; epoch: number; }> {} class GitRefsRefreshCacheKey extends Data.Class<{ gitCommonDir: string; generation: number; }> {} interface GitRepositoryPaths { readonly gitCommonDir: string; readonly worktreeRoot: string | null; readonly currentBranch: string | null; } interface GitRefsSnapshot { readonly localBranches: ReadonlyArray<VcsRef>; readonly remoteBranches: ReadonlyArray<VcsRef>; readonly hasPrimaryRemote: boolean; } interface ExecuteGitOptions { stdin?: string | undefined; timeoutMs?: number | null | undefined; allowNonZeroExit?: boolean | undefined; fallbackErrorDetail?: string | undefined; env?: NodeJS.ProcessEnv | undefined; maxOutputBytes?: number | undefined; appendTruncationMarker?: boolean | undefined; progress?: GitVcsDriver.ExecuteGitProgress | undefined; } function parseBranchAb(value: string): { ahead: number; behind: number } { const match = value.match(/^\+(\d+)\s+-(\d+)$/); if (!match) return { ahead: 0, behind: 0 }; return { ahead: Number(match[1] ?? "0"), behind: Number(match[2] ?? "0"), }; } function parseNumstatEntries( stdout: string, ): Array<{ path: string; insertions: number; deletions: number }> { const entries: Array<{ path: string; insertions: number; deletions: number }> = []; for (const line of stdout.split(/\r?\n/g)) { if (line.trim().length === 0) continue; const [addedRaw, deletedRaw, ...pathParts] = line.split("\t"); const rawPath = pathParts.length > 1 ? (pathParts.at(-1) ?? "").trim() : pathParts.join("\t").trim(); if (rawPath.length === 0) continue; const added = Number.parseInt(addedRaw ?? "0", 10); const deleted = Number.parseInt(deletedRaw ?? "0", 10); const renameArrowIndex = rawPath.indexOf(" => "); const normalizedPath = renameArrowIndex >= 0 ? rawPath.slice(renameArrowIndex + " => ".length).trim() : rawPath; entries.push({ path: normalizedPath.length > 0 ? normalizedPath : rawPath, insertions: Number.isFinite(added) ? added : 0, deletions: Number.isFinite(deleted) ? deleted : 0, }); } return entries; } function parsePorcelainPath(line: string): string | null { if (line.startsWith("? ") || line.startsWith("! ")) { const simple = line.slice(2).trim(); return simple.length > 0 ? simple : null; } if (!(line.startsWith("1 ") || line.startsWith("2 ") || line.startsWith("u "))) { return null; } const tabIndex = line.indexOf("\t"); if (tabIndex >= 0) { const fromTab = line.slice(tabIndex + 1); const [filePath] = fromTab.split("\t"); return filePath?.trim().length ? filePath.trim() : null; } const parts = line.trim().split(/\s+/g); const filePath = parts.at(-1) ?? ""; return filePath.length > 0 ? filePath : null; } function filterBranchesForListQuery( refs: ReadonlyArray<VcsRef>, query?: string, ): ReadonlyArray<VcsRef> { if (!query) { return refs; } const normalizedQuery = query.toLowerCase(); return refs.filter((refName) => refName.name.toLowerCase().includes(normalizedQuery)); } function paginateBranches(input: { refs: ReadonlyArray<VcsRef>; cursor?: number | undefined; limit?: number | undefined; }): { refs: ReadonlyArray<VcsRef>; nextCursor: number | null; totalCount: number; } { const cursor = input.cursor ?? 0; const limit = input.limit ?? GIT_LIST_BRANCHES_DEFAULT_LIMIT; const totalCount = input.refs.length; const refs = input.refs.slice(cursor, cursor + limit); const nextCursor = cursor + refs.length < totalCount ? cursor + refs.length : null; return { refs, nextCursor, totalCount, }; } function parseWorktreeBranchPaths(stdout: string): ReadonlyMap<string, string> { const worktreePaths = new Map<string, string>(); let currentPath: string | null = null; let currentBranch: string | null = null; let currentPrunable = false; const flush = () => { if (currentPath !== null && currentBranch !== null && !currentPrunable) { worktreePaths.set(currentBranch, currentPath); } currentPath = null; currentBranch = null; currentPrunable = false; }; for (const field of stdout.split("\0")) { if (field === "") { flush(); } else if (field.startsWith("worktree ")) { currentPath = field.slice("worktree ".length); } else if (field.startsWith("branch refs/heads/")) { currentBranch = field.slice("branch refs/heads/".length); } else if (field === "prunable" || field.startsWith("prunable ")) { currentPrunable = true; } } flush(); return worktreePaths; } function splitNullSeparatedPaths(input: string, truncated: boolean): string[] { const parts = input.split("\0"); if (parts.length === 0) return []; if (truncated && parts[parts.length - 1]?.length) { parts.pop(); } return parts.filter((value) => value.length > 0); } export function splitNullSeparatedGitStdoutPaths( result: Pick<GitVcsDriver.ExecuteGitResult, "stdout" | "stdoutTruncated">, ): string[] { return splitNullSeparatedPaths(result.stdout, result.stdoutTruncated); } function sanitizeRemoteName(value: string): string { const sanitized = value .trim() .replace(/[^A-Za-z0-9._-]+/g, "-") .replace(/^-+|-+$/g, ""); return sanitized.length > 0 ? sanitized : "fork"; } function parseRemoteFetchUrls(stdout: string): Map<string, string> { const remotes = new Map<string, string>(); for (const line of stdout.split("\n")) { const trimmed = line.trim(); if (trimmed.length === 0) continue; const match = /^(\S+)\s+(\S+)\s+\((fetch|push)\)$/.exec(trimmed); if (!match) continue; const [, remoteName = "", remoteUrl = "", direction = ""] = match; if (direction !== "fetch" || remoteName.length === 0 || remoteUrl.length === 0) { continue; } remotes.set(remoteName, remoteUrl); } return remotes; } function parseUpstreamRefWithRemoteNames( upstreamRef: string, remoteNames: ReadonlyArray<string>, ): { upstreamRef: string; remoteName: string; branchName: string } | null { const parsed = parseRemoteRefWithRemoteNames(upstreamRef, remoteNames); if (!parsed) { return null; } return { upstreamRef, remoteName: parsed.remoteName, branchName: parsed.branchName, }; } function parseUpstreamRefByFirstSeparator( upstreamRef: string, ): { upstreamRef: string; remoteName: string; branchName: string } | null { const separatorIndex = upstreamRef.indexOf("/"); if (separatorIndex <= 0 || separatorIndex === upstreamRef.length - 1) { return null; } const remoteName = upstreamRef.slice(0, separatorIndex).trim(); const branchName = upstreamRef.slice(separatorIndex + 1).trim(); if (remoteName.length === 0 || branchName.length === 0) { return null; } return { upstreamRef, remoteName, branchName, }; } function parseTrackingBranchByUpstreamRef(stdout: string, upstreamRef: string): string | null { for (const line of stdout.split("\n")) { const trimmedLine = line.trim(); if (trimmedLine.length === 0) { continue; } const [branchNameRaw, upstreamBranchRaw = ""] = trimmedLine.split("\t"); const branchName = branchNameRaw?.trim() ?? ""; const candidateUpstreamRef = upstreamBranchRaw.trim(); if (branchName.length === 0 || candidateUpstreamRef.length === 0) { continue; } if (candidateUpstreamRef === upstreamRef) { return branchName; } } return null; } function deriveLocalBranchNameFromRemoteRef(branchName: string): string | null { const separatorIndex = branchName.indexOf("/"); if (separatorIndex <= 0 || separatorIndex === branchName.length - 1) { return null; } const localBranch = branchName.slice(separatorIndex + 1).trim(); return localBranch.length > 0 ? localBranch : null; } function gitCommandContext( input: Pick<GitVcsDriver.ExecuteGitInput, "operation" | "cwd" | "args">, ) { return { operation: input.operation, command: "git", cwd: input.cwd, argumentCount: input.args.length, } as const; } function parseDefaultBranchFromRemoteHeadRef(value: string, remoteName: string): string | null { const trimmed = value.trim(); const prefix = `refs/remotes/${remoteName}/`; if (!trimmed.startsWith(prefix)) { return null; } const refName = trimmed.slice(prefix.length).trim(); return refName.length > 0 ? refName : null; } function isMissingGitCwdError(error: GitCommandError): boolean { if (!(error.cause instanceof PlatformError.PlatformError)) { return false; } const reason = error.cause.reason; if (reason._tag === "NotFound") { return reason.pathOrDescriptor === error.cwd; } return ( reason._tag === "BadResource" && reason.pathOrDescriptor === error.cwd && typeof reason.cause === "object" && reason.cause !== null && "code" in reason.cause && reason.cause.code === "ENOTDIR" ); } function isNonRepositoryGitStderr(stderr: string): boolean { return stderr.toLowerCase().includes("not a git repository"); } function isUnbornHeadStderr(stderr: string): boolean { const normalized = stderr.toLowerCase(); return ( normalized.includes("bad revision 'head'") || (normalized.includes("unknown revision") && normalized.includes("path not in the working tree")) ); } // Matches `git worktree remove` on a path git no longer tracks: "is not a // working tree" when the registration is gone, "cannot remove working tree" // when older gits fail validation on a registered-but-deleted directory. function isMissingWorktreeStderr(stderr: string): boolean { const normalized = stderr.toLowerCase(); return ( normalized.includes("is not a working tree") || normalized.includes("cannot remove working tree") ); } // Fetch stderr can contain remote credentials. Only fixed diagnoses may enter // persisted errors; unrecognized output keeps the generic failure message. function fetchFailureDetail(stderr: string): string | undefined { const normalized = stderr.toLowerCase(); if ( normalized.includes("authentication failed") || normalized.includes("permission denied (publickey") || normalized.includes("could not read username") || normalized.includes("could not read password") || normalized.includes("terminal prompts disabled") ) { return "Git could not authenticate with the remote. Check Git credentials or SSH access on the server, then retry."; } if ( normalized.includes("could not resolve host") || normalized.includes("could not resolve hostname") || normalized.includes("failed to connect") || normalized.includes("connection timed out") || normalized.includes("connection refused") || normalized.includes("network is unreachable") ) { return "Git could not reach the remote. Check the server's network connection and remote host, then retry."; } if ( normalized.includes("repository not found") || normalized.includes("does not appear to be a git repository") ) { return "Git could not access the remote repository. Check the remote URL and repository permissions on the server."; } if ( normalized.includes("cannot lock ref") || (normalized.includes("unable to create") && normalized.includes(".lock")) ) { 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."; } return undefined; } interface Trace2Monitor { readonly env: NodeJS.ProcessEnv; readonly flush: Effect.Effect<void, never>; } const nowUnixNano = DateTime.now.pipe( Effect.map((now) => BigInt(DateTime.toEpochMillis(now)) * 1_000_000n), ); const addCurrentSpanEvent = (name: string, attributes: Record<string, unknown>) => Effect.gen(function* () { const span = yield* Effect.currentSpan;