import { Context, DateTime, Effect, Layer, Option } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; import { GitCommandError, VcsProcessExitError, type VcsSwitchRefInput, type VcsSwitchRefResult, type VcsCreateRefInput, type VcsCreateRefResult, type VcsCreateWorktreeInput, type VcsCreateWorktreeResult, type VcsInitInput, type VcsListRefsInput, type VcsListRefsResult, type VcsPullResult, type VcsRemoveWorktreeInput, type VcsStatusInput, type VcsStatusResult, } from "@t3tools/contracts"; import * as GitVcsDriverCore from "./GitVcsDriverCore.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; readonly maxOutputBytes?: number; readonly truncateOutputAtMaxBytes?: boolean; readonly progress?: ExecuteGitProgress; } 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"]; 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 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; } export interface GitPushResult { status: "pushed" | "skipped_up_to_date"; branch: string; upstreamBranch?: string | undefined; setUpstream?: boolean | undefined; } export interface GitRangeContext { commitSummary: string; diffSummary: string; diffPatch: string; } export interface GitRenameBranchInput { cwd: string; oldBranch: string; newBranch: string; } export interface GitRenameBranchResult { branch: string; } export interface GitFetchPullRequestBranchInput { cwd: string; prNumber: number; branch: string; } 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 GitSetBranchUpstreamInput { cwd: string; branch: string; remoteName: string; remoteBranch: string; } export interface GitVcsDriverShape { readonly execute: (input: ExecuteGitInput) => Effect.Effect; readonly status: (input: VcsStatusInput) => Effect.Effect; readonly statusDetails: (cwd: string) => Effect.Effect; readonly statusDetailsLocal: (cwd: string) => Effect.Effect; readonly prepareCommitContext: ( cwd: string, filePaths?: readonly string[], ) => 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; readonly readRangeContext: ( cwd: string, baseRef: string, ) => Effect.Effect; readonly readConfigValue: ( cwd: string, key: string, ) => Effect.Effect; readonly listRefs: (input: VcsListRefsInput) => Effect.Effect; readonly pullCurrentBranch: (cwd: string) => Effect.Effect; readonly createWorktree: ( input: VcsCreateWorktreeInput, ) => Effect.Effect; readonly fetchPullRequestBranch: ( input: GitFetchPullRequestBranchInput, ) => Effect.Effect; readonly ensureRemote: (input: GitEnsureRemoteInput) => Effect.Effect; readonly resolvePrimaryRemoteName: (cwd: string) => Effect.Effect; readonly fetchRemoteBranch: ( input: GitFetchRemoteBranchInput, ) => Effect.Effect; readonly fetchRemoteTrackingBranch: ( input: GitFetchRemoteTrackingBranchInput, ) => Effect.Effect; readonly setBranchUpstream: ( input: GitSetBranchUpstreamInput, ) => Effect.Effect; readonly removeWorktree: (input: VcsRemoveWorktreeInput) => 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; } export class GitVcsDriver extends Context.Service()( "t3/vcs/GitVcsDriver", ) {} const WORKSPACE_FILES_MAX_OUTPUT_BYTES = 16 * 1024 * 1024; 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 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); } 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; } const match = /^(\S+)\s+(\S+)\s+\((fetch|push)\)$/.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 = ( process: VcsProcess.VcsProcessShape, operation: string, cwd: string, args: ReadonlyArray, options?: { readonly stdin?: string; readonly env?: NodeJS.ProcessEnv; readonly allowNonZeroExit?: boolean; readonly timeoutMs?: number; readonly maxOutputBytes?: number; readonly truncateOutputAtMaxBytes?: boolean; }, ) => process.run({ operation, command: "git", args, 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?.truncateOutputAtMaxBytes !== undefined ? { truncateOutputAtMaxBytes: options.truncateOutputAtMaxBytes } : {}), }); export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* () { const process = yield* VcsProcess.VcsProcess; const capabilities = { kind: "git" as const, supportsWorktrees: true, supportsBookmarks: false, supportsAtomicSnapshot: false, supportsPushDefaultRemote: true, ignoreClassifier: "native" as const, }; const isInsideWorkTree: VcsDriver.VcsDriverShape["isInsideWorkTree"] = (cwd) => gitCommand( process, "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")); const execute: VcsDriver.VcsDriverShape["execute"] = (input) => gitCommand(process, 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.truncateOutputAtMaxBytes !== undefined ? { truncateOutputAtMaxBytes: input.truncateOutputAtMaxBytes } : {}), }); const detectRepository: VcsDriver.VcsDriverShape["detectRepository"] = Effect.fn( "detectRepository", )(function* (cwd) { if (!(yield* isInsideWorkTree(cwd))) { return null; } const root = yield* gitCommand(process, "GitVcsDriver.detectRepository.root", cwd, [ "rev-parse", "--show-toplevel", ]); const gitCommonDir = yield* gitCommand( process, "GitVcsDriver.detectRepository.commonDir", cwd, ["rev-parse", "--git-common-dir"], ).pipe(Effect.catch(() => Effect.succeed(null))); return { kind: "git" as const, rootPath: root.stdout.trim(), metadataPath: gitCommonDir?.stdout.trim() || null, freshness: yield* nowFreshness(), }; }); const listWorkspaceFiles: VcsDriver.VcsDriverShape["listWorkspaceFiles"] = (cwd) => gitCommand( process, "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, truncateOutputAtMaxBytes: true, }, ).pipe( Effect.flatMap((result) => result.exitCode === 0 ? Effect.gen(function* () { const freshness = yield* nowFreshness(); return { paths: splitNullSeparatedPaths(result.stdout, result.stdoutTruncated), 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.VcsDriverShape["listRemotes"] = Effect.fn("listRemotes")( function* (cwd) { const result = yield* gitCommand(process, "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.VcsDriverShape["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( process, "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, truncateOutputAtMaxBytes: 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 splitNullSeparatedPaths(result.stdout, result.stdoutTruncated)) { ignoredPaths.add(ignoredPath); } } if (ignoredPaths.size === 0) { return relativePaths; } return relativePaths.filter((relativePath) => !ignoredPaths.has(relativePath)); }); const initRepository: VcsDriver.VcsDriverShape["initRepository"] = (input) => gitCommand(process, "GitVcsDriver.initRepository", input.cwd, ["init"], { timeoutMs: 10_000, maxOutputBytes: 64 * 1024, }).pipe(Effect.asVoid); return VcsDriver.VcsDriver.of({ capabilities, execute, detectRepository, isInsideWorkTree, listWorkspaceFiles, listRemotes, filterIgnoredPaths, initRepository, }); }); export const makeVcsDriver = Effect.fn("makeGitVcsDriver")(function* () { const driver = yield* makeVcsDriverShape(); return VcsDriver.VcsDriver.of(driver); }); export const make = Effect.fn("makeGitVcsDriverService")(function* () { const git = yield* GitVcsDriverCore.makeGitVcsDriverCore(); return GitVcsDriver.of(git); }); export const vcsLayer = Layer.effect(VcsDriver.VcsDriver, makeVcsDriver()); export const layer = Layer.effect(GitVcsDriver, make());