import * as NodeBuffer from "node:buffer"; import * as Context from "effect/Context"; import * as SecureRandom from "effect/crypto/SecureRandom"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import { ChildProcessSpawner } from "effect/process"; import { GitCommandError, VcsProcessExitError, VcsUnsupportedOperationError, type VcsSwitchRefInput, type VcsSwitchRefResult, type VcsCreateRefInput, type VcsCreateRefResult, type VcsCreateWorktreeInput, type VcsCreateWorktreeResult, type ReviewBranchCommitsInput, type ReviewBranchCommitsResult, type ReviewDiffPreviewInput, type ReviewDiffPreviewResult, type ReviewDiffFileContentsInput, type ReviewDiffFileContentsResult, type VcsInitInput, type VcsListRefsInput, type VcsListRefsResult, type VcsPullResult, type VcsRemoveWorktreeInput, type VcsStatusInput, type VcsStatusResult, type WorktreeSubmodules, } from "@t3tools/contracts"; import { isMissingExecutable } from "@t3tools/source-control-core/server/discovery"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { makeGitVcsDriverCore, PATCH_RENDER_PREFIX_ARGS, splitNullSeparatedGitStdoutPaths, } from "./GitVcsDriverCore.ts"; import * as CheckoutCloner from "./CheckoutCloner.ts"; import * as VcsDriver from "./VcsDriver.ts"; import * as VcsProcess from "./VcsProcess.ts"; export interface ExecuteGitInput { readonly operation: string; readonly cwd: string; readonly args: ReadonlyArray; readonly stdin?: string; readonly env?: NodeJS.ProcessEnv; readonly allowNonZeroExit?: boolean; readonly timeoutMs?: number | null; readonly maxOutputBytes?: number; readonly appendTruncationMarker?: boolean; /** With appendTruncationMarker, line callbacks continue beyond the buffer cap for progress-only consumers. */ readonly keepLineCallbacksAfterTruncation?: boolean; readonly progress?: ExecuteGitProgress; /** Writes .git/config, which every worktree shares and Git locks without waiting. */ readonly writesSharedConfig?: boolean; } export interface ExecuteGitResult { readonly exitCode: ChildProcessSpawner.ExitCode; readonly stdout: string; readonly stderr: string; readonly stdoutTruncated: boolean; readonly stderrTruncated: boolean; } export interface GitStatusDetails { isRepo: boolean; sourceControlProvider?: VcsStatusResult["sourceControlProvider"]; hasOriginRemote: boolean; isDefaultBranch: boolean; branch: string | null; upstreamRef: string | null; hasWorkingTreeChanges: boolean; workingTree: VcsStatusResult["workingTree"]; branchChanges?: VcsStatusResult["branchChanges"]; hasUpstream: boolean; aheadCount: number; behindCount: number; aheadOfDefaultCount: number; } export interface GitLocalStatusOptions { readonly includeDivergence?: boolean; readonly includeBranchChanges?: boolean; } export interface GitRemoteStatusDetails { isRepo: boolean; defaultBranch: string | null; isDefaultBranch: boolean; branch: string | null; upstreamRef: string | null; hasUpstream: boolean; aheadCount: number; behindCount: number; aheadOfDefaultCount: number; } export interface GitPreparedCommitContext { stagedSummary: string; stagedPatch: string; } export interface ExecuteGitProgress { readonly onStdoutLine?: (line: string) => Effect.Effect; readonly onStderrLine?: (line: string) => Effect.Effect; readonly onHookStarted?: (hookName: string) => Effect.Effect; readonly onHookFinished?: (input: { hookName: string; exitCode: number | null; durationMs: number | null; }) => Effect.Effect; } export interface CreateWorktreeProgress { /** Git refuses existing paths; claimed paths belong to this call and can be removed on cancel. */ readonly onWorktreeClaimed?: (path: string) => Effect.Effect; readonly onCheckoutProgress?: (input: { percent: number; completed: number; total: number; }) => Effect.Effect; readonly onSubmodulesStarted?: () => Effect.Effect; readonly onSubmodulesDisabled?: (input: { source: "settings" | "t3.json"; }) => Effect.Effect; readonly onSubmoduleLine?: (line: string) => Effect.Effect; readonly onSubmodulesFinished?: (input: { ok: boolean; detail: string | null; }) => Effect.Effect; } export interface CreateWorktreeOptions { readonly progress?: CreateWorktreeProgress; /** Null or omitted defers to the checkout's t3.json. */ readonly submodules?: WorktreeSubmodules | null; readonly worktreesDirectory?: string; /** Clone the checkout from the macOS worktree checkout cache. */ readonly fastCheckout?: boolean; } export interface GitCommitProgress { readonly onOutputLine?: (input: { stream: "stdout" | "stderr"; text: string; }) => Effect.Effect; readonly onHookStarted?: (hookName: string) => Effect.Effect; readonly onHookFinished?: (input: { hookName: string; exitCode: number | null; durationMs: number | null; }) => Effect.Effect; } export interface GitCommitOptions { readonly timeoutMs?: number; readonly progress?: GitCommitProgress; readonly stage?: { readonly filePaths?: readonly string[] }; } export interface GitDeleteLocalBranchInput { readonly cwd: string; readonly refName: string; readonly force?: boolean; } export interface GitPushResult { status: "pushed" | "skipped_up_to_date"; branch: string; upstreamBranch?: string | undefined; setUpstream?: boolean | undefined; } export interface GitPushTarget { remoteName: string; branchName: string; } export interface GitRangeContext { commitSummary: string; diffSummary: string; diffPatch: string; } export interface GitRenameBranchInput { exactName?: boolean; cwd: string; oldBranch: string; newBranch: string; } export interface GitRenameBranchResult { branch: string; } export interface GitFetchRefInput { cwd: string; /** A ref on the primary remote, such as a host's change request head ref. */ ref: string; } export interface GitResolveCommitInput { cwd: string; revision: string; } export interface GitResolveCommitResult { commitSha: string; } export interface GitRefreshCheckedOutBranchInput { cwd: string; targetCommit: string; /** Pre-fetch upstream commit; resetting away is safe only if HEAD stayed here. */ resetWhenHeadCommit?: string | null | undefined; } export interface GitRefreshCheckedOutBranchResult { headCommit: string; moved: boolean; onTarget: boolean; } export interface GitEnsureRemoteInput { cwd: string; preferredName: string; url: string; } export interface GitFetchRemoteBranchInput { cwd: string; remoteName: string; remoteBranch: string; localBranch: string; } export interface GitFetchRemoteTrackingBranchInput { cwd: string; remoteName: string; remoteBranch: string; } export interface GitFetchRemoteInput { cwd: string; remoteName: string; refName?: string; } export interface GitRemoteExistsInput { cwd: string; remoteName: string; } export interface GitRemoteBranchExistsInput extends GitRemoteExistsInput { refName: string; } export interface GitResolveRemoteTrackingCommitInput { cwd: string; refName: string; fallbackRemoteName: string; } export interface GitResolveRemoteTrackingCommitResult { commitSha: string; remoteRefName: string; } export interface GitFetchWorktreeBaseCommitInput { cwd: string; refName: string; } export interface GitCreateBranchInput { readonly cwd: string; readonly branch: string; readonly startPoint: string; } export interface GitWriteConfigValueInput { readonly cwd: string; readonly key: string; readonly value: string; } export interface GitWorktreeBaseRemote { remoteName: string; branchName: string; } export interface GitSetBranchUpstreamInput { cwd: string; branch: string; remoteName: string; remoteBranch: string; } export interface GitRemoteStatusOptions { readonly refreshUpstream?: boolean; } /** Set by background discovery so its Git commands run in their own small lane, not the user's. */ export const BackgroundGitWork = Context.Reference("t3/vcs/BackgroundGitWork", { defaultValue: () => false, }); export class GitVcsDriver extends Context.Service< GitVcsDriver, { readonly execute: (input: ExecuteGitInput) => Effect.Effect; readonly status: (input: VcsStatusInput) => Effect.Effect; readonly statusDetails: (cwd: string) => Effect.Effect; readonly statusDetailsLocal: ( cwd: string, options?: GitLocalStatusOptions, ) => Effect.Effect; readonly statusDetailsRemote: ( cwd: string, options?: GitRemoteStatusOptions, ) => Effect.Effect; readonly prepareCommitContext: ( cwd: string, filePaths?: readonly string[], options?: { readonly includePatch?: boolean }, ) => Effect.Effect; readonly commit: ( cwd: string, subject: string, body: string, options?: GitCommitOptions, ) => Effect.Effect<{ commitSha: string }, GitCommandError>; readonly pushCurrentBranch: ( cwd: string, fallbackBranch: string | null, options?: { readonly remoteName?: string | null }, ) => Effect.Effect; /** Where `pushCurrentBranch` would push the checked-out branch; null with no remote or when Git picks. */ readonly resolvePushTarget: ( cwd: string, current: { readonly branch: string; readonly hasUpstream: boolean }, ) => Effect.Effect; readonly readRangeContext: ( cwd: string, baseRef: string, ) => Effect.Effect; readonly getReviewBranchCommits: ( input: ReviewBranchCommitsInput, ) => Effect.Effect; readonly getReviewDiffPreview: ( input: ReviewDiffPreviewInput, ) => Effect.Effect; readonly getReviewDiffFileContents: ( input: ReviewDiffFileContentsInput, ) => Effect.Effect; readonly readConfigValue: ( cwd: string, key: string, ) => Effect.Effect; /** * The branch, named as on the host, that T3 recorded `branch` was cut from. Null when none * was recorded. */ readonly readBranchBase: ( cwd: string, branch: string, ) => Effect.Effect; readonly resolveBranchName: ( cwd: string, refName: string, ) => Effect.Effect; readonly listRefs: ( input: VcsListRefsInput, ) => Effect.Effect; readonly pullCurrentBranch: (cwd: string) => Effect.Effect; readonly createWorktree: ( input: VcsCreateWorktreeInput, options?: CreateWorktreeOptions, ) => Effect.Effect; /** Deletes the macOS worktree checkout cache in the background. */ readonly clearWorktreeCheckoutCache: Effect.Effect; /** Force-updates local `branch` to the primary remote's `ref`. */ readonly fetchRefIntoBranch: ( input: GitFetchRefInput & { readonly branch: string }, ) => Effect.Effect; readonly fetchRefCommit: ( input: GitFetchRefInput, ) => Effect.Effect; readonly resolveCommit: ( input: GitResolveCommitInput, ) => Effect.Effect; readonly refreshCheckedOutBranch: ( input: GitRefreshCheckedOutBranchInput, ) => Effect.Effect; readonly ensureRemote: (input: GitEnsureRemoteInput) => Effect.Effect; readonly resolvePrimaryRemoteName: (cwd: string) => Effect.Effect; readonly resolveDefaultBranchName: ( cwd: string, remoteName: string, ) => Effect.Effect; readonly fetchRemote: (input: GitFetchRemoteInput) => Effect.Effect; readonly remoteExists: (input: GitRemoteExistsInput) => Effect.Effect; readonly remoteBranchExists: ( input: GitRemoteBranchExistsInput, ) => Effect.Effect; readonly resolveRemoteTrackingCommit: ( input: GitResolveRemoteTrackingCommitInput, ) => Effect.Effect; readonly resolveWorktreeBaseRemote: ( input: GitFetchWorktreeBaseCommitInput, ) => Effect.Effect; /** * Fetches the configured upstream, explicit remote ref, or origin; null uses the local base. * A caller that already ran `resolveWorktreeBaseRemote` passes its result as `remote`. */ readonly fetchWorktreeBaseCommit: ( input: GitFetchWorktreeBaseCommitInput & { readonly remote?: GitWorktreeBaseRemote }, ) => Effect.Effect; readonly fetchRemoteBranch: ( input: GitFetchRemoteBranchInput, ) => Effect.Effect; readonly fetchRemoteTrackingBranch: ( input: GitFetchRemoteTrackingBranchInput, ) => Effect.Effect; readonly setBranchUpstream: ( input: GitSetBranchUpstreamInput, ) => Effect.Effect; /** Tracks the start point like `git branch` does. */ readonly createBranch: (input: GitCreateBranchInput) => Effect.Effect; readonly writeConfigValue: ( input: GitWriteConfigValueInput, ) => Effect.Effect; readonly removeWorktree: ( input: VcsRemoveWorktreeInput, ) => Effect.Effect; readonly pruneWorktrees: (input: { readonly cwd: string; }) => Effect.Effect; readonly listWorktreePaths: (cwd: string) => Effect.Effect; readonly deleteLocalBranch: ( input: GitDeleteLocalBranchInput, ) => Effect.Effect; readonly renameBranch: ( input: GitRenameBranchInput, ) => Effect.Effect; readonly createRef: ( input: VcsCreateRefInput, ) => Effect.Effect; readonly switchRef: ( input: VcsSwitchRefInput, ) => Effect.Effect; readonly initRepo: (input: VcsInitInput) => Effect.Effect; readonly listLocalBranchNames: (cwd: string) => Effect.Effect; } >()("t3/vcs/GitVcsDriver") {} const WORKSPACE_FILES_MAX_OUTPUT_BYTES = 16 * 1024 * 1024; const FLAGGED_INDEX_ENTRIES_MAX_BYTES = 1024 * 1024; const CHECKPOINT_RECOVERY_MAX_CANDIDATES = 64; const CHECKPOINT_RECOVERY_TIMEOUT = "5 seconds"; const GIT_CHECK_IGNORE_MAX_STDIN_BYTES = 256 * 1024; const WORKSPACE_GIT_HARDENED_CONFIG_ARGS = [ "-c", "core.fsmonitor=false", "-c", "core.untrackedCache=false", ] as const; const nowFreshness = Effect.fn("GitVcsDriver.nowFreshness")(function* () { const now = yield* DateTime.now; return { source: "live-local" as const, observedAt: now, expiresAt: Option.none(), }; }); function chunkPathsForGitCheckIgnore(relativePaths: ReadonlyArray): string[][] { const chunks: string[][] = []; let chunk: string[] = []; let chunkBytes = 0; for (const relativePath of relativePaths) { const relativePathBytes = Buffer.byteLength(relativePath) + 1; if (chunk.length > 0 && chunkBytes + relativePathBytes > GIT_CHECK_IGNORE_MAX_STDIN_BYTES) { chunks.push(chunk); chunk = []; chunkBytes = 0; } chunk.push(relativePath); chunkBytes += relativePathBytes; if (chunkBytes >= GIT_CHECK_IGNORE_MAX_STDIN_BYTES) { chunks.push(chunk); chunk = []; chunkBytes = 0; } } if (chunk.length > 0) { chunks.push(chunk); } return chunks; } function parseGitRemoteVerboseOutput( output: string, ): Map { const remotes = new Map(); for (const line of output.split("\n")) { const trimmed = line.trim(); if (trimmed.length === 0) { continue; } // Partial clones append the promisor filter, e.g. `(fetch) [blob:none]`. const match = /^(\S+)\s+(\S+)\s+\((fetch|push)\)(?:\s+\[[^\]]+\])?$/.exec(trimmed); if (!match) { continue; } const name = match[1]; const url = match[2]; const direction = match[3]; if (!name || !url || !direction) { continue; } const remote = remotes.get(name) ?? {}; if (direction === "fetch") { remote.url = url; } else { remote.pushUrl = url; } remotes.set(name, remote); } return remotes; } const gitCommand = Effect.fn("GitVcsDriver.gitCommand")( ( process: VcsProcess.VcsProcess["Service"], operation: string, cwd: string, args: ReadonlyArray, options?: { readonly stdin?: string; readonly env?: NodeJS.ProcessEnv; readonly allowNonZeroExit?: boolean; readonly timeoutMs?: number; readonly maxOutputBytes?: number; readonly outputMode?: VcsProcess.VcsProcessInput["outputMode"]; readonly appendTruncationMarker?: boolean; }, ) => process.run({ operation, command: "git", args: ["-C", cwd, ...args], cwd, spawnCwd: globalThis.process.cwd(), ...(options?.stdin !== undefined ? { stdin: options.stdin } : {}), ...(options?.env !== undefined ? { env: options.env } : {}), ...(options?.allowNonZeroExit !== undefined ? { allowNonZeroExit: options.allowNonZeroExit } : {}), ...(options?.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), ...(options?.maxOutputBytes !== undefined ? { maxOutputBytes: options.maxOutputBytes } : {}), ...(options?.outputMode !== undefined ? { outputMode: options.outputMode } : {}), ...(options?.appendTruncationMarker !== undefined ? { appendTruncationMarker: options.appendTruncationMarker } : {}), }), ); export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const vcsProcess = yield* VcsProcess.VcsProcess; const random = yield* SecureRandom.SecureRandom; const hostPlatform = yield* HostProcess.Platform; const isInsideWorkTree: VcsDriver.VcsDriver["Service"]["isInsideWorkTree"] = (cwd) => gitCommand( vcsProcess, "GitVcsDriver.isInsideWorkTree", cwd, ["rev-parse", "--is-inside-work-tree"], { allowNonZeroExit: true, timeoutMs: 5_000, maxOutputBytes: 4_096, }, ).pipe( Effect.map((result) => result.exitCode === 0 && result.stdout.trim() === "true"), Effect.catchIf(isMissingExecutable, () => Effect.succeed(false)), ); const execute: VcsDriver.VcsDriver["Service"]["execute"] = (input) => gitCommand(vcsProcess, input.operation, input.cwd, input.args, { ...(input.stdin !== undefined ? { stdin: input.stdin } : {}), ...(input.env !== undefined ? { env: input.env } : {}), ...(input.allowNonZeroExit !== undefined ? { allowNonZeroExit: input.allowNonZeroExit } : {}), ...(input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {}), ...(input.maxOutputBytes !== undefined ? { maxOutputBytes: input.maxOutputBytes } : {}), ...(input.outputMode !== undefined ? { outputMode: input.outputMode } : {}), ...(input.appendTruncationMarker !== undefined ? { appendTruncationMarker: input.appendTruncationMarker } : {}), }); const detectRepository: VcsDriver.VcsDriver["Service"]["detectRepository"] = Effect.fn( "detectRepository", )(function* (cwd) { // One spawn answers all three; outside a work tree it exits non-zero or prints "false". const result = yield* gitCommand( vcsProcess, "GitVcsDriver.detectRepository", cwd, ["rev-parse", "--is-inside-work-tree", "--show-toplevel", "--git-common-dir"], { allowNonZeroExit: true, timeoutMs: 5_000, maxOutputBytes: 16_384, }, ).pipe(Effect.catchIf(isMissingExecutable, () => Effect.succeed(null))); if (result === null || result.exitCode !== 0) return null; const lines = result.stdout.replace(/\r?\n$/u, "").split(/\r?\n/u); if (lines[0]?.trim() !== "true") return null; // A newline inside a path breaks the line framing; ask for each path on its own. if (lines.length !== 3) { const root = yield* gitCommand(vcsProcess, "GitVcsDriver.detectRepository", cwd, [ "rev-parse", "--show-toplevel", ]); const gitCommonDir = yield* gitCommand(vcsProcess, "GitVcsDriver.detectRepository", cwd, [ "rev-parse", "--git-common-dir", ]).pipe(Effect.orElseSucceed(() => null)); return { kind: "git" as const, rootPath: root.stdout.trim(), metadataPath: gitCommonDir?.stdout.trim() || null, freshness: yield* nowFreshness(), }; } return { kind: "git" as const, rootPath: lines[1]!.trim(), metadataPath: lines[2]!.trim() || null, freshness: yield* nowFreshness(), }; }); const listWorkspaceFiles: VcsDriver.VcsDriver["Service"]["listWorkspaceFiles"] = (cwd) => gitCommand( vcsProcess, "GitVcsDriver.listWorkspaceFiles", cwd, [ ...WORKSPACE_GIT_HARDENED_CONFIG_ARGS, "ls-files", "--cached", "--others", "--exclude-standard", "-z", ], { allowNonZeroExit: true, timeoutMs: 20_000, maxOutputBytes: WORKSPACE_FILES_MAX_OUTPUT_BYTES, appendTruncationMarker: true, }, ).pipe( Effect.flatMap((result) => result.exitCode === 0 ? Effect.gen(function* () { const freshness = yield* nowFreshness(); return { paths: splitNullSeparatedGitStdoutPaths(result), truncated: result.stdoutTruncated, freshness, }; }) : Effect.fail( new VcsProcessExitError({ operation: "GitVcsDriver.listWorkspaceFiles", command: "git ls-files", cwd, exitCode: result.exitCode, detail: result.stderr.trim() || "git ls-files failed", }), ), ), ); const listRemotes: VcsDriver.VcsDriver["Service"]["listRemotes"] = Effect.fn("listRemotes")( function* (cwd) { const result = yield* gitCommand( vcsProcess, "GitVcsDriver.listRemotes", cwd, ["remote", "-v"], { allowNonZeroExit: true, timeoutMs: 5_000, maxOutputBytes: 64 * 1024, }, ); if (result.exitCode !== 0) { return yield* new VcsProcessExitError({ operation: "GitVcsDriver.listRemotes", command: "git remote -v", cwd, exitCode: result.exitCode, detail: result.stderr.trim() || "git remote -v failed", }); } const parsed = parseGitRemoteVerboseOutput(result.stdout); const remotes = Array.from(parsed.entries()).flatMap(([name, remote]) => { if (!remote.url) { return []; } return [ { name, url: remote.url, pushUrl: remote.pushUrl ? Option.some(remote.pushUrl) : Option.none(), isPrimary: name === "origin", }, ]; }); return { remotes, freshness: yield* nowFreshness(), }; }, ); const filterIgnoredPaths: VcsDriver.VcsDriver["Service"]["filterIgnoredPaths"] = Effect.fn( "filterIgnoredPaths", )(function* (cwd, relativePaths) { if (relativePaths.length === 0) { return relativePaths; } const ignoredPaths = new Set(); const chunks = chunkPathsForGitCheckIgnore(relativePaths); for (const chunk of chunks) { const result = yield* gitCommand( vcsProcess, "GitVcsDriver.filterIgnoredPaths", cwd, [...WORKSPACE_GIT_HARDENED_CONFIG_ARGS, "check-ignore", "--no-index", "-z", "--stdin"], { stdin: `${chunk.join("\0")}\0`, allowNonZeroExit: true, timeoutMs: 20_000, maxOutputBytes: WORKSPACE_FILES_MAX_OUTPUT_BYTES, appendTruncationMarker: true, }, ); if (result.exitCode !== 0 && result.exitCode !== 1) { return yield* new VcsProcessExitError({ operation: "GitVcsDriver.filterIgnoredPaths", command: "git check-ignore", cwd, exitCode: result.exitCode, detail: result.stderr.trim() || "git check-ignore failed", }); } for (const ignoredPath of splitNullSeparatedGitStdoutPaths(result)) { ignoredPaths.add(ignoredPath); } } if (ignoredPaths.size === 0) { return relativePaths; } return relativePaths.filter((relativePath) => !ignoredPaths.has(relativePath)); }); const initRepository: VcsDriver.VcsDriver["Service"]["initRepository"] = (input) => gitCommand(vcsProcess, "GitVcsDriver.initRepository", input.cwd, ["init"], { timeoutMs: 10_000, maxOutputBytes: 64 * 1024, }).pipe(Effect.asVoid); const resolveHeadCommit = (cwd: string) => execute({ operation: "GitVcsDriver.checkpoints.resolveHeadCommit", cwd, args: ["rev-parse", "--verify", "--quiet", "HEAD^{commit}"], allowNonZeroExit: true, }).pipe( Effect.map((result) => { if (result.exitCode !== 0) { return null; } const commit = result.stdout.trim(); return commit.length > 0 ? commit : null; }), ); const hasHeadCommit = (cwd: string, env?: NodeJS.ProcessEnv) => execute({ operation: "GitVcsDriver.checkpoints.hasHeadCommit", cwd, args: ["rev-parse", "--verify", "HEAD"], allowNonZeroExit: true, ...(env !== undefined ? { env } : {}), }).pipe(Effect.map((result) => result.exitCode === 0)); const resolveCheckpointCommit = (cwd: string, checkpointRef: string) => execute({ operation: "GitVcsDriver.checkpoints.resolveCheckpointCommit", cwd, args: ["rev-parse", "--verify", "--quiet", `${checkpointRef}^{commit}`], allowNonZeroExit: true, }).pipe( Effect.map((result) => { if (result.exitCode !== 0) { return null; } const commit = result.stdout.trim(); return commit.length > 0 ? commit : null; }), ); // Git reports relative paths from the physical cwd, which can differ from a symlink. const resolveGitCommonDir = Effect.fn("GitVcsDriver.resolveGitCommonDir")(function* ( cwd: string, ) { const result = yield* execute({ operation: "GitVcsDriver.checkpoints.resolveGitCommonDir", cwd, args: ["rev-parse", "--git-common-dir"], }); const gitCommonDir = result.stdout.trim(); if (path.isAbsolute(gitCommonDir)) return gitCommonDir; const physicalCwd = yield* fileSystem.realPath(cwd).pipe(Effect.orElseSucceed(() => cwd)); return path.resolve(physicalCwd, gitCommonDir); }); // --verify prints HEAD after the paths; --quiet leaves only paths for an unborn HEAD. const resolveCaptureRepository = Effect.fn("GitVcsDriver.resolveCaptureRepository")(function* ( cwd: string, ) { const result = yield* execute({ operation: VcsProcess.CHECKPOINT_CAPTURE_OPERATION, cwd, args: [ "rev-parse", "--path-format=absolute", "--git-common-dir", "--git-path", "index", "--show-prefix", "--verify", "--quiet", "HEAD", ], allowNonZeroExit: true, }); const headExists = result.exitCode === 0; const headChecked = headExists || result.exitCode === 1; const lines = result.stdout.split("\n"); const [gitCommonDir = "", indexPath = "", prefix = ""] = lines; if (headChecked && lines.length === (headExists ? 5 : 4) && lines.at(-1) === "") { return { gitCommonDir: gitCommonDir.trim(), indexPath: indexPath.trim(), prefix, headExists, }; } // Git before 2.31 echoes --path-format, and a path containing a newline spans lines. // Separate commands read the paths as before and report a failure the same way. const fallbackCommonDir = yield* resolveGitCommonDir(cwd); const prefixResult = yield* execute({ operation: VcsProcess.CHECKPOINT_CAPTURE_OPERATION, cwd, args: ["rev-parse", "--show-prefix"], }); return { gitCommonDir: fallbackCommonDir, indexPath: undefined, prefix: prefixResult.stdout.replace(/\n$/, ""), headExists: headChecked ? headExists : yield* hasHeadCommit(cwd), }; }); // Git defaults and macOS writeout-only can leave empty refs after crashes; flush before publication. // Set fsync first: Git 2.36 ignores unknown batch without resetting the method. // Batch fully flushes refs and equals fsync on macOS HFS+/APFS and Windows NTFS/ReFS. // Keep per-object fsync elsewhere; VcsProcess retries unsupported macOS full flushes with plain fsync. const durableWrite = [ "-c", "core.fsync=objects,reference", "-c", "core.fsyncMethod=fsync", ...(hostPlatform === "darwin" || hostPlatform === "win32" ? ["-c", "core.fsyncMethod=batch"] : []), ] as const; // Checkpoints must handle existing paths beyond Windows MAX_PATH, even when // core.longpaths is disabled in the user's Git configuration. const longPaths = hostPlatform === "win32" ? ["-c", "core.longpaths=true"] : []; const checkpoints: VcsDriver.VcsCheckpointOps = { captureCheckpoint: Effect.fn("GitVcsDriver.checkpoints.captureCheckpoint")(function* (input) { const operation = VcsProcess.CHECKPOINT_CAPTURE_OPERATION; // Scratch split-index writes can expire sharedindex files still used by the real index. const indexConfig = [ "-c", "core.fsmonitor=false", "-c", "sparse.expectFilesOutsideOfPatterns=false", "-c", "core.splitIndex=false", "-c", "splitIndex.sharedIndexExpire=never", ]; const { gitCommonDir, indexPath, prefix, headExists } = yield* resolveCaptureRepository( input.cwd, ); const indexId = yield* random.uuidV4(); const tempIndexPath = path.join(gitCommonDir, `t3-checkpoint-index-${indexId}`); const hostEnv = yield* HostProcess.Environment; const commitEnv: NodeJS.ProcessEnv = { ...hostEnv, GIT_INDEX_FILE: tempIndexPath, GIT_AUTHOR_NAME: "T3 Code", GIT_AUTHOR_EMAIL: "t3code@users.noreply.github.com", GIT_COMMITTER_NAME: "T3 Code", GIT_COMMITTER_EMAIL: "t3code@users.noreply.github.com", }; const cleanupTempIndex = Effect.forEach( [tempIndexPath, `${tempIndexPath}.lock`], (indexFile) => fileSystem.remove(indexFile, { force: true }).pipe(Effect.ignore), { discard: true }, ); yield* Effect.gen(function* () { const sparseConfig = yield* execute({ operation, cwd: input.cwd, args: ["config", "--bool", "core.sparseCheckout"], allowNonZeroExit: true, }); let sparseCheckout = sparseConfig.stdout.trim() === "true"; if (sparseCheckout) { const help = yield* execute({ operation, cwd: input.cwd, args: ["add", "-h"], allowNonZeroExit: true, }); sparseCheckout = /--(?:\[no-\])?sparse\b/.test(`${help.stdout}${help.stderr}`); } if (headExists) { const reusedIndex = yield* Effect.gen(function* () { const userIndexPath = indexPath ?? (yield* execute({ operation, cwd: input.cwd, args: ["rev-parse", "--path-format=absolute", "--git-path", "index"], })).stdout.trim(); const { mtime } = yield* fileSystem.stat(userIndexPath); if (Option.isNone(mtime)) return false; // Stay below the source timestamp even if Date rounded up, preserving Git's racy check. const indexTime = Math.floor((mtime.value.getTime() - 1) / 1000); if (indexTime <= 0) return false; yield* fileSystem.copyFile(userIndexPath, tempIndexPath); yield* execute({ operation, cwd: input.cwd, args: [...indexConfig, "read-tree", "--reset", "HEAD"], env: commitEnv, }); // read-tree can rewrite the index, so restore its racy timestamp afterward. yield* fileSystem.utimes(tempIndexPath, indexTime, indexTime); // Manually flagged entries hide edits from add -A, so they are re-added below // with no stat cache. Every other entry keeps its stat data and is not rehashed. let fallback = false; let flaggedBytes = 0; let recordStart = true; let collect: "flagged" | "skipped" | undefined; let record: Uint8Array[] = []; const flagged: string[] = []; const skipped: string[] = []; yield* vcsProcess.run({ operation, command: "git", cwd: input.cwd, args: [...indexConfig, "ls-files", "--full-name", "--sparse", "--stage", "-v", "-z"], env: commitEnv, maxOutputBytes: 4_096, outputMode: "truncate", // Only tags and flagged or skip-worktree records matter; scan NUL-delimited records. onStdoutChunk: (chunk) => { let start = 0; while (start < chunk.length && !fallback) { const end = chunk.indexOf(0, start); if (recordStart) { const tag = chunk[start]!; record = []; // Sparse skip-worktree bits are expected; only in-cone ones are manual. collect = tag === 83 && sparseCheckout ? "skipped" : (tag >= 97 && tag <= 122) || tag === 83 ? "flagged" : undefined; // index-info cannot keep skip-worktree while dropping assume-unchanged. if (tag === 115 && sparseCheckout) fallback = true; } if (collect !== undefined && !fallback) { const piece = chunk.subarray(start, end === -1 ? undefined : end); record.push(Buffer.from(piece)); if ( collect === "flagged" && (flaggedBytes += piece.length) > FLAGGED_INDEX_ENTRIES_MAX_BYTES ) fallback = true; if (end !== -1 && !fallback) { const full = Buffer.concat(record); record = []; // Sparse directory entries stay collapsed. if (collect === "flagged" || full.at(-1) !== 47) { // Drop the " " prefix, leaving index-info's " \t". const entry = full.subarray(2); if (!NodeBuffer.isUtf8(entry)) fallback = true; else (collect === "flagged" ? flagged : skipped).push(entry.toString("utf8")); } } } recordStart = end !== -1; if (!recordStart) break; start = end + 1; } }, }); if (fallback) return false; if (skipped.length > 0) { const paths = skipped.map((entry) => entry.slice(entry.indexOf("\t") + 1)); const stdin = paths.join("\0") + "\0"; // check-rules echoes a subset of its input, so the input size bounds its output. const rules = yield* execute({ operation, cwd: input.cwd, args: [...indexConfig, "sparse-checkout", "check-rules", "-z"], stdin, env: commitEnv, maxOutputBytes: Buffer.byteLength(stdin), outputMode: "error", }); const selected = new Set(rules.stdout.split("\0")); skipped.forEach((entry, index) => { if (selected.has(paths[index]!)) flagged.push(entry); }); } if (flagged.length > 0) { // Flagged gitlinks keep the fresh-index behaviour rather than a guessed one. if (flagged.some((entry) => entry.startsWith("160000 "))) return false; yield* execute({ operation, cwd: input.cwd, // core.ignorestat would mark these assume-unchanged, hiding edits from add -A. args: [ ...indexConfig, "-c", "core.ignorestat=false", "update-index", "-z", "--index-info", ], stdin: flagged.join("\0") + "\0", env: commitEnv, }); yield* fileSystem.utimes(tempIndexPath, indexTime, indexTime); } return true; }).pipe(Effect.orElseSucceed(() => false)); if (!reusedIndex) { if (sparseCheckout) { const cone = yield* execute({ operation, cwd: input.cwd, args: ["config", "--bool", "core.sparseCheckoutCone"], allowNonZeroExit: true, }); // Rebuilding a non-cone index loses exclusions; do not publish false deletions. if (cone.stdout.trim() !== "true") { return yield* new VcsProcessExitError({ operation, command: "git read-tree", cwd: input.cwd, exitCode: 1, detail: "Cannot rebuild a checkpoint index for non-cone sparse checkout.", }); } } yield* cleanupTempIndex; yield* execute({ operation, cwd: input.cwd, args: sparseCheckout ? [...indexConfig, "-c", "index.sparse=true", "read-tree", "--reset", "HEAD"] : [...indexConfig, "read-tree", "HEAD"], env: commitEnv, }); } } // The working-tree root is never ignored. Probe a subdirectory without the trailing slash // Git adds to ".", which a blank or whitespace-only ignore line matches as an empty pattern. const workspaceIgnored = prefix !== "" && (yield* filterIgnoredPaths(input.cwd, [`../${prefix.slice(0, -1).split("/").at(-1)}`])) .length === 0; if (workspaceIgnored) { // HEAD omits staged-only paths. Seed just this workspace's additions, keeping // unrelated staged changes and the scratch index's sparse flags untouched. const additions = yield* execute({ operation, cwd: input.cwd, args: [ "-c", "core.quotePath=true", "diff", "--cached", "--raw", "--no-abbrev", "--no-renames", "--no-relative", "--ignore-submodules=none", "--no-color", "--no-ext-diff", "--no-textconv", "--ita-visible-in-index", "--diff-filter=A", "--", ".", ], maxOutputBytes: WORKSPACE_FILES_MAX_OUTPUT_BYTES, outputMode: "error", }); if (additions.stdout.length > 0) { // Git's quoted paths round-trip through index-info, including non-UTF-8 names. yield* execute({ operation, cwd: input.cwd, args: [...indexConfig, "-c", "index.sparse=false", "update-index", "--index-info"], stdin: additions.stdout.replace( /^:000000 ([0-7]{6}) 0+ ([0-9a-f]+) A\t/gm, "$1 $2\t", ), env: commitEnv, }); } const tracked = yield* execute({ operation, cwd: input.cwd, args: [...indexConfig, "ls-files", "--cached", "-z", "--", "."], env: commitEnv, maxOutputBytes: 1, outputMode: "truncate", }); if (tracked.stdout.length === 0) { return yield* new VcsUnsupportedOperationError({ operation, kind: "git", detail: "The workspace directory is ignored by its Git repository.", }); } } const stageFiles = (exclusions: ReadonlyArray) => execute({ operation, cwd: input.cwd, args: [ ...indexConfig, ...durableWrite, ...longPaths, // safecrlf=true refuses LF files under CRLF conversion; capture them as Git's default does. "-c", "core.safecrlf=false", "add", ...(sparseCheckout ? ["--sparse"] : []), // add -A rejects ignored directories even when they contain tracked files. workspaceIgnored ? "-u" : "-A", "--", ".", ...exclusions, ], env: commitEnv, }); yield* stageFiles([]).pipe( Effect.catchTags({ VcsProcessExitError: (error) => Effect.gen(function* () { // Git cannot stage uncommitted embedded repos; scan for them only after staging fails. const untracked = yield* execute({ operation, cwd: input.cwd, args: [ ...longPaths, "ls-files", "--others", "--exclude-standard", "-z", "--", ".", ], env: commitEnv, maxOutputBytes: WORKSPACE_FILES_MAX_OUTPUT_BYTES, }); if (untracked.stdoutTruncated) return yield* error; const candidates = splitNullSeparatedGitStdoutPaths(untracked).filter((entry) => entry.endsWith("/"), ); if (candidates.length > CHECKPOINT_RECOVERY_MAX_CANDIDATES) return yield* error; const nestedRepoEnv: NodeJS.ProcessEnv = { ...hostEnv, GIT_DIR: undefined, GIT_WORK_TREE: undefined, GIT_COMMON_DIR: undefined, GIT_INDEX_FILE: undefined, GIT_OBJECT_DIRECTORY: undefined, GIT_ALTERNATE_OBJECT_DIRECTORIES: undefined, }; const exclusions: Array = []; for (const entry of candidates) { const nestedCwd = path.join(input.cwd, entry); if ( (yield* fileSystem .exists(path.join(nestedCwd, ".git")) .pipe(Effect.mapError(() => error))) && !(yield* hasHeadCommit(nestedCwd, nestedRepoEnv)) ) { exclusions.push(`:(exclude,literal)${entry}`); } } if (exclusions.length === 0) return yield* error; return yield* stageFiles(exclusions); }).pipe( Effect.timeoutOrElse({ duration: CHECKPOINT_RECOVERY_TIMEOUT, orElse: () => Effect.fail(error), }), ), }), ); const writeTreeResult = yield* execute({ operation, cwd: input.cwd, args: [...indexConfig, ...durableWrite, "write-tree"], env: commitEnv, }); const treeOid = writeTreeResult.stdout.trim(); if (treeOid.length === 0) { return yield* new VcsProcessExitError({ operation, command: "git write-tree", cwd: input.cwd, exitCode: 0, detail: "git write-tree returned an empty tree oid.", }); } const message = `t3 checkpoint ref=${input.checkpointRef}`; // HEAD is the parent so listAuthoredPaths can see how HEAD moved between checkpoints. const commitTreeResult = yield* execute({ operation, cwd: input.cwd, args: [ ...durableWrite, "commit-tree", treeOid, ...(headExists ? ["-p", "HEAD"] : []), "-m", message, ], env: commitEnv, }); const commitOid = commitTreeResult.stdout.trim(); if (commitOid.length === 0) { return yield* new VcsProcessExitError({ operation, command: "git commit-tree", cwd: input.cwd, exitCode: 0, detail: "git commit-tree returned an empty commit oid.", }); } yield* execute({ operation, cwd: input.cwd, args: [...durableWrite, "update-ref", input.checkpointRef, commitOid], }); }).pipe(Effect.ensuring(cleanupTempIndex)); }), hasCheckpointRef: (input) => resolveCheckpointCommit(input.cwd, input.checkpointRef).pipe( Effect.map((commit) => commit !== null), ), restoreCheckpoint: Effect.fn("GitVcsDriver.checkpoints.restoreCheckpoint")(function* (input) { const operation = "GitVcsDriver.checkpoints.restoreCheckpoint"; let commitOid = yield* resolveCheckpointCommit(input.cwd, input.checkpointRef); if (!commitOid && input.fallbackToHead === true) { commitOid = yield* resolveHeadCommit(input.cwd); } if (!commitOid) { return false; } const tracked = yield* execute({ operation, cwd: input.cwd, args: ["ls-files", "--cached", `--with-tree=${commitOid}`, "-z", "--", "."], }); // An empty index and checkpoint have nothing for git restore's pathspec to match. if (tracked.stdout.length > 0) { yield* execute({ operation, cwd: input.cwd, args: [ ...longPaths, "restore", "--source", commitOid, "--worktree", "--staged", "--", ".", ], }); } // Restoring away the last tracked file can remove a nested workspace directory. const recreateWorkspace = fileSystem.makeDirectory(input.cwd, { recursive: true }).pipe( Effect.mapError( (cause) => new VcsProcessExitError({ operation, command: "git restore", cwd: input.cwd, exitCode: 0, detail: `Could not recreate the checkpoint workspace: ${cause.message}`, }), ), ); const resetIndex = Effect.gen(function* () { // On Windows clean can remove './' and exit 0. yield* recreateWorkspace; yield* execute({ operation, cwd: input.cwd, args: [...longPaths, "reset", "--quiet", "--", "."], }); }); yield* recreateWorkspace; const clean = execute({ operation, cwd: input.cwd, args: [...longPaths, "clean", "-fd", "--", "."], env: { LC_ALL: "C" }, allowNonZeroExit: true, }); let cleaned = yield* clean; // Removing a new .gitignore exposes the files it hid; clean again until nothing is left. for ( let pass = 1; pass < 4 && cleaned.exitCode === 0 && /^Removing /m.test(cleaned.stdout); pass++ ) { yield* recreateWorkspace; cleaned = yield* clean; } if (cleaned.exitCode === 0 && /^Removing /m.test(cleaned.stdout)) { yield* recreateWorkspace; const remaining = yield* execute({ operation, cwd: input.cwd, args: [...longPaths, "clean", "-dn", "--", "."], env: { LC_ALL: "C" }, }); if (/^Would remove /m.test(remaining.stdout)) { yield* resetIndex; return yield* new VcsProcessExitError({ operation, command: "git clean", cwd: input.cwd, exitCode: 0, detail: "Could not clean the checkpoint workspace: untracked files remain.", }); } } if (cleaned.exitCode !== 0) { // Git can remove every child, then fail trying to remove './' itself. const emptiedWorkspace = cleaned.exitCode === 1 && /^warning: failed to remove \.\/: [^\n]+$/.test(cleaned.stderr.trim()) && (yield* fileSystem.readDirectory(input.cwd).pipe( Effect.map((entries) => entries.length === 0), Effect.orElseSucceed(() => false), )); if (!emptiedWorkspace) { yield* resetIndex; return yield* new VcsProcessExitError({ operation, command: "git clean", cwd: input.cwd, exitCode: cleaned.exitCode, detail: cleaned.stderr.trim() || "Could not clean the checkpoint workspace.", }); } } yield* resetIndex; return true; }), diffCheckpoints: Effect.fn("GitVcsDriver.checkpoints.diffCheckpoints")(function* (input) { const operation = "GitVcsDriver.checkpoints.diffCheckpoints"; if (input.filePaths?.length === 0) return ""; yield* Effect.annotateCurrentSpan({ "checkpoint.cwd": input.cwd, "checkpoint.from_ref": input.fromCheckpointRef, "checkpoint.to_ref": input.toCheckpointRef, "checkpoint.ignore_whitespace": input.ignoreWhitespace, "checkpoint.format": input.format ?? "patch", "checkpoint.fallback_from_to_head": input.fallbackFromToHead, }); let fromRevision: string = input.fromCheckpointRef; if (input.fallbackFromToHead === true) { const resolvedFromCommit = yield* resolveCheckpointCommit( input.cwd, input.fromCheckpointRef, ); if (resolvedFromCommit) { fromRevision = resolvedFromCommit; } else { const headCommit = yield* resolveHeadCommit(input.cwd); if (!headCommit) { return yield* new VcsProcessExitError({ operation, command: "git diff", cwd: input.cwd, exitCode: 1, detail: "Checkpoint ref is unavailable for diff operation.", }); } fromRevision = headCommit; } } const result = yield* execute({ operation, cwd: input.cwd, args: [ // Not --no-relative, which Git before 2.28 rejects. "-c", "diff.relative=false", "-c", "diff.suppressBlankEmpty=false", "diff", ...(input.format === "numstat" ? ["--numstat", "-z"] : ["--patch"]), "--no-color", "--no-ext-diff", "--no-textconv", "--submodule=short", ...PATCH_RENDER_PREFIX_ARGS, ...(input.ignoreWhitespace ? ["--ignore-all-space"] : []), `${fromRevision}^{commit}`, `${input.toCheckpointRef}^{commit}`, // Paths are repository-relative, while cwd can be a subdirectory. ...(input.filePaths ? ["--", ...input.filePaths.map((file) => `:(top,literal)${file}`)] : []), ], allowNonZeroExit: true, maxOutputBytes: input.maxOutputBytes ?? VcsDriver.CHECKPOINT_DIFF_MAX_OUTPUT_BYTES, outputMode: input.format === "numstat" ? "error" : "truncate", }); if (result.exitCode !== 0) { return yield* new VcsProcessExitError({ operation, command: "git diff", cwd: input.cwd, exitCode: result.exitCode, detail: result.stderr.trim() || "Checkpoint ref is unavailable for diff operation.", }); } return result.stdout; }), listAuthoredPaths: Effect.fn("GitVcsDriver.checkpoints.listAuthoredPaths")(function* (input) { const operation = "GitVcsDriver.checkpoints.listAuthoredPaths"; const heads = yield* execute({ operation, cwd: input.cwd, args: [ "log", "--no-walk=unsorted", "--format=%P %ct", `${input.fromCheckpointRef}^{commit}`, `${input.toCheckpointRef}^{commit}`, ], }); const [fromLine = "", toLine = ""] = heads.stdout.trimEnd().split("\n"); const [startHead = "", capturedAt = ""] = fromLine.split(" "); const [endHead = ""] = toLine.split(" "); if (startHead === "" || endHead === "" || startHead === endHead) { return null; } const listPaths = (args: ReadonlyArray) => execute({ operation, cwd: input.cwd, args: [ "-c", "diff.relative=false", // log.showSignature prints verification text into the path list. "-c", "log.showSignature=false", ...args, "--name-only", "-z", "--no-renames", "--no-ext-diff", ], maxOutputBytes: VcsDriver.CHECKPOINT_DIFF_MAX_OUTPUT_BYTES, outputMode: "error", }).pipe(Effect.map((result) => result.stdout.split("\0"))); // Remerge diffs retain merge conflict fixes while excluding clean upstream changes. const pathLists = yield* Effect.all( [ listPaths(["diff", startHead, `${input.fromCheckpointRef}^{commit}`]), listPaths(["diff", endHead, `${input.toCheckpointRef}^{commit}`]), listPaths(["log", "--format=", "--diff-merges=remerge", `${endHead}..${startHead}`]), listPaths([ "log", "--format=", "--diff-merges=remerge", `--since=@${capturedAt}`, `${startHead}..${endHead}`, ]), ], { concurrency: "unbounded" }, ); return new Set(pathLists.flat().filter((path) => path.length > 0)); }), deleteCheckpointRefs: Effect.fn("GitVcsDriver.checkpoints.deleteCheckpointRefs")( function* (input) { yield* Effect.forEach( input.checkpointRefs, (checkpointRef) => execute({ operation: "GitVcsDriver.checkpoints.deleteCheckpointRefs", cwd: input.cwd, args: ["update-ref", "-d", checkpointRef], allowNonZeroExit: true, }), { discard: true }, ); }, ), }; return { execute, checkpoints, detectRepository, isInsideWorkTree, listWorkspaceFiles, listRemotes, filterIgnoredPaths, initRepository, }; }); export const makeVcsDriver = Effect.gen(function* () { const driver = yield* makeVcsDriverShape(); return VcsDriver.VcsDriver.of(driver); }); export const make = Effect.gen(function* () { const git = yield* makeGitVcsDriverCore(); return GitVcsDriver.of(git); }); export const layerVcs = Layer.effect(VcsDriver.VcsDriver, makeVcsDriver); export const layer = Layer.effect(GitVcsDriver, make).pipe(Layer.provide(CheckoutCloner.layer));