import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { $which, hasFsCode, isEnoent, Snowflake } from "@oh-my-pi/pi-utils"; import { parseDiffHunks as parseCommitDiffHunks, parseFileDiffs, parseFileHunks, parseNumstat, } from "../commit/git/diff"; import type { FileDiff, FileHunks, NumstatEntry } from "../commit/types"; import { ToolAbortError, ToolError, throwIfAborted } from "../tools/tool-errors"; // ════════════════════════════════════════════════════════════════════════════ // Types // ════════════════════════════════════════════════════════════════════════════ export interface GitCommandResult { exitCode: number; stdout: string; stderr: string; } export interface GitRepository { commonDir: string; gitDir: string; gitEntryPath: string; headPath: string; repoRoot: string; } export interface GitStatusSummary { staged: number; unstaged: number; untracked: number; } export type HunkSelection = { path: string; hunks: { type: "all" } | { type: "indices"; indices: number[] } | { type: "lines"; start: number; end: number }; }; export interface StageHunksOptions { readonly diffCached?: boolean; readonly rawDiff?: string; readonly signal?: AbortSignal; } export interface DiffOptions { readonly allowFailure?: boolean; readonly base?: string; readonly binary?: boolean; readonly cached?: boolean; readonly env?: Record; readonly files?: readonly string[]; readonly head?: string; readonly nameOnly?: boolean; readonly noIndex?: { left: string; right: string }; readonly numstat?: boolean; readonly signal?: AbortSignal; readonly stat?: boolean; } export interface StatusOptions { readonly pathspecs?: readonly string[]; readonly porcelainV1?: boolean; readonly signal?: AbortSignal; readonly untrackedFiles?: "all" | "no" | "normal"; readonly z?: boolean; } export interface CommitOptions { readonly allowEmpty?: boolean; readonly files?: readonly string[]; readonly signal?: AbortSignal; } export interface PushOptions { readonly forceWithLease?: boolean; readonly refspec?: string; readonly remote?: string; readonly signal?: AbortSignal; } export interface PatchOptions { readonly cached?: boolean; readonly check?: boolean; readonly env?: Record; readonly signal?: AbortSignal; } export interface RestoreOptions { readonly files?: readonly string[]; readonly signal?: AbortSignal; readonly source?: string; readonly staged?: boolean; readonly worktree?: boolean; } export interface CloneOptions { readonly ref?: string; readonly sha?: string; readonly signal?: AbortSignal; } interface GitHeadBase extends GitRepository { headContent: string; } export interface GitRefHead extends GitHeadBase { branchName: string | null; commit: string | null; kind: "ref"; ref: string; } export interface GitDetachedHead extends GitHeadBase { commit: string | null; kind: "detached"; } export type GitHeadState = GitRefHead | GitDetachedHead; export interface GitWorktreeEntry { branch?: string; detached: boolean; head?: string; path: string; } // ════════════════════════════════════════════════════════════════════════════ // Error // ════════════════════════════════════════════════════════════════════════════ export class GitCommandError extends Error { readonly args: readonly string[]; readonly result: GitCommandResult; constructor(args: readonly string[], result: GitCommandResult) { super(formatCommandFailure(args, result)); this.name = "GitCommandError"; this.args = [...args]; this.result = result; } } // ════════════════════════════════════════════════════════════════════════════ // Internal: Core execution // ════════════════════════════════════════════════════════════════════════════ const NO_OPTIONAL_LOCKS = "--no-optional-locks"; const HEAD_REF_PREFIX = "ref:"; const LOCAL_BRANCH_PREFIX = "refs/heads/"; const DEFAULT_BRANCH_REFS = ["refs/remotes/origin/HEAD", "refs/remotes/upstream/HEAD"] as const; const SHORT_LIVED_GIT_CONFIG: readonly (readonly [key: string, value: string])[] = [ ["core.fsmonitor", "false"], ["core.untrackedCache", "false"], ]; const REMOTE_ALREADY_EXISTS = /remote .* already exists/i; interface CommandOptions { readonly env?: Record; readonly readOnly?: boolean; readonly signal?: AbortSignal; readonly stdin?: string | Uint8Array | ArrayBuffer | SharedArrayBuffer; } function normalizeStdin(input: CommandOptions["stdin"]): "ignore" | Uint8Array { if (input === undefined) return "ignore"; if (typeof input === "string") return new TextEncoder().encode(input); if (input instanceof Uint8Array) return input; return new Uint8Array(input); } function ensureAvailable(): void { if (!$which("git")) { throw new Error("git is not installed."); } } function formatCommandFailure( args: readonly string[], result: Pick, ): string { const stderr = result.stderr.trim(); if (stderr) return stderr; const stdout = result.stdout.trim(); if (stdout) return stdout; return `git ${args.join(" ")} failed with exit code ${result.exitCode}`; } async function runCommand( cwd: string, args: readonly string[], options: CommandOptions = {}, ): Promise { const commandArgs = withShortLivedGitConfig(options.readOnly ? withNoOptionalLocks(args) : [...args]); const child = Bun.spawn(["git", ...commandArgs], { cwd, env: options.env ? { ...process.env, GIT_OPTIONAL_LOCKS: "0", ...options.env } : undefined, signal: options.signal, stdin: normalizeStdin(options.stdin), stdout: "pipe", stderr: "pipe", windowsHide: true, }); if (!child.stdout || !child.stderr) { throw new Error("Failed to capture git command output."); } const [stdout, stderr, exitCode] = await Promise.all([ new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited, ]); return { exitCode: exitCode ?? 0, stdout, stderr }; } function withNoOptionalLocks(args: readonly string[]): string[] { if (args.includes(NO_OPTIONAL_LOCKS)) return [...args]; return [NO_OPTIONAL_LOCKS, ...args]; } function withShortLivedGitConfig(args: readonly string[]): string[] { const prefix: string[] = []; for (const [key, value] of SHORT_LIVED_GIT_CONFIG) { if (hasGitConfig(args, key, value)) continue; prefix.push("-c", `${key}=${value}`); } return [...prefix, ...args]; } function hasGitConfig(args: readonly string[], key: string, value: string): boolean { const expected = `${key}=${value}`; for (let index = 0; index < args.length - 1; index += 1) { if (args[index] === "-c" && args[index + 1] === expected) { return true; } } return false; } async function runChecked( cwd: string, args: readonly string[], options: CommandOptions = {}, ): Promise { ensureAvailable(); const result = await runCommand(cwd, args, options); if (result.exitCode !== 0) { throw new GitCommandError(args, result); } return result; } async function runEffect(cwd: string, args: readonly string[], options: CommandOptions = {}): Promise { await runChecked(cwd, args, options); } async function runText(cwd: string, args: readonly string[], options: CommandOptions = {}): Promise { return (await runChecked(cwd, args, options)).stdout; } async function tryText( cwd: string, args: readonly string[], options: CommandOptions = {}, ): Promise { ensureAvailable(); const result = await runCommand(cwd, args, options); if (result.exitCode !== 0) return undefined; return result.stdout; } // ════════════════════════════════════════════════════════════════════════════ // Internal: per-repo write serialization // ════════════════════════════════════════════════════════════════════════════ // Git uses lock files (`.git/config.lock`, commit-graph chain locks, // `packed-refs.lock`, …) for many of its mutating operations. Each is created // O_EXCL with no waiter, so concurrent in-process git invocations against the // same repository fail immediately rather than block. Worktrees share the // primary repo's `.git` directory, so racing across worktrees has the same // failure mode. We give callers a single per-repo serialization point keyed by // the primary repo root: any block that mutates repo state should hold this // lock so unrelated callers cannot collide on git's internal locks. const repoWriteChain = new Map>(); /** * Serialize an async block that mutates a git repository against other * in-process callers operating on the same repository. The lock is keyed by * the primary repo root so worktrees of the same repo share a single queue. * Failures in one block do not poison the queue for the next caller. * * Not reentrant: do NOT nest acquisitions for the same repo. Helpers in this * module never auto-acquire — callers wrap the critical section themselves. */ export async function withRepoLock(cwd: string, fn: () => Promise, signal?: AbortSignal): Promise { const key = (await repo.primaryRoot(cwd, signal)) ?? cwd; const prior = repoWriteChain.get(key); const run = (async () => { if (prior) { try { await prior; } catch { // A prior caller failing must not block us from running. } } throwIfAborted(signal); return fn(); })(); repoWriteChain.set(key, run); try { return await run; } finally { if (repoWriteChain.get(key) === run) repoWriteChain.delete(key); } } function splitLines(text: string): string[] { return text .split("\n") .map(line => line.trim()) .filter(Boolean); } function trimScalar(text: string | undefined): string | undefined { const trimmed = text?.trim(); return trimmed || undefined; } // ════════════════════════════════════════════════════════════════════════════ // Internal: Argument builders // ════════════════════════════════════════════════════════════════════════════ function buildDiffArgs(options: DiffOptions): string[] { const args = ["diff"]; if (options.binary) args.push("--binary"); if (options.cached) args.push("--cached"); if (options.nameOnly) args.push("--name-only"); if (options.stat) args.push("--stat"); if (options.numstat) args.push("--numstat"); if (options.noIndex) { args.push("--no-index", options.noIndex.left, options.noIndex.right); return args; } if (options.base) { args.push(options.base); if (options.head) args.push(options.head); } if (options.files?.length) args.push("--", ...options.files); return args; } function buildApplyArgs(patchPath: string, options: PatchOptions): string[] { const args = ["apply"]; if (options.check) args.push("--check"); if (options.cached) args.push("--cached"); args.push("--binary", patchPath); return args; } async function writeTempPatch(content: string): Promise { const tempPath = path.join(os.tmpdir(), `omp-git-patch-${Snowflake.next()}.patch`); await Bun.write(tempPath, content); return tempPath; } // ════════════════════════════════════════════════════════════════════════════ // Internal: Repository resolution // ════════════════════════════════════════════════════════════════════════════ type EntryType = "directory" | "file"; function shouldRetry(err: unknown, n: number) { if (isEnoent(err) || hasFsCode(err, "ENFILE") || hasFsCode(err, "EMFILE")) return false; if (hasFsCode(err, "EINTR")) return n < EINTR_MAX_RETRIES; if (n > EINTR_MAX_RETRIES) throw err; throw err; } /** * Bounded retry for synchronous I/O against `EINTR`. POSIX permits short syscalls * to be interrupted by signals; when that happens libc traditionally retries. * Node's sync wrappers surface the raw `EINTR` so we replicate the retry locally. * Any other error (and persistent EINTR after `EINTR_MAX_RETRIES`) is rethrown * for the caller's normal "optional metadata" classifier to handle. */ const EINTR_MAX_RETRIES = 3; function retryOnEintrSync(op: () => T): T | null { for (let attempt = 0; attempt <= EINTR_MAX_RETRIES; attempt += 1) { try { return op(); } catch (err) { if (shouldRetry(err, attempt)) continue; return null; } } throw new Error("retryOnEintrSync: exhausted without resolution"); } async function retryOnEintr(op: () => Promise): Promise { for (let attempt = 0; attempt <= EINTR_MAX_RETRIES; attempt += 1) { try { return await op(); } catch (err) { if (shouldRetry(err, attempt)) continue; return null; } } throw new Error("retryOnEintr: exhausted without resolution"); } function getEntryTypeSync(gitEntryPath: string): EntryType | null { return retryOnEintrSync(() => { const stat = fs.statSync(gitEntryPath); if (stat.isDirectory()) return "directory"; if (stat.isFile()) return "file"; return null; }); } async function getEntryType(gitEntryPath: string): Promise { return retryOnEintr(async () => { const stat = await fs.promises.stat(gitEntryPath); if (stat.isDirectory()) return "directory"; if (stat.isFile()) return "file"; return null; }); } function readOptionalTextSync(filePath: string): string | null { return retryOnEintrSync(() => fs.readFileSync(filePath, "utf8")); } async function readOptionalText(filePath: string): Promise { return retryOnEintr(async () => await Bun.file(filePath).text()); } function parseGitDirPointer(content: string): string | null { const match = /^gitdir:\s*(.+)\s*$/iu.exec(content.trim()); return match?.[1] ?? null; } function resolveGitDirSync(gitEntryPath: string, entryType: EntryType): string | null { if (entryType === "directory") return gitEntryPath; const content = readOptionalTextSync(gitEntryPath); if (content === null) return null; const parsed = parseGitDirPointer(content); if (!parsed) return null; const gitDir = path.resolve(path.dirname(gitEntryPath), parsed); return getEntryTypeSync(gitDir) === "directory" ? gitDir : null; } async function resolveGitDir(gitEntryPath: string, entryType: EntryType): Promise { if (entryType === "directory") return gitEntryPath; const content = await readOptionalText(gitEntryPath); if (content === null) return null; const parsed = parseGitDirPointer(content); if (!parsed) return null; const gitDir = path.resolve(path.dirname(gitEntryPath), parsed); return (await getEntryType(gitDir)) === "directory" ? gitDir : null; } function resolveCommonDirSync(gitDir: string): string { const content = readOptionalTextSync(path.join(gitDir, "commondir")); const relative = content?.trim(); if (!relative) return gitDir; return path.resolve(gitDir, relative); } async function resolveCommonDir(gitDir: string): Promise { const content = await readOptionalText(path.join(gitDir, "commondir")); const relative = content?.trim(); if (!relative) return gitDir; return path.resolve(gitDir, relative); } function resolveRepoFromEntrySync(repoRoot: string, gitEntryPath: string, entryType: EntryType): GitRepository | null { const gitDir = resolveGitDirSync(gitEntryPath, entryType); if (!gitDir) return null; return { commonDir: resolveCommonDirSync(gitDir), gitDir, gitEntryPath, headPath: path.join(gitDir, "HEAD"), repoRoot, }; } async function resolveRepoFromEntry( repoRoot: string, gitEntryPath: string, entryType: EntryType, ): Promise { const gitDir = await resolveGitDir(gitEntryPath, entryType); if (!gitDir) return null; return { commonDir: await resolveCommonDir(gitDir), gitDir, gitEntryPath, headPath: path.join(gitDir, "HEAD"), repoRoot, }; } function resolveRepositorySync(startDir: string): GitRepository | null { let current = path.resolve(startDir); while (true) { const gitEntryPath = path.join(current, ".git"); const entryType = getEntryTypeSync(gitEntryPath); if (entryType) { const repository = resolveRepoFromEntrySync(current, gitEntryPath, entryType); if (repository) return repository; } const parent = path.dirname(current); if (parent === current) return null; current = parent; } } async function resolveRepository(startDir: string): Promise { let current = path.resolve(startDir); while (true) { const gitEntryPath = path.join(current, ".git"); const entryType = await getEntryType(gitEntryPath); if (entryType) { const repository = await resolveRepoFromEntry(current, gitEntryPath, entryType); if (repository) return repository; } const parent = path.dirname(current); if (parent === current) return null; current = parent; } } // ════════════════════════════════════════════════════════════════════════════ // Internal: Ref resolution // ════════════════════════════════════════════════════════════════════════════ function getRefLookupDirs(repository: GitRepository): string[] { if (repository.gitDir === repository.commonDir) return [repository.gitDir]; return [repository.gitDir, repository.commonDir]; } function normalizeRefValue(content: string | null): string | null { const trimmed = content?.trim() ?? ""; return trimmed || null; } function parsePackedRefs(content: string | null, targetRef: string): string | null { if (!content) return null; for (const line of content.split("\n")) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith("#") || trimmed.startsWith("^")) continue; const [sha, refName] = trimmed.split(" ", 2); if (refName === targetRef && sha) return sha; } return null; } function readRefSync(repository: GitRepository, targetRef: string): string | null { for (const dir of getRefLookupDirs(repository)) { const value = normalizeRefValue(readOptionalTextSync(path.join(dir, targetRef))); if (value) return value; } for (const dir of getRefLookupDirs(repository)) { const value = parsePackedRefs(readOptionalTextSync(path.join(dir, "packed-refs")), targetRef); if (value) return value; } return null; } async function readRef(repository: GitRepository, targetRef: string): Promise { for (const dir of getRefLookupDirs(repository)) { const value = normalizeRefValue(await readOptionalText(path.join(dir, targetRef))); if (value) return value; } for (const dir of getRefLookupDirs(repository)) { const value = parsePackedRefs(await readOptionalText(path.join(dir, "packed-refs")), targetRef); if (value) return value; } return null; } // ════════════════════════════════════════════════════════════════════════════ // Internal: Head state parsing // ════════════════════════════════════════════════════════════════════════════ function parseHeadStateSync(repository: GitRepository, headContent: string): GitHeadState { const trimmed = headContent.trim(); if (!trimmed?.startsWith(HEAD_REF_PREFIX)) { return { ...repository, commit: trimmed || null, headContent, kind: "detached" }; } const refValue = trimmed.slice(HEAD_REF_PREFIX.length).trim(); const branchName = refValue.startsWith(LOCAL_BRANCH_PREFIX) ? refValue.slice(LOCAL_BRANCH_PREFIX.length) : null; return { ...repository, branchName, commit: readRefSync(repository, refValue), headContent, kind: "ref", ref: refValue, }; } async function parseHeadState(repository: GitRepository, headContent: string): Promise { const trimmed = headContent.trim(); if (!trimmed?.startsWith(HEAD_REF_PREFIX)) { return { ...repository, commit: trimmed || null, headContent, kind: "detached" }; } const refValue = trimmed.slice(HEAD_REF_PREFIX.length).trim(); const branchName = refValue.startsWith(LOCAL_BRANCH_PREFIX) ? refValue.slice(LOCAL_BRANCH_PREFIX.length) : null; return { ...repository, branchName, commit: await readRef(repository, refValue), headContent, kind: "ref", ref: refValue, }; } function parseDefaultBranchRef(refPath: string, target: string | null): string | null { if (!target?.startsWith(HEAD_REF_PREFIX)) return null; const resolvedRef = target.slice(HEAD_REF_PREFIX.length).trim(); const remotePrefix = refPath.slice(0, -"HEAD".length); if (!resolvedRef.startsWith(remotePrefix)) return null; return resolvedRef.slice(remotePrefix.length) || null; } function stripRemotePrefix(refValue: string): string | null { const slash = refValue.indexOf("/"); if (slash < 0) return refValue || null; return refValue.slice(slash + 1) || null; } function parseWorktreeList(text: string): GitWorktreeEntry[] { const trimmed = text.trim(); if (!trimmed) return []; return trimmed .split(/\n\s*\n/) .map(block => block.trim()) .filter(Boolean) .map(block => { const entry: GitWorktreeEntry = { detached: false, path: "" }; for (const line of block.split("\n")) { if (line.startsWith("worktree ")) entry.path = line.slice("worktree ".length); else if (line.startsWith("HEAD ")) entry.head = line.slice("HEAD ".length); else if (line.startsWith("branch ")) entry.branch = line.slice("branch ".length); else if (line === "detached") entry.detached = true; } return entry; }); } // ════════════════════════════════════════════════════════════════════════════ // Internal: Hunk selection // ════════════════════════════════════════════════════════════════════════════ function extractFileHeader(diffText: string): string { const lines = diffText.split("\n"); const headerLines: string[] = []; for (const line of lines) { if (line.startsWith("@@")) break; headerLines.push(line); } return headerLines.join("\n"); } function selectHunks(file: FileHunks, selector: HunkSelection["hunks"]): FileHunks["hunks"] { if (selector.type === "indices") { const wanted = new Set(selector.indices.map(v => Math.max(1, Math.floor(v)))); return file.hunks.filter(hunk => wanted.has(hunk.index + 1)); } if (selector.type === "lines") { const start = Math.floor(selector.start); const end = Math.floor(selector.end); return file.hunks.filter(hunk => hunk.newStart <= end && hunk.newStart + hunk.newLines - 1 >= start); } return file.hunks; } function parseStatusPorcelain(text: string): GitStatusSummary { let staged = 0; let unstaged = 0; let untracked = 0; for (const line of text.split("\n")) { if (!line) continue; const x = line[0]; const y = line[1]; if (x === "?" && y === "?") { untracked += 1; continue; } if (x && x !== " " && x !== "?") staged += 1; if (y && y !== " ") unstaged += 1; } return { staged, unstaged, untracked }; } // ════════════════════════════════════════════════════════════════════════════ // API: diff // ════════════════════════════════════════════════════════════════════════════ /** Run `git diff` with the given options. Returns raw diff text. */ export const diff = Object.assign( async function diff(cwd: string, options: DiffOptions = {}): Promise { const args = buildDiffArgs(options); if (options.allowFailure) { return (await runCommand(cwd, args, { env: options.env, readOnly: true, signal: options.signal })).stdout; } return runText(cwd, args, { env: options.env, readOnly: true, signal: options.signal }); }, { /** List changed file paths. */ async changedFiles( cwd: string, options: Pick = {}, ): Promise { return splitLines(await diff(cwd, { ...options, nameOnly: true })); }, /** Parsed per-file add/remove counts. */ async numstat(cwd: string, options: Pick = {}): Promise { return parseNumstat(await diff(cwd, { ...options, numstat: true })); }, /** Parsed diff hunks for the given files. */ async hunks( cwd: string, files: readonly string[], options: { cached?: boolean; signal?: AbortSignal } = {}, ): Promise { return parseCommitDiffHunks( await diff(cwd, { cached: options.cached ?? true, files, signal: options.signal }), ); }, /** Check whether a diff exists (uses `--quiet` for efficiency). */ async has(cwd: string, options: Pick = {}): Promise { const args = ["diff"]; if (options.cached) args.push("--cached"); args.push("--quiet"); if (options.files?.length) args.push("--", ...options.files); const result = await runCommand(cwd, args, { readOnly: true, signal: options.signal }); if (result.exitCode === 0) return false; if (result.exitCode === 1) return true; throw new GitCommandError(args, result); }, /** Diff between two tree-ish objects (`git diff-tree`). */ async tree( cwd: string, base: string, headRef: string, options: { binary?: boolean; signal?: AbortSignal; allowFailure?: boolean } = {}, ): Promise { const args = ["diff-tree", "-r", "-p"]; if (options.binary) args.push("--binary"); args.push(base, headRef); if (options.allowFailure) { return (await runCommand(cwd, args, { readOnly: true, signal: options.signal })).stdout; } return runText(cwd, args, { readOnly: true, signal: options.signal }); }, /** Parse raw diff text into per-file diffs. */ parseFiles(text: string): FileDiff[] { return parseFileDiffs(text); }, /** Parse raw diff text into per-file hunks. */ parseHunks(text: string): FileHunks[] { return parseCommitDiffHunks(text); }, }, ); // ════════════════════════════════════════════════════════════════════════════ // API: status // ════════════════════════════════════════════════════════════════════════════ /** Run `git status --porcelain`. Returns raw status text. */ export const status = Object.assign( async function status(cwd: string, options: StatusOptions = {}): Promise { const args = ["status"]; args.push(options.porcelainV1 ? "--porcelain=v1" : "--porcelain"); if (options.z) args.push("-z"); if (options.untrackedFiles) args.push(`--untracked-files=${options.untrackedFiles}`); if (options.pathspecs?.length) args.push("--", ...options.pathspecs); return runText(cwd, args, { readOnly: true, signal: options.signal }); }, { /** Parsed status counts (staged, unstaged, untracked). */ async summary(cwd: string, signal?: AbortSignal): Promise { const result = await runCommand(cwd, ["status", "--porcelain"], { readOnly: true, signal }); if (result.exitCode !== 0) return null; return parseStatusPorcelain(result.stdout); }, /** Parse porcelain status text into counts. */ parse: parseStatusPorcelain, }, ); // ════════════════════════════════════════════════════════════════════════════ // API: stage // ════════════════════════════════════════════════════════════════════════════ export const stage = { /** Stage files. Empty array stages all (`git add -A`). */ async files(cwd: string, files: readonly string[] = [], signal?: AbortSignal): Promise { const args = files.length === 0 ? ["add", "-A"] : ["add", "--", ...files]; await runEffect(cwd, args, { signal }); }, /** Selectively stage hunks from the provided diff or the current working tree diff. */ async hunks(cwd: string, selections: HunkSelection[], options: StageHunksOptions = {}): Promise { if (selections.length === 0) return; const rawDiff = options.rawDiff ?? (await diff(cwd, { cached: options.diffCached, signal: options.signal })); const fileDiffs = parseFileDiffs(rawDiff); const fileDiffMap = new Map(fileDiffs.map(entry => [entry.filename, entry])); const patchParts: string[] = []; for (const selection of selections) { const fileDiff = fileDiffMap.get(selection.path); if (!fileDiff) throw new Error(`No diff found for ${selection.path}`); if (fileDiff.isBinary) { if (selection.hunks.type !== "all") throw new Error(`Cannot select hunks for binary file ${selection.path}`); patchParts.push(fileDiff.content); continue; } if (selection.hunks.type === "all") { patchParts.push(fileDiff.content); continue; } const fileHunks = parseFileHunks(fileDiff); const selected = selectHunks(fileHunks, selection.hunks); if (selected.length === 0) throw new Error(`No hunks selected for ${selection.path}`); const header = extractFileHeader(fileDiff.content); patchParts.push([header, ...selected.map(h => h.content)].join("\n")); } const patchText = patch.join(patchParts); if (!patchText.trim()) return; await patch.applyText(cwd, patchText, { cached: true, signal: options.signal }); }, /** Unstage files. Empty array unstages all (`git reset`). */ async reset(cwd: string, files: readonly string[] = [], signal?: AbortSignal): Promise { const args = files.length === 0 ? ["reset"] : ["reset", "--", ...files]; await runEffect(cwd, args, { signal }); }, }; // ════════════════════════════════════════════════════════════════════════════ // API: commit, push, checkout // ════════════════════════════════════════════════════════════════════════════ /** Create a commit with the given message (passed via stdin). */ export async function commit(cwd: string, message: string, options: CommitOptions = {}): Promise { const args = ["commit", "-F", "-"]; if (options.allowEmpty) args.push("--allow-empty"); if (options.files?.length) args.push("--", ...options.files); return runChecked(cwd, args, { signal: options.signal, stdin: message }); } /** Push the current branch. */ export async function push(cwd: string, options: PushOptions = {}): Promise { const args = ["push"]; if (options.forceWithLease) args.push("--force-with-lease"); if (options.remote) args.push(options.remote); if (options.refspec) args.push(options.refspec); await runEffect(cwd, args, { signal: options.signal }); } /** Checkout a ref. */ export async function checkout(cwd: string, ref: string, signal?: AbortSignal): Promise { await runEffect(cwd, ["checkout", ref], { signal }); } /** Fetch a specific refspec from a remote. */ export async function fetch( cwd: string, remote: string, source: string, target: string, signal?: AbortSignal, ): Promise { await runEffect(cwd, ["fetch", remote, `+${source}:${target}`], { signal }); } /** Read a tree-ish into the index. */ export async function readTree( cwd: string, treeish: string, options: Pick = {}, ): Promise { await runEffect(cwd, ["read-tree", treeish], options); } /** Write the current index as a tree and return its object id. */ export async function writeTree(cwd: string, options: Pick = {}): Promise { return (await runText(cwd, ["write-tree"], options)).trim(); } // ════════════════════════════════════════════════════════════════════════════ // API: show // ════════════════════════════════════════════════════════════════════════════ /** Run `git show` on a revision. */ export const show = Object.assign( async function show( cwd: string, revision: string, options: { format?: string; signal?: AbortSignal } = {}, ): Promise { return runText(cwd, ["show", `--format=${options.format ?? ""}`, revision], { readOnly: true, signal: options.signal, }); }, { /** Get the path prefix of the current directory relative to the repo root. */ async prefix(cwd: string, signal?: AbortSignal): Promise { return (await runText(cwd, ["rev-parse", "--show-prefix"], { readOnly: true, signal })).trim(); }, }, ); // ════════════════════════════════════════════════════════════════════════════ // API: log // ════════════════════════════════════════════════════════════════════════════ export const log = { /** Recent commit subjects (one-line each). */ async subjects(cwd: string, count: number, signal?: AbortSignal): Promise { return splitLines(await runText(cwd, ["log", `-n${count}`, "--pretty=format:%s"], { readOnly: true, signal })); }, /** Recent commits as ` ` onelines. */ async onelines(cwd: string, count: number, signal?: AbortSignal): Promise { return splitLines( await runText(cwd, ["log", `-${count}`, "--oneline", "--no-decorate"], { readOnly: true, signal }), ); }, }; // ════════════════════════════════════════════════════════════════════════════ // API: branch // ════════════════════════════════════════════════════════════════════════════ export const branch = { /** Current branch name, or null if detached/unavailable. */ async current(cwd: string, signal?: AbortSignal): Promise { const headState = await resolveHead(cwd); if (headState?.kind === "ref") return headState.branchName ?? headState.ref; const result = await runCommand(cwd, ["symbolic-ref", "--short", "HEAD"], { readOnly: true, signal }); if (result.exitCode !== 0) return null; return result.stdout.trim() || null; }, /** Default branch name (from remote HEAD refs). */ async default(cwd: string, signal?: AbortSignal): Promise { const repository = await resolveRepository(cwd); if (repository) { for (const refPath of DEFAULT_BRANCH_REFS) { const target = await readRef(repository, refPath); const branchName = parseDefaultBranchRef(refPath, target); if (branchName) return branchName; } } for (const remoteRef of ["origin/HEAD", "upstream/HEAD"]) { const result = await runCommand(cwd, ["rev-parse", "--abbrev-ref", remoteRef], { readOnly: true, signal }); if (result.exitCode !== 0) continue; const branchName = stripRemotePrefix(result.stdout.trim()); if (branchName) return branchName; } return null; }, /** Create a new branch at the given start point. */ async create(cwd: string, name: string, startPoint = "HEAD", signal?: AbortSignal): Promise { await runEffect(cwd, ["branch", name, startPoint], { signal }); }, /** Force-move a branch to a new start point. */ async force(cwd: string, name: string, startPoint: string, signal?: AbortSignal): Promise { await runEffect(cwd, ["branch", "--force", name, startPoint], { signal }); }, /** Delete a branch. Throws on failure. */ async delete(cwd: string, name: string, options: { force?: boolean; signal?: AbortSignal } = {}): Promise { await runEffect(cwd, ["branch", options.force === false ? "-d" : "-D", name], { signal: options.signal }); }, /** Delete a branch. Returns false on failure instead of throwing. */ async tryDelete( cwd: string, name: string, options: { force?: boolean; signal?: AbortSignal } = {}, ): Promise { const result = await runCommand(cwd, ["branch", options.force === false ? "-d" : "-D", name], { signal: options.signal, }); return result.exitCode === 0; }, /** Create and checkout a new branch. */ async checkoutNew(cwd: string, name: string, signal?: AbortSignal): Promise { await runEffect(cwd, ["checkout", "-b", name], { signal }); }, /** List branches. Pass `{ all: true }` to include remotes. */ async list(cwd: string, options: { all?: boolean; signal?: AbortSignal } = {}): Promise { const args = ["branch"]; if (options.all) args.push("-a"); args.push("--format=%(refname:short)"); return splitLines(await runText(cwd, args, { readOnly: true, signal: options.signal })); }, }; // ════════════════════════════════════════════════════════════════════════════ // API: remote // ════════════════════════════════════════════════════════════════════════════ export const remote = { /** List remote names. */ async list(cwd: string, signal?: AbortSignal): Promise { return splitLines(await runText(cwd, ["remote"], { readOnly: true, signal })); }, /** Get the URL for a remote. */ async url(cwd: string, name: string, signal?: AbortSignal): Promise { return trimScalar(await tryText(cwd, ["remote", "get-url", name], { readOnly: true, signal })); }, /** * Add a remote pointing at `url`. Idempotent: if a remote named `name` * already exists with the same URL (e.g. an in-process race or a leftover * remote from a previous run), this is treated as success. Throws when the * remote exists with a different URL — that's a real conflict the caller * needs to resolve, not paper over. */ async add(cwd: string, name: string, url: string, signal?: AbortSignal): Promise { const result = await runCommand(cwd, ["remote", "add", name, url], { signal }); if (result.exitCode === 0) return; if (REMOTE_ALREADY_EXISTS.test(result.stderr)) { const existing = await remote.url(cwd, name, signal); if (existing === url) return; throw new ToolError(`remote ${name} already exists with URL ${existing ?? "(unset)"}, expected ${url}`); } throw new GitCommandError(["remote", "add", name, url], result); }, }; // ════════════════════════════════════════════════════════════════════════════ // API: ref // ════════════════════════════════════════════════════════════════════════════ export const ref = { /** Check if a ref exists. */ async exists(cwd: string, refName: string, signal?: AbortSignal): Promise { if (refName === "HEAD") return (await head.sha(cwd, signal)) !== null; const repository = await resolveRepository(cwd); if (repository && refName.startsWith("refs/")) return (await readRef(repository, refName)) !== null; const result = await runCommand(cwd, ["show-ref", "--verify", "--quiet", refName], { readOnly: true, signal }); return result.exitCode === 0; }, /** Resolve a ref to its commit SHA. */ async resolve(cwd: string, refName: string, signal?: AbortSignal): Promise { if (refName === "HEAD") return head.sha(cwd, signal); const repository = await resolveRepository(cwd); if (repository && refName.startsWith("refs/")) return readRef(repository, refName); const result = await runCommand(cwd, ["rev-parse", refName], { readOnly: true, signal }); if (result.exitCode !== 0) return null; return result.stdout.trim() || null; }, /** Tags pointing at a ref. */ async tags(cwd: string, refName = "HEAD", signal?: AbortSignal): Promise { return splitLines( await runText( cwd, [ "for-each-ref", "--points-at", refName, "--sort=-version:refname", "--format=%(refname:strip=2)", "refs/tags", ], { readOnly: true, signal }, ), ); }, }; // ════════════════════════════════════════════════════════════════════════════ // API: config // ════════════════════════════════════════════════════════════════════════════ export const config = { async get(cwd: string, key: string, signal?: AbortSignal): Promise { return trimScalar(await tryText(cwd, ["config", "--get", key], { readOnly: true, signal })); }, async set(cwd: string, key: string, value: string, signal?: AbortSignal): Promise { await runEffect(cwd, ["config", key, value], { signal }); }, async getBranch(cwd: string, branchName: string, key: string, signal?: AbortSignal): Promise { return config.get(cwd, `branch.${branchName}.${key}`, signal); }, async setBranch(cwd: string, branchName: string, key: string, value: string, signal?: AbortSignal): Promise { return config.set(cwd, `branch.${branchName}.${key}`, value, signal); }, }; // ════════════════════════════════════════════════════════════════════════════ // API: worktree // ════════════════════════════════════════════════════════════════════════════ export const worktree = { async add( cwd: string, worktreePath: string, refName: string, options: { detach?: boolean; signal?: AbortSignal } = {}, ): Promise { const args = ["worktree", "add"]; if (options.detach) args.push("--detach"); args.push(worktreePath, refName); await runEffect(cwd, args, { signal: options.signal }); }, async remove( cwd: string, worktreePath: string, options: { force?: boolean; signal?: AbortSignal } = {}, ): Promise { const args = ["worktree", "remove"]; if (options.force ?? true) args.push("-f"); args.push(worktreePath); await runEffect(cwd, args, { signal: options.signal }); }, async tryRemove( cwd: string, worktreePath: string, options: { force?: boolean; signal?: AbortSignal } = {}, ): Promise { const args = ["worktree", "remove"]; if (options.force ?? true) args.push("-f"); args.push(worktreePath); const result = await runCommand(cwd, args, { signal: options.signal }); return result.exitCode === 0; }, async list(cwd: string, signal?: AbortSignal): Promise { return parseWorktreeList(await runText(cwd, ["worktree", "list", "--porcelain"], { readOnly: true, signal })); }, async prune(cwd: string, signal?: AbortSignal): Promise { await runEffect(cwd, ["worktree", "prune"], { signal }); }, }; // ════════════════════════════════════════════════════════════════════════════ // API: patch // ════════════════════════════════════════════════════════════════════════════ export const patch = { /** Apply a patch file. */ async apply(cwd: string, patchPath: string, options: PatchOptions = {}): Promise { await runEffect(cwd, buildApplyArgs(patchPath, options), { env: options.env, signal: options.signal }); }, /** Apply a patch from a string (writes to a temp file). */ async applyText(cwd: string, patchText: string, options: PatchOptions = {}): Promise { if (!patchText.trim()) return; const tempPath = await writeTempPatch(patchText); try { await patch.apply(cwd, tempPath, options); } finally { await fs.promises.rm(tempPath, { force: true }); } }, /** Check if a patch file can be applied cleanly. */ async canApply(cwd: string, patchPath: string, options: Omit = {}): Promise { const result = await runCommand(cwd, buildApplyArgs(patchPath, { ...options, check: true }), { env: options.env, readOnly: true, signal: options.signal, }); return result.exitCode === 0; }, /** Check if a patch string can be applied cleanly. */ async canApplyText(cwd: string, patchText: string, options: Omit = {}): Promise { if (!patchText.trim()) return true; const tempPath = await writeTempPatch(patchText); try { return await patch.canApply(cwd, tempPath, options); } finally { await fs.promises.rm(tempPath, { force: true }); } }, /** Join patch parts into a single patch string. */ join(parts: string[]): string { return `${parts .map(part => (part.endsWith("\n") ? part : `${part}\n`)) .join("\n") .replace(/\n+$/, "")}\n`; }, }; // ════════════════════════════════════════════════════════════════════════════ // API: cherryPick // ════════════════════════════════════════════════════════════════════════════ export const cherryPick = Object.assign( async function cherryPick(cwd: string, revision: string, signal?: AbortSignal): Promise { await runEffect(cwd, ["cherry-pick", revision], { signal }); }, { async abort(cwd: string, signal?: AbortSignal): Promise { await runEffect(cwd, ["cherry-pick", "--abort"], { signal }); }, }, ); // ════════════════════════════════════════════════════════════════════════════ // API: stash // ════════════════════════════════════════════════════════════════════════════ export const stash = { /** Stash working tree + index changes. Returns true when git created a new stash entry. */ async push(cwd: string, message?: string): Promise { ensureAvailable(); const previousStash = await ref.resolve(cwd, "refs/stash"); const args = ["stash", "push", "--include-untracked"]; if (message) args.push("-m", message); await runEffect(cwd, args); const nextStash = await ref.resolve(cwd, "refs/stash"); return nextStash !== null && nextStash !== previousStash; }, /** Pop the most recent stash entry, optionally restoring its staged state. */ async pop(cwd: string, options?: { index?: boolean }): Promise { const args = ["stash", "pop"]; if (options?.index) args.push("--index"); await runEffect(cwd, args); }, }; // ════════════════════════════════════════════════════════════════════════════ // API: clone, restore, clean // ════════════════════════════════════════════════════════════════════════════ export async function clone(url: string, targetDir: string, options: CloneOptions = {}): Promise { ensureAvailable(); const absoluteTarget = path.resolve(targetDir); await fs.promises.mkdir(path.dirname(absoluteTarget), { recursive: true }); const args = ["clone", "--depth", "1"]; if (options.ref) args.push("--branch", options.ref, "--single-branch"); else args.push("--single-branch"); args.push(url, absoluteTarget); try { await runEffect(path.dirname(absoluteTarget), args, { signal: options.signal }); if (options.sha) { try { await checkout(absoluteTarget, options.sha, options.signal); } catch { await fs.promises.rm(absoluteTarget, { force: true, recursive: true }); throw new Error(`Failed to checkout SHA ${options.sha} - shallow clone may not contain this commit`); } } } catch (err) { await fs.promises.rm(absoluteTarget, { force: true, recursive: true }); throw err; } } export async function restore(cwd: string, options: RestoreOptions = {}): Promise { const args = ["restore"]; if (options.source) args.push(`--source=${options.source}`); if (options.staged) args.push("--staged"); if (options.worktree) args.push("--worktree"); if (options.files?.length) args.push("--", ...options.files); await runEffect(cwd, args, { signal: options.signal }); } /** * Run `git reset` with options. Default is a soft reset (no flag); pass `hard: true` for a destructive reset. * * NOTE: stage.reset() handles the per-file unstaging case. This helper exists for tree-wide resets. */ export async function reset( cwd: string, options: { hard?: boolean; mixed?: boolean; soft?: boolean; target?: string; signal?: AbortSignal } = {}, ): Promise { const args = ["reset"]; if (options.hard) args.push("--hard"); else if (options.mixed) args.push("--mixed"); else if (options.soft) args.push("--soft"); if (options.target) args.push(options.target); await runEffect(cwd, args, { signal: options.signal }); } export async function clean( cwd: string, options: { ignoredOnly?: boolean; paths?: readonly string[]; signal?: AbortSignal } = {}, ): Promise { const args = ["clean", options.ignoredOnly ? "-fdX" : "-fd"]; if (options.paths?.length) args.push("--", ...options.paths); await runEffect(cwd, args, { signal: options.signal }); } // ════════════════════════════════════════════════════════════════════════════ // API: ls // ════════════════════════════════════════════════════════════════════════════ export const ls = { /** List files tracked or untracked by git. */ async files( cwd: string, options: { others?: boolean; excludeStandard?: boolean; signal?: AbortSignal } = {}, ): Promise { const args = ["ls-files"]; if (options.others) args.push("--others"); if (options.excludeStandard) args.push("--exclude-standard"); return splitLines(await runText(cwd, args, { readOnly: true, signal: options.signal })); }, /** List untracked files (excludes ignored). */ async untracked(cwd: string, signal?: AbortSignal): Promise { return ls.files(cwd, { others: true, excludeStandard: true, signal }); }, /** List submodule paths (recursive). */ async submodules(cwd: string, signal?: AbortSignal): Promise { const output = await runCommand(cwd, ["submodule", "--quiet", "foreach", "--recursive", "echo $sm_path"], { readOnly: true, signal, }); return splitLines(output.stdout); }, }; // ════════════════════════════════════════════════════════════════════════════ // API: head // ════════════════════════════════════════════════════════════════════════════ export const head = { /** Full HEAD state (branch, commit, repo info). */ async resolve(cwd: string): Promise { const repository = await resolveRepository(cwd); if (!repository) return null; const content = await readOptionalText(repository.headPath); if (content === null) return null; return parseHeadState(repository, content); }, /** Full HEAD state (synchronous). */ resolveSync(cwd: string): GitHeadState | null { const repository = resolveRepositorySync(cwd); if (!repository) return null; const content = readOptionalTextSync(repository.headPath); if (content === null) return null; return parseHeadStateSync(repository, content); }, /** Current HEAD commit SHA. */ async sha(cwd: string, signal?: AbortSignal): Promise { const headState = await head.resolve(cwd); if (headState?.commit) return headState.commit; const result = await runCommand(cwd, ["rev-parse", "HEAD"], { readOnly: true, signal }); if (result.exitCode !== 0) return null; return result.stdout.trim() || null; }, /** Abbreviated HEAD commit SHA. */ async short(cwd: string, length = 7, signal?: AbortSignal): Promise { const result = await runCommand(cwd, ["rev-parse", `--short=${length}`, "HEAD"], { readOnly: true, signal }); if (result.exitCode !== 0) return null; return result.stdout.trim() || null; }, }; // ════════════════════════════════════════════════════════════════════════════ // API: repo // ════════════════════════════════════════════════════════════════════════════ export const repo = { /** Resolve the repository root (may be a worktree root). */ async root(cwd: string, signal?: AbortSignal): Promise { const repository = await resolveRepository(cwd); if (repository) return repository.repoRoot; const result = await runCommand(cwd, ["rev-parse", "--show-toplevel"], { readOnly: true, signal }); if (result.exitCode !== 0) return null; return result.stdout.trim() || null; }, /** Resolve the primary repository root (not a worktree — the main checkout). */ async primaryRoot(cwd: string, signal?: AbortSignal): Promise { const repository = await resolveRepository(cwd); if (repository) { if (path.basename(repository.commonDir) === ".git") return path.dirname(repository.commonDir); return repository.repoRoot; } const repoRoot = await repo.root(cwd, signal); if (!repoRoot) return null; const commonDir = await runText(repoRoot, ["rev-parse", "--path-format=absolute", "--git-common-dir"], { readOnly: true, signal, }); if (path.basename(commonDir.trim()) === ".git") return path.dirname(commonDir.trim()); return repoRoot; }, /** Full GitRepository metadata (sync). */ resolveSync(cwd: string): GitRepository | null { return resolveRepositorySync(cwd); }, /** Full GitRepository metadata. */ resolve(cwd: string): Promise { return resolveRepository(cwd); }, }; // Helper used during head resolution — defined here to reference `head` namespace. async function resolveHead(cwd: string): Promise { return head.resolve(cwd); } // ════════════════════════════════════════════════════════════════════════════ // API: github (GitHub CLI) // ════════════════════════════════════════════════════════════════════════════ export interface GhCommandResult { exitCode: number; stdout: string; stderr: string; } export interface GhCommandOptions { repoProvided?: boolean; trimOutput?: boolean; } function formatGhFailure(args: readonly string[], stdout: string, stderr: string, options?: GhCommandOptions): string { const message = (stderr || stdout).trim(); if (message.includes("gh auth login") || message.includes("not logged into any GitHub hosts")) { return "GitHub CLI is not authenticated. Run `gh auth login`."; } if ( !options?.repoProvided && (message.includes("not a git repository") || message.includes("no git remotes found") || message.includes("unable to determine current repository")) ) { return "GitHub repository context is unavailable. Pass `repo` explicitly or run the tool inside a GitHub checkout."; } if (message.length > 0) return message; return `GitHub CLI command failed: gh ${args.join(" ")}`; } export const github = { /** Check if `gh` CLI is installed. */ available(): boolean { return Boolean($which("gh")); }, /** Run a raw `gh` CLI command. Does not throw on non-zero exit. */ async run(cwd: string, args: string[], signal?: AbortSignal, options?: GhCommandOptions): Promise { throwIfAborted(signal); if (!$which("gh")) { throw new ToolError("GitHub CLI (gh) is not installed. Install it from https://cli.github.com/."); } try { const child = Bun.spawn(["gh", ...args], { cwd, stdin: "ignore", stdout: "pipe", stderr: "pipe", windowsHide: true, signal, }); if (!child.stdout || !child.stderr) { throw new ToolError("Failed to capture GitHub CLI output."); } const [stdout, stderr, exitCode] = await Promise.all([ new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited, ]); throwIfAborted(signal); const trim = options?.trimOutput !== false; return { exitCode: exitCode ?? 0, stdout: trim ? stdout.trim() : stdout, stderr: trim ? stderr.trim() : stderr, }; } catch (error) { if (signal?.aborted) throw new ToolAbortError(); throw error; } }, /** Run `gh` and parse stdout as JSON. Throws on non-zero exit or invalid JSON. */ async json(cwd: string, args: string[], signal?: AbortSignal, options?: GhCommandOptions): Promise { const result = await github.run(cwd, args, signal, options); if (result.exitCode !== 0) { throw new ToolError(formatGhFailure(args, result.stdout, result.stderr, options)); } if (!result.stdout) { throw new ToolError("GitHub CLI returned empty output."); } try { return JSON.parse(result.stdout) as T; } catch { throw new ToolError("GitHub CLI returned invalid JSON output."); } }, /** Run `gh` and return stdout as text. Throws on non-zero exit. */ async text(cwd: string, args: string[], signal?: AbortSignal, options?: GhCommandOptions): Promise { const result = await github.run(cwd, args, signal, options); if (result.exitCode !== 0) { throw new ToolError(formatGhFailure(args, result.stdout, result.stderr, options)); } return result.stdout; }, };