import fs from "node:fs";import os from "node:os";import path from "node:path"; import { gitPullInputSchema, gitPullResultSchema, gitStatusInputSchema, gitStatusResultSchema, type GitCheckoutInput, type GitCreateBranchInput, type GitCreateWorktreeInput, type GitCreateWorktreeResult, type GitInitInput, type GitListBranchesInput, type GitListBranchesResult, type GitPullInput, type GitPullResult, type GitRemoveWorktreeInput, type GitStatusInput, type GitStatusResult,} from "@t3tools/contracts";import { type ProcessRunResult, runProcess } from "./processRunner"; export interface TerminalCommandInput { command: string; cwd: string; timeoutMs?: number; maxOutputBytes?: number;} export type TerminalCommandResult = ProcessRunResult; export interface GitStatusDetails extends Omit<GitStatusResult, "openPr"> { upstreamRef: string | null;} export interface GitPreparedCommitContext { stagedSummary: string; stagedPatch: string;} 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;} interface RunGitOptions { timeoutMs?: number | undefined; allowNonZeroExit?: boolean | undefined;} interface ExecuteGitOptions { timeoutMs?: number | undefined; allowNonZeroExit?: boolean | undefined; fallbackErrorMessage?: string | undefined;} const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000;const STATUS_UPSTREAM_REFRESH_INTERVAL_MS = 15_000;const STATUS_UPSTREAM_REFRESH_TIMEOUT_MS = 5_000; /** Spawn git directly with an argv array — no shell, no quoting needed. */function runGit( args: readonly string[], cwd: string, timeoutMs = 30_000,): Promise<ProcessRunResult> { return runProcess("git", args, { cwd, timeoutMs, allowNonZeroExit: true, maxBufferBytes: DEFAULT_MAX_OUTPUT_BYTES, outputMode: "truncate", });} function trimStdout(value: string): string { return value.trim();} function parseBranchAb(value: string): { ahead: number; behind: number } { const match = value.match(/^\+(\d+)\s+-(\d+)$/); if (!match) return { ahead: 0, behind: 0 }; return { ahead: Number(match[1] ?? "0"), behind: Number(match[2] ?? "0"), };} function parseNumstatEntries( stdout: string,): Array<{ path: string; insertions: number; deletions: number }> { const entries: Array<{ path: string; insertions: number; deletions: number }> = []; for (const line of stdout.split(/\r?\n/g)) { if (line.trim().length === 0) continue; const [addedRaw, deletedRaw, ...pathParts] = line.split("\t"); const rawPath = pathParts.length > 1 ? (pathParts.at(-1) ?? "").trim() : pathParts.join("\t").trim(); if (rawPath.length === 0) continue; const added = Number.parseInt(addedRaw ?? "0", 10); const deleted = Number.parseInt(deletedRaw ?? "0", 10); const renameArrowIndex = rawPath.indexOf(" => "); const normalizedPath = renameArrowIndex >= 0 ? rawPath.slice(renameArrowIndex + " => ".length).trim() : rawPath; entries.push({ path: normalizedPath.length > 0 ? normalizedPath : rawPath, insertions: Number.isFinite(added) ? added : 0, deletions: Number.isFinite(deleted) ? deleted : 0, }); } return entries;} function parsePorcelainPath(line: string): string | null { if (line.startsWith("? ") || line.startsWith("! ")) { const simple = line.slice(2).trim(); return simple.length > 0 ? simple : null; } if (!(line.startsWith("1 ") || line.startsWith("2 ") || line.startsWith("u "))) { return null; } const tabIndex = line.indexOf("\t"); if (tabIndex >= 0) { const fromTab = line.slice(tabIndex + 1); const [path] = fromTab.split("\t"); return path?.trim().length ? path.trim() : null; } const parts = line.trim().split(/\s+/g); const path = parts.at(-1) ?? ""; return path.length > 0 ? path : null;} function commandLabel(args: readonly string[]): string { return `git ${args.join(" ")}`;} function normalizeGitSpawnError(cwd: string, error: unknown): Error { if (!(error instanceof Error)) { return new Error("Failed to run git command."); } if (!fs.existsSync(cwd)) { return new Error(`Working directory does not exist: ${cwd}`); } if (error.message.includes("Command not found: git")) { return new Error("Git is required but not available on PATH."); } return new Error(`Failed to run git command: ${error.message}`);} function normalizeGitExecutionError(args: readonly string[], result: TerminalCommandResult): Error { const stderr = result.stderr.trim(); if (stderr.toLowerCase().includes("not a git repository")) { return new Error("Current folder is not a git repository."); } if (result.timedOut) { return new Error(`${commandLabel(args)} timed out.`); } const detail = stderr.length > 0 ? stderr : `code=${result.code ?? "null"}, signal=${result.signal ?? "null"}`; return new Error(`${commandLabel(args)} failed: ${detail}`);} async function runGitOrThrow( cwd: string, args: readonly string[], options: RunGitOptions = {},): Promise<TerminalCommandResult> { let result: TerminalCommandResult; try { result = await runGit(args, cwd, options.timeoutMs); } catch (error) { throw normalizeGitSpawnError(cwd, error); } if (result.timedOut) { throw normalizeGitExecutionError(args, result); } if (!options.allowNonZeroExit && result.code !== 0) { throw normalizeGitExecutionError(args, result); } return result;} async function executeGit( cwd: string, args: readonly string[], options: ExecuteGitOptions = {},): Promise<TerminalCommandResult> { const result = await runGitOrThrow(cwd, args, { timeoutMs: options.timeoutMs, allowNonZeroExit: true, }); if (options.allowNonZeroExit || result.code === 0) { return result; } const stderr = result.stderr.trim(); if (stderr.length > 0) { throw new Error(stderr); } if (options.fallbackErrorMessage) { throw new Error(options.fallbackErrorMessage); } throw normalizeGitExecutionError(args, result);} export class GitCoreService { private readonly lastUpstreamRefreshAt = new Map<string, number>(); async status(raw: GitStatusInput): Promise<GitStatusResult> { const input = gitStatusInputSchema.parse(raw); const details = await this.statusDetails(input.cwd); return gitStatusResultSchema.parse({ branch: details.branch, hasWorkingTreeChanges: details.hasWorkingTreeChanges, workingTree: details.workingTree, hasUpstream: details.hasUpstream, aheadCount: details.aheadCount, behindCount: details.behindCount, openPr: null, }); } async statusDetails(cwd: string): Promise<GitStatusDetails> { await this.refreshStatusUpstreamIfStale(cwd).catch(() => undefined); const [statusStdout, unstagedNumstatStdout, stagedNumstatStdout] = await Promise.all([ this.gitStdout(cwd, ["status", "--porcelain=2", "--branch"]), this.gitStdout(cwd, ["diff", "--numstat"]), this.gitStdout(cwd, ["diff", "--cached", "--numstat"]), ]); let branch: string | null = null; let upstreamRef: string | null = null; let aheadCount = 0; let behindCount = 0; let hasWorkingTreeChanges = false; const changedFilesWithoutNumstat = new Set<string>(); for (const line of statusStdout.split(/\r?\n/g)) { if (line.startsWith("# branch.head ")) { const value = line.slice("# branch.head ".length).trim(); branch = value.startsWith("(") ? null : value; continue; } if (line.startsWith("# branch.upstream ")) { const value = line.slice("# branch.upstream ".length).trim(); upstreamRef = value.length > 0 ? value : null; continue; } if (line.startsWith("# branch.ab ")) { const value = line.slice("# branch.ab ".length).trim(); const parsed = parseBranchAb(value); aheadCount = parsed.ahead; behindCount = parsed.behind; continue; } if (line.trim().length > 0 && !line.startsWith("#")) { hasWorkingTreeChanges = true; const pathValue = parsePorcelainPath(line); if (pathValue) changedFilesWithoutNumstat.add(pathValue); } } const stagedEntries = parseNumstatEntries(stagedNumstatStdout); const unstagedEntries = parseNumstatEntries(unstagedNumstatStdout); const fileStatMap = new Map<string, { insertions: number; deletions: number }>(); for (const entry of [...stagedEntries, ...unstagedEntries]) { const existing = fileStatMap.get(entry.path) ?? { insertions: 0, deletions: 0 }; existing.insertions += entry.insertions; existing.deletions += entry.deletions; fileStatMap.set(entry.path, existing); } let insertions = 0; let deletions = 0; const files = Array.from(fileStatMap.entries()) .map(([path, stat]) => { insertions += stat.insertions; deletions += stat.deletions; return { path, insertions: stat.insertions, deletions: stat.deletions }; }) .toSorted((a, b) => a.path.localeCompare(b.path)); for (const filePath of changedFilesWithoutNumstat) { if (fileStatMap.has(filePath)) continue; files.push({ path: filePath, insertions: 0, deletions: 0 }); } files.sort((a, b) => a.path.localeCompare(b.path)); return { branch, upstreamRef, hasWorkingTreeChanges, workingTree: { files, insertions, deletions, }, hasUpstream: upstreamRef !== null, aheadCount, behindCount, }; } async prepareCommitContext(cwd: string): Promise<GitPreparedCommitContext | null> { await this.git(cwd, ["add", "-A"]); const stagedSummary = await this.gitStdout(cwd, ["diff", "--cached", "--name-status"]); if (trimStdout(stagedSummary).length === 0) { return null; } const stagedPatch = await this.gitStdout(cwd, ["diff", "--cached", "--patch", "--minimal"]); return { stagedSummary, stagedPatch, }; } async commit(cwd: string, subject: string, body: string): Promise<{ commitSha: string }> { const args = ["commit", "-m", subject]; const trimmedBody = body.trim(); if (trimmedBody.length > 0) { args.push("-m", trimmedBody); } await this.git(cwd, args); const commitSha = trimStdout(await this.gitStdout(cwd, ["rev-parse", "HEAD"])); return { commitSha }; } async pushCurrentBranch(cwd: string, fallbackBranch: string | null): Promise<GitPushResult> { const details = await this.statusDetails(cwd); const branch = details.branch ?? fallbackBranch; if (!branch) { throw new Error("Cannot push from detached HEAD."); } if (details.hasUpstream && details.aheadCount === 0 && details.behindCount === 0) { return { status: "skipped_up_to_date", branch, ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}), }; } if (!details.hasUpstream) { await this.git(cwd, ["push", "-u", "origin", branch]); return { status: "pushed", branch, upstreamBranch: `origin/${branch}`, setUpstream: true, }; } await this.git(cwd, ["push"]); return { status: "pushed", branch, ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}), setUpstream: false, }; } async pullCurrentBranch(cwd: string): Promise<GitPullResult> { const details = await this.statusDetails(cwd); const branch = details.branch; if (!branch) { throw new Error("Cannot pull from detached HEAD."); } if (!details.hasUpstream) { throw new Error("Current branch has no upstream configured. Push with upstream first."); } const beforeSha = trimStdout(await this.gitStdout(cwd, ["rev-parse", "HEAD"], true)); await executeGit(cwd, ["pull", "--ff-only"], { timeoutMs: 30_000, fallbackErrorMessage: "git pull failed", }); const afterSha = trimStdout(await this.gitStdout(cwd, ["rev-parse", "HEAD"], true)); const refreshed = await this.statusDetails(cwd); return gitPullResultSchema.parse({ status: beforeSha.length > 0 && beforeSha === afterSha ? "skipped_up_to_date" : "pulled", branch, ...(refreshed.upstreamRef ? { upstreamBranch: refreshed.upstreamRef } : {}), }); } async readRangeContext(cwd: string, baseBranch: string): Promise<GitRangeContext> { const range = `${baseBranch}..HEAD`; const [commitSummary, diffSummary, diffPatch] = await Promise.all([ this.gitStdout(cwd, ["log", "--oneline", range]), this.gitStdout(cwd, ["diff", "--stat", range]), this.gitStdout(cwd, ["diff", "--patch", "--minimal", range]), ]); return { commitSummary, diffSummary, diffPatch, }; } async readConfigValue(cwd: string, key: string): Promise<string | null> { const stdout = await this.gitStdout(cwd, ["config", "--get", key], true); const value = trimStdout(stdout); return value.length > 0 ? value : null; } private async readBranchRecency(cwd: string): Promise<Map<string, number>> { const branchRecency = await executeGit( cwd, ["for-each-ref", "--format=%(refname:short)%09%(committerdate:unix)", "refs/heads"], { timeoutMs: 15_000, allowNonZeroExit: true, }, ); const branchLastCommit = new Map<string, number>(); if (branchRecency.code !== 0) { return branchLastCommit; } for (const line of branchRecency.stdout.split("\n")) { if (line.length === 0) { continue; } const [name, lastCommitRaw] = line.split("\t"); if (!name) { continue; } const lastCommit = Number.parseInt(lastCommitRaw ?? "0", 10); branchLastCommit.set(name, Number.isFinite(lastCommit) ? lastCommit : 0); } return branchLastCommit; } async listBranches(input: GitListBranchesInput): Promise<GitListBranchesResult> { const branchRecencyPromise = this.readBranchRecency(input.cwd).catch( () => new Map<string, number>(), ); const result = await executeGit(input.cwd, ["branch", "--no-color"], { timeoutMs: 10_000, allowNonZeroExit: true, }); if (result.code !== 0) { const stderr = result.stderr.trim(); if (stderr.toLowerCase().includes("not a git repository")) { return { branches: [], isRepo: false }; } throw new Error(stderr || "git branch failed"); } const [defaultRef, worktreeList, branchLastCommit] = await Promise.all([ executeGit(input.cwd, ["symbolic-ref", "refs/remotes/origin/HEAD"], { timeoutMs: 5_000, allowNonZeroExit: true, }), executeGit(input.cwd, ["worktree", "list", "--porcelain"], { timeoutMs: 5_000, allowNonZeroExit: true, }), branchRecencyPromise, ]); const defaultBranch = defaultRef.code === 0 ? defaultRef.stdout.trim().replace(/^refs\/remotes\/origin\//, "") : null; const worktreeMap = new Map<string, string>(); if (worktreeList.code === 0) { let currentPath: string | null = null; for (const line of worktreeList.stdout.split("\n")) { if (line.startsWith("worktree ")) { const candidatePath = line.slice("worktree ".length); currentPath = fs.existsSync(candidatePath) ? candidatePath : null; } else if (line.startsWith("branch refs/heads/") && currentPath) { worktreeMap.set(line.slice("branch refs/heads/".length), currentPath); } else if (line === "") { currentPath = null; } } } Lines 1–500 of 740. Load more
import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { gitPullInputSchema, gitPullResultSchema, gitStatusInputSchema, gitStatusResultSchema, type GitCheckoutInput, type GitCreateBranchInput, type GitCreateWorktreeInput, type GitCreateWorktreeResult, type GitInitInput, type GitListBranchesInput, type GitListBranchesResult, type GitPullInput, type GitPullResult, type GitRemoveWorktreeInput, type GitStatusInput, type GitStatusResult, } from "@t3tools/contracts"; import { type ProcessRunResult, runProcess } from "./processRunner"; export interface TerminalCommandInput { command: string; cwd: string; timeoutMs?: number; maxOutputBytes?: number; } export type TerminalCommandResult = ProcessRunResult; export interface GitStatusDetails extends Omit<GitStatusResult, "openPr"> { upstreamRef: string | null; } export interface GitPreparedCommitContext { stagedSummary: string; stagedPatch: string; } 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; } interface RunGitOptions { timeoutMs?: number | undefined; allowNonZeroExit?: boolean | undefined; } interface ExecuteGitOptions { timeoutMs?: number | undefined; allowNonZeroExit?: boolean | undefined; fallbackErrorMessage?: string | undefined; } const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; const STATUS_UPSTREAM_REFRESH_INTERVAL_MS = 15_000; const STATUS_UPSTREAM_REFRESH_TIMEOUT_MS = 5_000; /** Spawn git directly with an argv array — no shell, no quoting needed. */ function runGit( args: readonly string[], cwd: string, timeoutMs = 30_000, ): Promise<ProcessRunResult> { return runProcess("git", args, { cwd, timeoutMs, allowNonZeroExit: true, maxBufferBytes: DEFAULT_MAX_OUTPUT_BYTES, outputMode: "truncate", }); } function trimStdout(value: string): string { return value.trim(); } function parseBranchAb(value: string): { ahead: number; behind: number } { const match = value.match(/^\+(\d+)\s+-(\d+)$/); if (!match) return { ahead: 0, behind: 0 }; return { ahead: Number(match[1] ?? "0"), behind: Number(match[2] ?? "0"), }; } function parseNumstatEntries( stdout: string, ): Array<{ path: string; insertions: number; deletions: number }> { const entries: Array<{ path: string; insertions: number; deletions: number }> = []; for (const line of stdout.split(/\r?\n/g)) { if (line.trim().length === 0) continue; const [addedRaw, deletedRaw, ...pathParts] = line.split("\t"); const rawPath = pathParts.length > 1 ? (pathParts.at(-1) ?? "").trim() : pathParts.join("\t").trim(); if (rawPath.length === 0) continue; const added = Number.parseInt(addedRaw ?? "0", 10); const deleted = Number.parseInt(deletedRaw ?? "0", 10); const renameArrowIndex = rawPath.indexOf(" => "); const normalizedPath = renameArrowIndex >= 0 ? rawPath.slice(renameArrowIndex + " => ".length).trim() : rawPath; entries.push({ path: normalizedPath.length > 0 ? normalizedPath : rawPath, insertions: Number.isFinite(added) ? added : 0, deletions: Number.isFinite(deleted) ? deleted : 0, }); } return entries; } function parsePorcelainPath(line: string): string | null { if (line.startsWith("? ") || line.startsWith("! ")) { const simple = line.slice(2).trim(); return simple.length > 0 ? simple : null; } if (!(line.startsWith("1 ") || line.startsWith("2 ") || line.startsWith("u "))) { return null; } const tabIndex = line.indexOf("\t"); if (tabIndex >= 0) { const fromTab = line.slice(tabIndex + 1); const [path] = fromTab.split("\t"); return path?.trim().length ? path.trim() : null; } const parts = line.trim().split(/\s+/g); const path = parts.at(-1) ?? ""; return path.length > 0 ? path : null; } function commandLabel(args: readonly string[]): string { return `git ${args.join(" ")}`; } function normalizeGitSpawnError(cwd: string, error: unknown): Error { if (!(error instanceof Error)) { return new Error("Failed to run git command."); } if (!fs.existsSync(cwd)) { return new Error(`Working directory does not exist: ${cwd}`); } if (error.message.includes("Command not found: git")) { return new Error("Git is required but not available on PATH."); } return new Error(`Failed to run git command: ${error.message}`); } function normalizeGitExecutionError(args: readonly string[], result: TerminalCommandResult): Error { const stderr = result.stderr.trim(); if (stderr.toLowerCase().includes("not a git repository")) { return new Error("Current folder is not a git repository."); } if (result.timedOut) { return new Error(`${commandLabel(args)} timed out.`); } const detail = stderr.length > 0 ? stderr : `code=${result.code ?? "null"}, signal=${result.signal ?? "null"}`; return new Error(`${commandLabel(args)} failed: ${detail}`); } async function runGitOrThrow( cwd: string, args: readonly string[], options: RunGitOptions = {}, ): Promise<TerminalCommandResult> { let result: TerminalCommandResult; try { result = await runGit(args, cwd, options.timeoutMs); } catch (error) { throw normalizeGitSpawnError(cwd, error); } if (result.timedOut) { throw normalizeGitExecutionError(args, result); } if (!options.allowNonZeroExit && result.code !== 0) { throw normalizeGitExecutionError(args, result); } return result; } async function executeGit( cwd: string, args: readonly string[], options: ExecuteGitOptions = {}, ): Promise<TerminalCommandResult> { const result = await runGitOrThrow(cwd, args, { timeoutMs: options.timeoutMs, allowNonZeroExit: true, }); if (options.allowNonZeroExit || result.code === 0) { return result; } const stderr = result.stderr.trim(); if (stderr.length > 0) { throw new Error(stderr); } if (options.fallbackErrorMessage) { throw new Error(options.fallbackErrorMessage); } throw normalizeGitExecutionError(args, result); } export class GitCoreService { private readonly lastUpstreamRefreshAt = new Map<string, number>(); async status(raw: GitStatusInput): Promise<GitStatusResult> { const input = gitStatusInputSchema.parse(raw); const details = await this.statusDetails(input.cwd); return gitStatusResultSchema.parse({ branch: details.branch, hasWorkingTreeChanges: details.hasWorkingTreeChanges, workingTree: details.workingTree, hasUpstream: details.hasUpstream, aheadCount: details.aheadCount, behindCount: details.behindCount, openPr: null, }); } async statusDetails(cwd: string): Promise<GitStatusDetails> { await this.refreshStatusUpstreamIfStale(cwd).catch(() => undefined); const [statusStdout, unstagedNumstatStdout, stagedNumstatStdout] = await Promise.all([ this.gitStdout(cwd, ["status", "--porcelain=2", "--branch"]), this.gitStdout(cwd, ["diff", "--numstat"]), this.gitStdout(cwd, ["diff", "--cached", "--numstat"]), ]); let branch: string | null = null; let upstreamRef: string | null = null; let aheadCount = 0; let behindCount = 0; let hasWorkingTreeChanges = false; const changedFilesWithoutNumstat = new Set<string>(); for (const line of statusStdout.split(/\r?\n/g)) { if (line.startsWith("# branch.head ")) { const value = line.slice("# branch.head ".length).trim(); branch = value.startsWith("(") ? null : value; continue; } if (line.startsWith("# branch.upstream ")) { const value = line.slice("# branch.upstream ".length).trim(); upstreamRef = value.length > 0 ? value : null; continue; } if (line.startsWith("# branch.ab ")) { const value = line.slice("# branch.ab ".length).trim(); const parsed = parseBranchAb(value); aheadCount = parsed.ahead; behindCount = parsed.behind; continue; } if (line.trim().length > 0 && !line.startsWith("#")) { hasWorkingTreeChanges = true; const pathValue = parsePorcelainPath(line); if (pathValue) changedFilesWithoutNumstat.add(pathValue); } } const stagedEntries = parseNumstatEntries(stagedNumstatStdout); const unstagedEntries = parseNumstatEntries(unstagedNumstatStdout); const fileStatMap = new Map<string, { insertions: number; deletions: number }>(); for (const entry of [...stagedEntries, ...unstagedEntries]) { const existing = fileStatMap.get(entry.path) ?? { insertions: 0, deletions: 0 }; existing.insertions += entry.insertions; existing.deletions += entry.deletions; fileStatMap.set(entry.path, existing); } let insertions = 0; let deletions = 0; const files = Array.from(fileStatMap.entries()) .map(([path, stat]) => { insertions += stat.insertions; deletions += stat.deletions; return { path, insertions: stat.insertions, deletions: stat.deletions }; }) .toSorted((a, b) => a.path.localeCompare(b.path)); for (const filePath of changedFilesWithoutNumstat) { if (fileStatMap.has(filePath)) continue; files.push({ path: filePath, insertions: 0, deletions: 0 }); } files.sort((a, b) => a.path.localeCompare(b.path)); return { branch, upstreamRef, hasWorkingTreeChanges, workingTree: { files, insertions, deletions, }, hasUpstream: upstreamRef !== null, aheadCount, behindCount, }; } async prepareCommitContext(cwd: string): Promise<GitPreparedCommitContext | null> { await this.git(cwd, ["add", "-A"]); const stagedSummary = await this.gitStdout(cwd, ["diff", "--cached", "--name-status"]); if (trimStdout(stagedSummary).length === 0) { return null; } const stagedPatch = await this.gitStdout(cwd, ["diff", "--cached", "--patch", "--minimal"]); return { stagedSummary, stagedPatch, }; } async commit(cwd: string, subject: string, body: string): Promise<{ commitSha: string }> { const args = ["commit", "-m", subject]; const trimmedBody = body.trim(); if (trimmedBody.length > 0) { args.push("-m", trimmedBody); } await this.git(cwd, args); const commitSha = trimStdout(await this.gitStdout(cwd, ["rev-parse", "HEAD"])); return { commitSha }; } async pushCurrentBranch(cwd: string, fallbackBranch: string | null): Promise<GitPushResult> { const details = await this.statusDetails(cwd); const branch = details.branch ?? fallbackBranch; if (!branch) { throw new Error("Cannot push from detached HEAD."); } if (details.hasUpstream && details.aheadCount === 0 && details.behindCount === 0) { return { status: "skipped_up_to_date", branch, ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}), }; } if (!details.hasUpstream) { await this.git(cwd, ["push", "-u", "origin", branch]); return { status: "pushed", branch, upstreamBranch: `origin/${branch}`, setUpstream: true, }; } await this.git(cwd, ["push"]); return { status: "pushed", branch, ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}), setUpstream: false, }; } async pullCurrentBranch(cwd: string): Promise<GitPullResult> { const details = await this.statusDetails(cwd); const branch = details.branch; if (!branch) { throw new Error("Cannot pull from detached HEAD."); } if (!details.hasUpstream) { throw new Error("Current branch has no upstream configured. Push with upstream first."); } const beforeSha = trimStdout(await this.gitStdout(cwd, ["rev-parse", "HEAD"], true)); await executeGit(cwd, ["pull", "--ff-only"], { timeoutMs: 30_000, fallbackErrorMessage: "git pull failed", }); const afterSha = trimStdout(await this.gitStdout(cwd, ["rev-parse", "HEAD"], true)); const refreshed = await this.statusDetails(cwd); return gitPullResultSchema.parse({ status: beforeSha.length > 0 && beforeSha === afterSha ? "skipped_up_to_date" : "pulled", branch, ...(refreshed.upstreamRef ? { upstreamBranch: refreshed.upstreamRef } : {}), }); } async readRangeContext(cwd: string, baseBranch: string): Promise<GitRangeContext> { const range = `${baseBranch}..HEAD`; const [commitSummary, diffSummary, diffPatch] = await Promise.all([ this.gitStdout(cwd, ["log", "--oneline", range]), this.gitStdout(cwd, ["diff", "--stat", range]), this.gitStdout(cwd, ["diff", "--patch", "--minimal", range]), ]); return { commitSummary, diffSummary, diffPatch, }; } async readConfigValue(cwd: string, key: string): Promise<string | null> { const stdout = await this.gitStdout(cwd, ["config", "--get", key], true); const value = trimStdout(stdout); return value.length > 0 ? value : null; } private async readBranchRecency(cwd: string): Promise<Map<string, number>> { const branchRecency = await executeGit( cwd, ["for-each-ref", "--format=%(refname:short)%09%(committerdate:unix)", "refs/heads"], { timeoutMs: 15_000, allowNonZeroExit: true, }, ); const branchLastCommit = new Map<string, number>(); if (branchRecency.code !== 0) { return branchLastCommit; } for (const line of branchRecency.stdout.split("\n")) { if (line.length === 0) { continue; } const [name, lastCommitRaw] = line.split("\t"); if (!name) { continue; } const lastCommit = Number.parseInt(lastCommitRaw ?? "0", 10); branchLastCommit.set(name, Number.isFinite(lastCommit) ? lastCommit : 0); } return branchLastCommit; } async listBranches(input: GitListBranchesInput): Promise<GitListBranchesResult> { const branchRecencyPromise = this.readBranchRecency(input.cwd).catch( () => new Map<string, number>(), ); const result = await executeGit(input.cwd, ["branch", "--no-color"], { timeoutMs: 10_000, allowNonZeroExit: true, }); if (result.code !== 0) { const stderr = result.stderr.trim(); if (stderr.toLowerCase().includes("not a git repository")) { return { branches: [], isRepo: false }; } throw new Error(stderr || "git branch failed"); } const [defaultRef, worktreeList, branchLastCommit] = await Promise.all([ executeGit(input.cwd, ["symbolic-ref", "refs/remotes/origin/HEAD"], { timeoutMs: 5_000, allowNonZeroExit: true, }), executeGit(input.cwd, ["worktree", "list", "--porcelain"], { timeoutMs: 5_000, allowNonZeroExit: true, }), branchRecencyPromise, ]); const defaultBranch = defaultRef.code === 0 ? defaultRef.stdout.trim().replace(/^refs\/remotes\/origin\//, "") : null; const worktreeMap = new Map<string, string>(); if (worktreeList.code === 0) { let currentPath: string | null = null; for (const line of worktreeList.stdout.split("\n")) { if (line.startsWith("worktree ")) { const candidatePath = line.slice("worktree ".length); currentPath = fs.existsSync(candidatePath) ? candidatePath : null; } else if (line.startsWith("branch refs/heads/") && currentPath) { worktreeMap.set(line.slice("branch refs/heads/".length), currentPath); } else if (line === "") { currentPath = null; } } }