From 418b6801a096825e4cf576f747254b993a6386a4 Mon Sep 17 00:00:00 2001 From: can1357 Date: Thu, 2 Apr 2026 07:35:44 +0200 Subject: [PATCH] refactor(coding-agent): migrated git operations to centralized utils module - Extracted git operations from ControlledGit class into centralized utils/git module with 1276 lines of typed command wrappers. - Replaced dependency injection of ControlledGit instances with direct cwd string parameters across commit agent tools and workflows. - Migrated all git command execution from inline shell calls and custom helpers to structured git module API (diff, status, branch, worktree, patch, etc.). - Removed ControlledGit class, operations.ts, and helper functions (findGitHeadPath, mergeStdoutStderr, joinPatch) now provided by git module. - Exported git utilities from main package entry point for extension and plugin use. --- packages/coding-agent/CHANGELOG.md | 24 + packages/coding-agent/package.json | 4 - packages/coding-agent/src/autoresearch/git.ts | 55 +- .../src/autoresearch/tools/log-experiment.ts | 151 +- .../coding-agent/src/commit/agentic/agent.ts | 3 - .../coding-agent/src/commit/agentic/index.ts | 41 +- .../src/commit/agentic/tools/git-file-diff.ts | 9 +- .../src/commit/agentic/tools/git-hunk.ts | 6 +- .../src/commit/agentic/tools/git-overview.ts | 15 +- .../src/commit/agentic/tools/index.ts | 14 +- .../commit/agentic/tools/propose-commit.ts | 11 +- .../commit/agentic/tools/recent-commits.ts | 6 +- .../src/commit/agentic/tools/split-commit.ts | 8 +- .../src/commit/changelog/index.ts | 14 +- .../coding-agent/src/commit/git/errors.ts | 9 - packages/coding-agent/src/commit/git/index.ts | 210 --- .../coding-agent/src/commit/git/operations.ts | 54 - packages/coding-agent/src/commit/pipeline.ts | 22 +- .../custom-commands/bundled/ci-green/index.ts | 20 +- .../custom-commands/bundled/review/index.ts | 84 +- .../plugins/marketplace/fetcher.ts | 59 +- .../plugins/marketplace/source-resolver.ts | 8 +- packages/coding-agent/src/index.ts | 1 + .../src/modes/components/footer.ts | 38 +- .../src/modes/components/status-line.ts | 76 +- packages/coding-agent/src/modes/shared.ts | 42 - packages/coding-agent/src/task/index.ts | 25 +- packages/coding-agent/src/task/worktree.ts | 218 ++- packages/coding-agent/src/tools/gh.ts | 346 +---- packages/coding-agent/src/utils/git.ts | 1276 +++++++++++++++++ .../test/commit-agentic-attribution.test.ts | 1 - packages/coding-agent/test/join-patch.test.ts | 8 +- 32 files changed, 1705 insertions(+), 1153 deletions(-) delete mode 100644 packages/coding-agent/src/commit/git/errors.ts delete mode 100644 packages/coding-agent/src/commit/git/index.ts delete mode 100644 packages/coding-agent/src/commit/git/operations.ts create mode 100644 packages/coding-agent/src/utils/git.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 742d87c12..19756051b 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,6 +1,30 @@ # Changelog ## [Unreleased] +### Added + +- Exported git utilities from main package entry point for use by extensions +- Added comprehensive git utility module (`utils/git`) with organized namespaces for common git operations (branch, commit, diff, log, patch, ref, stage, status, head, repository) + +### Changed + +- Refactored git operations to use centralized utility module instead of `ControlledGit` class throughout codebase +- Replaced `ControlledGit` dependency injection pattern with direct `cwd` parameter in commit agent tools +- Migrated git HEAD resolution in footer and status-line components to use new synchronous and asynchronous utilities +- Updated git status summary calculation in status-line component to use new git utility API +- Simplified git branch operations in task execution and cleanup to use new utility functions +- Refactored patch application logic in task worktree to use new git patch utilities + +### Removed + +- Removed `ControlledGit` class and associated git wrapper infrastructure from `commit/git` module +- Removed `mergeStdoutStderr` helper function from autoresearch git utilities +- Removed `findGitHeadPathAsync` and `findGitHeadPathSync` from modes/shared module (replaced by git utilities) +- Removed `./commit/git` export from package.json (internal diff parsing still available via `./commit/git/*`) + +### Fixed + +- Fixed `read` output for file-backed internal URLs like `local://...` to include hashline prefixes in hashline edit mode, preserving usable line refs for follow-up edits ## [13.18.0] - 2026-04-02 ### Breaking Changes diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index 064fcaab4..8d3b87965 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -146,10 +146,6 @@ "types": "./src/commit/changelog/*.ts", "import": "./src/commit/changelog/*.ts" }, - "./commit/git": { - "types": "./src/commit/git/index.ts", - "import": "./src/commit/git/index.ts" - }, "./commit/git/*": { "types": "./src/commit/git/*.ts", "import": "./src/commit/git/*.ts" diff --git a/packages/coding-agent/src/autoresearch/git.ts b/packages/coding-agent/src/autoresearch/git.ts index e22ea4976..8defea105 100644 --- a/packages/coding-agent/src/autoresearch/git.ts +++ b/packages/coding-agent/src/autoresearch/git.ts @@ -1,4 +1,5 @@ import type { ExtensionAPI } from "../extensibility/extensions"; +import * as git from "../utils/git"; import { isAutoresearchLocalStatePath, normalizeAutoresearchPath } from "./helpers"; const AUTORESEARCH_BRANCH_PREFIX = "autoresearch/"; @@ -17,9 +18,8 @@ export interface EnsureAutoresearchBranchSuccess { export type EnsureAutoresearchBranchResult = EnsureAutoresearchBranchFailure | EnsureAutoresearchBranchSuccess; -export async function getCurrentAutoresearchBranch(api: ExtensionAPI, workDir: string): Promise { - const currentBranchResult = await api.exec("git", ["branch", "--show-current"], { cwd: workDir, timeout: 5_000 }); - const currentBranch = currentBranchResult.stdout.trim(); +export async function getCurrentAutoresearchBranch(_api: ExtensionAPI, workDir: string): Promise { + const currentBranch = (await git.branch.current(workDir)) ?? ""; return currentBranch.startsWith(AUTORESEARCH_BRANCH_PREFIX) ? currentBranch : null; } @@ -28,28 +28,30 @@ export async function ensureAutoresearchBranch( workDir: string, goal: string | null, ): Promise { - const repoRootResult = await api.exec("git", ["rev-parse", "--show-toplevel"], { cwd: workDir, timeout: 5_000 }); - if (repoRootResult.code !== 0) { + const repoRoot = await git.repo.root(workDir); + if (!repoRoot) { return { error: "Autoresearch requires a git repository so it can isolate experiments and revert failed runs safely.", ok: false, }; } - const repoRoot = repoRootResult.stdout.trim() || workDir; - const dirtyPathsResult = await api.exec("git", ["status", "--porcelain=v1", "-z", "--untracked-files=all"], { - cwd: repoRoot, - timeout: 5_000, - }); - if (dirtyPathsResult.code !== 0) { + let dirtyPathsOutput: string; + try { + dirtyPathsOutput = await git.status(repoRoot, { + porcelainV1: true, + untrackedFiles: "all", + z: true, + }); + } catch (err) { return { - error: `Unable to inspect git status before starting autoresearch: ${mergeStdoutStderr(dirtyPathsResult).trim() || `exit ${dirtyPathsResult.code}`}`, + error: `Unable to inspect git status before starting autoresearch: ${err instanceof Error ? err.message : String(err)}`, ok: false, }; } const workDirPrefix = await readGitWorkDirPrefix(api, workDir); - const unsafeDirtyPaths = collectUnsafeDirtyPaths(dirtyPathsResult.stdout, workDirPrefix); + const unsafeDirtyPaths = collectUnsafeDirtyPaths(dirtyPathsOutput, workDirPrefix); const currentBranch = await getCurrentAutoresearchBranch(api, workDir); if (currentBranch) { if (unsafeDirtyPaths.length > 0) { @@ -66,12 +68,11 @@ export async function ensureAutoresearchBranch( } const branchName = await allocateBranchName(api, workDir, goal); - const checkoutResult = await api.exec("git", ["checkout", "-b", branchName], { cwd: workDir, timeout: 10_000 }); - if (checkoutResult.code !== 0) { + try { + await git.branch.checkoutNew(workDir, branchName); + } catch (err) { return { - error: - `Failed to create autoresearch branch ${branchName}: ` + - `${mergeStdoutStderr(checkoutResult).trim() || `exit ${checkoutResult.code}`}`, + error: `Failed to create autoresearch branch ${branchName}: ${err instanceof Error ? err.message : String(err)}`, ok: false, }; } @@ -109,11 +110,12 @@ export function relativizeGitPathToWorkDir(repoRelativePath: string, workDirPref } async function readGitWorkDirPrefix(api: ExtensionAPI, workDir: string): Promise { - const prefixResult = await api.exec("git", ["rev-parse", "--show-prefix"], { cwd: workDir, timeout: 5_000 }); - if (prefixResult.code !== 0) { + void api; + try { + return await git.show.prefix(workDir); + } catch { return ""; } - return prefixResult.stdout.trim(); } export function parseDirtyPaths(statusOutput: string): string[] { @@ -180,11 +182,8 @@ async function allocateBranchName(api: ExtensionAPI, workDir: string, goal: stri } async function branchExists(api: ExtensionAPI, workDir: string, branchName: string): Promise { - const result = await api.exec("git", ["show-ref", "--verify", "--quiet", `refs/heads/${branchName}`], { - cwd: workDir, - timeout: 5_000, - }); - return result.code === 0; + void api; + return git.ref.exists(workDir, `refs/heads/${branchName}`); } function slugifyGoal(goal: string | null): string { @@ -204,10 +203,6 @@ function currentDateStamp(): string { return `${year}${month}${day}`; } -function mergeStdoutStderr(result: { stderr: string; stdout: string }): string { - return `${result.stdout}${result.stderr}`; -} - function addDirtyPath(paths: Set, rawPath: string): void { const normalizedPath = normalizeStatusPath(rawPath); if (normalizedPath.length === 0) return; diff --git a/packages/coding-agent/src/autoresearch/tools/log-experiment.ts b/packages/coding-agent/src/autoresearch/tools/log-experiment.ts index ec9f6caee..08a860fcc 100644 --- a/packages/coding-agent/src/autoresearch/tools/log-experiment.ts +++ b/packages/coding-agent/src/autoresearch/tools/log-experiment.ts @@ -7,6 +7,7 @@ import { Type } from "@sinclair/typebox"; import type { ToolDefinition } from "../../extensibility/extensions"; import type { Theme } from "../../modes/theme/theme"; import { replaceTabs, truncateToWidth } from "../../tools/render-utils"; +import * as git from "../../utils/git"; import { getAutoresearchFingerprintMismatchError, pathMatchesContractPath } from "../contract"; import { getCurrentAutoresearchBranch, parseWorkDirDirtyPaths } from "../git"; import { @@ -493,7 +494,7 @@ function validateObservedStatus( } async function commitKeptExperiment( - options: AutoresearchToolFactoryOptions, + _options: AutoresearchToolFactoryOptions, workDir: string, state: ExperimentState, experiment: ExperimentResult, @@ -503,25 +504,15 @@ async function commitKeptExperiment( return { note: "nothing to commit" }; } - const addResult = await options.pi.exec("git", ["add", "--all", "--", ...scopeValidation.committablePaths], { - cwd: workDir, - timeout: 10_000, - }); - if (addResult.code !== 0) { + try { + await git.stage.files(workDir, scopeValidation.committablePaths); + } catch (err) { return { - error: `git add failed: ${mergeStdoutStderr(addResult).trim() || `exit ${addResult.code}`}`, + error: `git add failed: ${err instanceof Error ? err.message : String(err)}`, }; } - const diffResult = await options.pi.exec( - "git", - ["diff", "--cached", "--quiet", "--", ...scopeValidation.committablePaths], - { - cwd: workDir, - timeout: 10_000, - }, - ); - if (diffResult.code === 0) { + if (!(await git.diff.has(workDir, { cached: true, files: scopeValidation.committablePaths }))) { return { note: "nothing to commit" }; } @@ -533,32 +524,23 @@ async function commitKeptExperiment( payload[name] = value; } const commitMessage = `${experiment.description}\n\nResult: ${JSON.stringify(payload)}`; - const commitResult = await options.pi.exec( - "git", - ["commit", "-m", commitMessage, "--", ...scopeValidation.committablePaths], - { - cwd: workDir, - timeout: 10_000, - }, - ); - if (commitResult.code !== 0) { + let commitResultText = ""; + try { + const commitResult = await git.commit(workDir, commitMessage, { + files: scopeValidation.committablePaths, + }); + commitResultText = mergeStdoutStderr(commitResult); + } catch (err) { return { - error: `git commit failed: ${mergeStdoutStderr(commitResult).trim() || `exit ${commitResult.code}`}`, + error: `git commit failed: ${err instanceof Error ? err.message : String(err)}`, }; } - const revParseResult = await options.pi.exec("git", ["rev-parse", "--short=7", "HEAD"], { - cwd: workDir, - timeout: 5_000, - }); - const newCommit = revParseResult.stdout.trim(); + const newCommit = (await git.head.short(workDir, 7)) ?? ""; if (newCommit.length >= 7) { experiment.commit = newCommit; } - const summaryLine = - mergeStdoutStderr(commitResult) - .split("\n") - .find(line => line.trim().length > 0) ?? "committed"; + const summaryLine = commitResultText.split("\n").find(line => line.trim().length > 0) ?? "committed"; return { note: summaryLine.trim() }; } @@ -567,44 +549,46 @@ async function revertFailedExperiment( workDir: string, ): Promise { const preservedFiles = preserveAutoresearchFiles(workDir); - const restoreResult = await options.pi.exec( - "git", - ["restore", "--source=HEAD", "--staged", "--worktree", "--", "."], - { cwd: workDir, timeout: 10_000 }, - ); - const cleanResult = await options.pi.exec("git", ["clean", "-fd", "--", "."], { cwd: workDir, timeout: 10_000 }); - const cleanIgnoredResult = await options.pi.exec("git", ["clean", "-fdX", "--", "."], { - cwd: workDir, - timeout: 10_000, - }); + try { + await git.restore(workDir, { files: ["."], source: "HEAD", staged: true, worktree: true }); + } catch (err) { + restoreAutoresearchFiles(preservedFiles); + return { + error: `git restore failed: ${err instanceof Error ? err.message : String(err)}`, + }; + } + try { + await git.clean(workDir, { paths: ["."] }); + } catch (err) { + restoreAutoresearchFiles(preservedFiles); + return { + error: `git clean failed: ${err instanceof Error ? err.message : String(err)}`, + }; + } + try { + await git.clean(workDir, { ignoredOnly: true, paths: ["."] }); + } catch (err) { + restoreAutoresearchFiles(preservedFiles); + return { + error: `git clean -X failed: ${err instanceof Error ? err.message : String(err)}`, + }; + } restoreAutoresearchFiles(preservedFiles); - if (restoreResult.code !== 0) { + let dirtyStatus = ""; + try { + dirtyStatus = await git.status(workDir, { + pathspecs: ["."], + porcelainV1: true, + untrackedFiles: "all", + z: true, + }); + } catch (err) { return { - error: `git restore failed: ${mergeStdoutStderr(restoreResult).trim() || `exit ${restoreResult.code}`}`, - }; - } - if (cleanResult.code !== 0) { - return { - error: `git clean failed: ${mergeStdoutStderr(cleanResult).trim() || `exit ${cleanResult.code}`}`, - }; - } - if (cleanIgnoredResult.code !== 0) { - return { - error: `git clean -X failed: ${mergeStdoutStderr(cleanIgnoredResult).trim() || `exit ${cleanIgnoredResult.code}`}`, - }; - } - const dirtyCheckResult = await options.pi.exec( - "git", - ["status", "--porcelain=v1", "-z", "--untracked-files=all", "--", "."], - { cwd: workDir, timeout: 10_000 }, - ); - if (dirtyCheckResult.code !== 0) { - return { - error: `git status failed after cleanup: ${mergeStdoutStderr(dirtyCheckResult).trim() || `exit ${dirtyCheckResult.code}`}`, + error: `git status failed after cleanup: ${err instanceof Error ? err.message : String(err)}`, }; } const workDirPrefix = await readGitWorkDirPrefix(options, workDir); - const remainingDirtyPaths = parseWorkDirDirtyPaths(dirtyCheckResult.stdout, workDirPrefix).filter( + const remainingDirtyPaths = parseWorkDirDirtyPaths(dirtyStatus, workDirPrefix).filter( relativePath => !isAutoresearchLocalStatePath(relativePath), ); if (remainingDirtyPaths.length > 0) { @@ -654,21 +638,21 @@ async function validateKeepPaths( return "Files in Scope is empty for the current segment. Re-run init_experiment after fixing autoresearch.md."; } - const statusResult = await options.pi.exec( - "git", - ["status", "--porcelain=v1", "-z", "--untracked-files=all", "--", "."], - { - cwd: workDir, - timeout: 10_000, - }, - ); - if (statusResult.code !== 0) { - return `git status failed: ${mergeStdoutStderr(statusResult).trim() || `exit ${statusResult.code}`}`; + let statusText: string; + try { + statusText = await git.status(workDir, { + pathspecs: ["."], + porcelainV1: true, + untrackedFiles: "all", + z: true, + }); + } catch (err) { + return `git status failed: ${err instanceof Error ? err.message : String(err)}`; } const workDirPrefix = await readGitWorkDirPrefix(options, workDir); const committablePaths: string[] = []; - for (const normalizedPath of parseWorkDirDirtyPaths(statusResult.stdout, workDirPrefix)) { + for (const normalizedPath of parseWorkDirDirtyPaths(statusText, workDirPrefix)) { if (isAutoresearchLocalStatePath(normalizedPath)) { continue; } @@ -808,9 +792,12 @@ function buildLogText( } async function readGitWorkDirPrefix(options: AutoresearchToolFactoryOptions, workDir: string): Promise { - const prefixResult = await options.pi.exec("git", ["rev-parse", "--show-prefix"], { cwd: workDir, timeout: 5_000 }); - if (prefixResult.code !== 0) return ""; - return prefixResult.stdout.trim(); + void options; + try { + return await git.show.prefix(workDir); + } catch { + return ""; + } } function truncateAsiValue(value: ASIData[string]): string { diff --git a/packages/coding-agent/src/commit/agentic/agent.ts b/packages/coding-agent/src/commit/agentic/agent.ts index df53930df..1b533188b 100644 --- a/packages/coding-agent/src/commit/agentic/agent.ts +++ b/packages/coding-agent/src/commit/agentic/agent.ts @@ -2,7 +2,6 @@ import { INTENT_FIELD, type ThinkingLevel } from "@oh-my-pi/pi-agent-core"; import type { Api, Model } from "@oh-my-pi/pi-ai"; import { Markdown } from "@oh-my-pi/pi-tui"; import chalk from "chalk"; -import type { ControlledGit } from "../../commit/git"; import typesDescriptionPrompt from "../../commit/prompts/types-description.md" with { type: "text" }; import type { ModelRegistry } from "../../config/model-registry"; import { renderPromptTemplate } from "../../config/prompt-templates"; @@ -18,7 +17,6 @@ import { createCommitTools } from "./tools"; export interface CommitAgentInput { cwd: string; - git: ControlledGit; model: Model; thinkingLevel?: ThinkingLevel; settings: Settings; @@ -46,7 +44,6 @@ export async function runCommitAgentSession(input: CommitAgentInput): Promise { const cwd = getProjectDir(); - const git = new ControlledGit(cwd); const [settings, authStorage] = await Promise.all([Settings.init({ cwd }), discoverAuthStorage()]); process.stdout.write("● Resolving model...\n"); const modelRegistry = new ModelRegistry(authStorage); await modelRegistry.refresh(); const stagedFilesPromise = (async () => { - let stagedFiles = await git.getStagedFiles(); + let stagedFiles = await git.diff.changedFiles(cwd, { cached: true }); if (stagedFiles.length === 0) { process.stdout.write("No staged changes detected, staging all changes...\n"); - await git.stageAll(); - stagedFiles = await git.getStagedFiles(); + await git.stage.files(cwd); + stagedFiles = await git.diff.changedFiles(cwd, { cached: true }); } return stagedFiles; })(); @@ -66,8 +65,8 @@ export async function runAgenticCommit(args: CommitCommandArgs): Promise { const [changelogBoundaries, contextFiles, numstat, diff] = await Promise.all([ args.noChangelog ? [] : detectChangelogBoundaries(cwd, stagedFiles), discoverContextFiles(cwd), - git.getNumstat(true), - git.getDiff(true), + git.diff.numstat(cwd, { cached: true }), + git.diff(cwd, { cached: true }), ]); const changelogTargets = changelogBoundaries.map(boundary => boundary.changelogPath); if (!args.noChangelog) { @@ -93,7 +92,7 @@ export async function runAgenticCommit(args: CommitCommandArgs): Promise { if (forceFallback) { process.stdout.write("● Forcing fallback commit generation...\n"); const fallbackProposal = generateFallbackProposal(numstat); - await runSingleCommit(fallbackProposal, { git, dryRun: args.dryRun, push: args.push }); + await runSingleCommit(fallbackProposal, { cwd, dryRun: args.dryRun, push: args.push }); return; } @@ -110,7 +109,7 @@ export async function runAgenticCommit(args: CommitCommandArgs): Promise { summary: trivialChange.summary, warnings: [], }; - await runSingleCommit(trivialProposal, { git, dryRun: args.dryRun, push: args.push }); + await runSingleCommit(trivialProposal, { cwd, dryRun: args.dryRun, push: args.push }); return; } @@ -129,7 +128,6 @@ export async function runAgenticCommit(args: CommitCommandArgs): Promise { try { commitState = await runCommitAgentSession({ cwd, - git, model: agentModel, thinkingLevel: agentThinkingLevel, settings, @@ -169,7 +167,6 @@ export async function runAgenticCommit(args: CommitCommandArgs): Promise { } process.stdout.write("● Applying changelog entries...\n"); const updated = await applyChangelogProposals({ - git, cwd, proposals: commitState.changelogProposal.entries, dryRun: args.dryRun, @@ -188,13 +185,13 @@ export async function runAgenticCommit(args: CommitCommandArgs): Promise { } if (commitState.proposal) { - await runSingleCommit(commitState.proposal, { git, dryRun: args.dryRun, push: args.push }); + await runSingleCommit(commitState.proposal, { cwd, dryRun: args.dryRun, push: args.push }); return; } if (commitState.splitProposal) { await runSplitCommit(commitState.splitProposal, { - git, + cwd, dryRun: args.dryRun, push: args.push, additionalFiles: updatedChangelogFiles, @@ -215,10 +212,10 @@ async function runSingleCommit(proposal: CommitProposal, ctx: CommitExecutionCon process.stdout.write(`${commitMessage}\n`); return; } - await ctx.git.commit(commitMessage); + await git.commit(ctx.cwd, commitMessage); process.stdout.write("Commit created.\n"); if (ctx.push) { - await ctx.git.push(); + await git.push(ctx.cwd); process.stdout.write("Pushed to remote.\n"); } } @@ -233,7 +230,7 @@ async function runSplitCommit( if (ctx.additionalFiles && ctx.additionalFiles.length > 0) { appendFilesToLastCommit(plan, ctx.additionalFiles); } - const stagedFiles = await ctx.git.getStagedFiles(); + const stagedFiles = await git.diff.changedFiles(ctx.cwd, { cached: true }); const plannedFiles = new Set(plan.commits.flatMap(commit => commit.changes.map(change => change.path))); const missingFiles = stagedFiles.filter(file => !plannedFiles.has(file)); if (missingFiles.length > 0) { @@ -270,10 +267,10 @@ async function runSplitCommit( throw new Error(order.error); } - await ctx.git.resetStaging(); + await git.stage.reset(ctx.cwd); for (const commitIndex of order) { const commit = plan.commits[commitIndex]; - await ctx.git.stageHunks(commit.changes); + await git.stage.hunks(ctx.cwd, commit.changes); const analysis: ConventionalAnalysis = { type: commit.type, scope: commit.scope, @@ -281,12 +278,12 @@ async function runSplitCommit( issueRefs: commit.issueRefs, }; const message = formatCommitMessage(analysis, commit.summary); - await ctx.git.commit(message); - await ctx.git.resetStaging(); + await git.commit(ctx.cwd, message); + await git.stage.reset(ctx.cwd); } process.stdout.write("Split commits created.\n"); if (ctx.push) { - await ctx.git.push(); + await git.push(ctx.cwd); process.stdout.write("Pushed to remote.\n"); } } diff --git a/packages/coding-agent/src/commit/agentic/tools/git-file-diff.ts b/packages/coding-agent/src/commit/agentic/tools/git-file-diff.ts index 814a0d9a9..953fb2869 100644 --- a/packages/coding-agent/src/commit/agentic/tools/git-file-diff.ts +++ b/packages/coding-agent/src/commit/agentic/tools/git-file-diff.ts @@ -1,7 +1,7 @@ import { Type } from "@sinclair/typebox"; import type { CommitAgentState } from "../../../commit/agentic/state"; -import type { ControlledGit } from "../../../commit/git"; import type { CustomTool } from "../../../extensibility/custom-tools/types"; +import * as git from "../../../utils/git"; const TARGET_TOKENS = 30000; const CHARS_PER_TOKEN = 4; @@ -136,10 +136,7 @@ const gitFileDiffSchema = Type.Object({ staged: Type.Optional(Type.Boolean({ description: "Use staged changes (default: true)" })), }); -export function createGitFileDiffTool( - git: ControlledGit, - state: CommitAgentState, -): CustomTool { +export function createGitFileDiffTool(cwd: string, state: CommitAgentState): CustomTool { return { name: "git_file_diff", label: "Git File Diff", @@ -167,7 +164,7 @@ export function createGitFileDiffTool( if (uncachedFiles.length > 0) { for (const file of uncachedFiles) { - const diff = await git.getDiffForFiles([file], staged); + const diff = await git.diff(cwd, { cached: staged, files: [file] }); if (diff) { diffs.set(file, diff); state.diffCache.set(cacheKey(file), diff); diff --git a/packages/coding-agent/src/commit/agentic/tools/git-hunk.ts b/packages/coding-agent/src/commit/agentic/tools/git-hunk.ts index 9e6b528e0..a39191cb9 100644 --- a/packages/coding-agent/src/commit/agentic/tools/git-hunk.ts +++ b/packages/coding-agent/src/commit/agentic/tools/git-hunk.ts @@ -1,7 +1,7 @@ import { Type } from "@sinclair/typebox"; -import type { ControlledGit } from "../../../commit/git"; import type { DiffHunk, FileHunks } from "../../../commit/types"; import type { CustomTool } from "../../../extensibility/custom-tools/types"; +import * as git from "../../../utils/git"; const gitHunkSchema = Type.Object({ file: Type.String({ description: "File path" }), @@ -15,7 +15,7 @@ function selectHunks(fileHunks: FileHunks, requested?: number[]): DiffHunk[] { return fileHunks.hunks.filter(hunk => wanted.has(hunk.index + 1)); } -export function createGitHunkTool(git: ControlledGit): CustomTool { +export function createGitHunkTool(cwd: string): CustomTool { return { name: "git_hunk", label: "Git Hunk", @@ -23,7 +23,7 @@ export function createGitHunkTool(git: ControlledGit): CustomTool entry.filename === params.file) ?? { filename: params.file, isBinary: false, diff --git a/packages/coding-agent/src/commit/agentic/tools/git-overview.ts b/packages/coding-agent/src/commit/agentic/tools/git-overview.ts index 9451ea630..bd493e2c7 100644 --- a/packages/coding-agent/src/commit/agentic/tools/git-overview.ts +++ b/packages/coding-agent/src/commit/agentic/tools/git-overview.ts @@ -1,8 +1,8 @@ import { Type } from "@sinclair/typebox"; import type { CommitAgentState, GitOverviewSnapshot } from "../../../commit/agentic/state"; import { extractScopeCandidates } from "../../../commit/analysis/scope"; -import type { ControlledGit } from "../../../commit/git"; import type { CustomTool } from "../../../extensibility/custom-tools/types"; +import * as git from "../../../utils/git"; const EXCLUDED_LOCK_FILES = new Set([ "Cargo.lock", @@ -47,10 +47,7 @@ const gitOverviewSchema = Type.Object({ include_untracked: Type.Optional(Type.Boolean({ description: "Include untracked files when staged=false" })), }); -export function createGitOverviewTool( - git: ControlledGit, - state: CommitAgentState, -): CustomTool { +export function createGitOverviewTool(cwd: string, state: CommitAgentState): CustomTool { return { name: "git_overview", label: "Git Overview", @@ -58,13 +55,13 @@ export function createGitOverviewTool( parameters: gitOverviewSchema, async execute(_toolCallId, params) { const staged = params.staged ?? true; - const allFiles = staged ? await git.getStagedFiles() : await git.getChangedFiles(false); + const allFiles = await git.diff.changedFiles(cwd, { cached: staged }); const { filtered: files, excluded } = filterExcludedFiles(allFiles); - const stat = await git.getStat(staged); - const allNumstat = await git.getNumstat(staged); + const stat = await git.diff(cwd, { stat: true, cached: staged }); + const allNumstat = await git.diff.numstat(cwd, { cached: staged }); const numstat = allNumstat.filter(entry => !isExcludedFile(entry.path)); const scopeResult = extractScopeCandidates(numstat); - const untrackedFiles = !staged && params.include_untracked ? await git.getUntrackedFiles() : undefined; + const untrackedFiles = !staged && params.include_untracked ? await git.ls.untracked(cwd) : undefined; const snapshot: GitOverviewSnapshot = { files, stat, diff --git a/packages/coding-agent/src/commit/agentic/tools/index.ts b/packages/coding-agent/src/commit/agentic/tools/index.ts index 4f28bc21a..11ed162fb 100644 --- a/packages/coding-agent/src/commit/agentic/tools/index.ts +++ b/packages/coding-agent/src/commit/agentic/tools/index.ts @@ -1,5 +1,4 @@ import type { CommitAgentState } from "../../../commit/agentic/state"; -import type { ControlledGit } from "../../../commit/git"; import type { ModelRegistry } from "../../../config/model-registry"; import type { Settings } from "../../../config/settings"; import type { CustomTool } from "../../../extensibility/custom-tools/types"; @@ -15,7 +14,6 @@ import { createSplitCommitTool } from "./split-commit"; export interface CommitToolOptions { cwd: string; - git: ControlledGit; authStorage: AuthStorage; modelRegistry: ModelRegistry; settings: Settings; @@ -27,10 +25,10 @@ export interface CommitToolOptions { export function createCommitTools(options: CommitToolOptions): Array> { const tools: Array> = [ - createGitOverviewTool(options.git, options.state), - createGitFileDiffTool(options.git, options.state), - createGitHunkTool(options.git), - createRecentCommitsTool(options.git), + createGitOverviewTool(options.cwd, options.state), + createGitFileDiffTool(options.cwd, options.state), + createGitHunkTool(options.cwd), + createRecentCommitsTool(options.cwd), ]; if (options.enableAnalyzeFiles ?? true) { @@ -48,8 +46,8 @@ export function createCommitTools(options: CommitToolOptions): Array { +export function createProposeCommitTool(cwd: string, state: CommitAgentState): CustomTool { return { name: "propose_commit", label: "Propose Commit", @@ -72,8 +69,8 @@ export function createProposeCommitTool( const summaryValidation = validateSummaryRules(summary); const analysisValidation = validateAnalysis(analysis); - const stagedFiles = state.overview?.files ?? (await git.getStagedFiles()); - const diffText = state.diffText ?? (await git.getDiff(true)); + const stagedFiles = state.overview?.files ?? (await git.diff.changedFiles(cwd, { cached: true })); + const diffText = state.diffText ?? (await git.diff(cwd, { cached: true })); const typeValidation = validateTypeConsistency(params.type, stagedFiles, { diffText, summary, diff --git a/packages/coding-agent/src/commit/agentic/tools/recent-commits.ts b/packages/coding-agent/src/commit/agentic/tools/recent-commits.ts index 025f2d4a8..8b83647eb 100644 --- a/packages/coding-agent/src/commit/agentic/tools/recent-commits.ts +++ b/packages/coding-agent/src/commit/agentic/tools/recent-commits.ts @@ -1,6 +1,6 @@ import { Type } from "@sinclair/typebox"; -import type { ControlledGit } from "../../../commit/git"; import type { CustomTool } from "../../../extensibility/custom-tools/types"; +import * as git from "../../../utils/git"; const recentCommitsSchema = Type.Object({ count: Type.Optional(Type.Number({ description: "Number of commits to fetch", minimum: 1, maximum: 50 })), @@ -25,7 +25,7 @@ function extractScope(subject: string): string | null { return match?.[1]?.trim() ?? null; } -export function createRecentCommitsTool(git: ControlledGit): CustomTool { +export function createRecentCommitsTool(cwd: string): CustomTool { return { name: "recent_commits", label: "Recent Commits", @@ -33,7 +33,7 @@ export function createRecentCommitsTool(git: ControlledGit): CustomTool = {}; const scopes: Record = {}; const lengths: number[] = []; diff --git a/packages/coding-agent/src/commit/agentic/tools/split-commit.ts b/packages/coding-agent/src/commit/agentic/tools/split-commit.ts index 0b61609b9..b47c9b6d3 100644 --- a/packages/coding-agent/src/commit/agentic/tools/split-commit.ts +++ b/packages/coding-agent/src/commit/agentic/tools/split-commit.ts @@ -10,9 +10,9 @@ import { validateTypeConsistency, } from "../../../commit/agentic/validation"; import { validateScope } from "../../../commit/analysis/validation"; -import type { ControlledGit } from "../../../commit/git"; import type { ConventionalDetail } from "../../../commit/types"; import type { CustomTool } from "../../../extensibility/custom-tools/types"; +import * as git from "../../../utils/git"; import { commitTypeSchema, detailSchema } from "./schemas.js"; const hunkSelectorSchema = Type.Union([ @@ -64,7 +64,7 @@ function normalizeDetails( } export function createSplitCommitTool( - git: ControlledGit, + cwd: string, state: CommitAgentState, changelogTargets: string[], ): CustomTool { @@ -74,13 +74,13 @@ export function createSplitCommitTool( description: "Propose multiple atomic commits for unrelated changes.", parameters: splitCommitSchema, async execute(_toolCallId, params) { - const stagedFiles = state.overview?.files ?? (await git.getStagedFiles()); + const stagedFiles = state.overview?.files ?? (await git.diff.changedFiles(cwd, { cached: true })); const stagedSet = new Set(stagedFiles); const changelogSet = new Set(changelogTargets); const usedFiles = new Set(); const errors: string[] = []; const warnings: string[] = []; - const diffText = await git.getDiff(true); + const diffText = await git.diff(cwd, { cached: true }); const commits: SplitCommitGroup[] = params.commits.map((commit, index) => { const scope = commit.scope?.trim() || null; diff --git a/packages/coding-agent/src/commit/changelog/index.ts b/packages/coding-agent/src/commit/changelog/index.ts index d00fbc67f..f80f79401 100644 --- a/packages/coding-agent/src/commit/changelog/index.ts +++ b/packages/coding-agent/src/commit/changelog/index.ts @@ -2,8 +2,8 @@ import * as path from "node:path"; import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core"; import type { Api, Model } from "@oh-my-pi/pi-ai"; import { logger } from "@oh-my-pi/pi-utils"; -import type { ControlledGit } from "../../commit/git"; import { CHANGELOG_CATEGORIES } from "../../commit/types"; +import * as git from "../../utils/git"; import { detectChangelogBoundaries } from "./detect"; import { generateChangelogEntries } from "./generate"; import { parseUnreleasedSection } from "./parse"; @@ -13,7 +13,6 @@ const CHANGELOG_SECTIONS = CHANGELOG_CATEGORIES; const DEFAULT_MAX_DIFF_CHARS = 120_000; export interface ChangelogFlowInput { - git: ControlledGit; cwd: string; model: Model; apiKey: string; @@ -25,7 +24,6 @@ export interface ChangelogFlowInput { } export interface ChangelogProposalInput { - git: ControlledGit; cwd: string; proposals: Array<{ path: string; @@ -40,7 +38,6 @@ export interface ChangelogProposalInput { * Update CHANGELOG.md entries for staged changes. */ export async function runChangelogFlow({ - git, cwd, model, apiKey, @@ -58,9 +55,9 @@ export async function runChangelogFlow({ const updated: string[] = []; for (const boundary of boundaries) { onProgress?.(`Generating entries for ${boundary.changelogPath}…`); - const diff = await git.getDiffForFiles(boundary.files, true); + const diff = await git.diff(cwd, { cached: true, files: boundary.files }); if (!diff.trim()) continue; - const stat = await git.getStatForFiles(boundary.files, true); + const stat = await git.diff(cwd, { stat: true, cached: true, files: boundary.files }); const diffForPrompt = truncateDiff(diff, maxDiffChars ?? DEFAULT_MAX_DIFF_CHARS); const changelogContent = await Bun.file(boundary.changelogPath).text(); let unreleased: { startLine: number; endLine: number; entries: Record }; @@ -87,7 +84,7 @@ export async function runChangelogFlow({ const updatedContent = applyChangelogEntries(changelogContent, unreleased, generated.entries); if (!dryRun) { await Bun.write(boundary.changelogPath, updatedContent); - await git.stageFiles([path.relative(cwd, boundary.changelogPath)]); + await git.stage.files(cwd, [path.relative(cwd, boundary.changelogPath)]); } updated.push(boundary.changelogPath); } @@ -99,7 +96,6 @@ export async function runChangelogFlow({ * Apply changelog entries provided by the commit agent. */ export async function applyChangelogProposals({ - git, cwd, proposals, dryRun, @@ -132,7 +128,7 @@ export async function applyChangelogProposals({ const updatedContent = applyChangelogEntries(changelogContent, unreleased, normalized, normalizedDeletions); if (!dryRun) { await Bun.write(proposal.path, updatedContent); - await git.stageFiles([path.relative(cwd, proposal.path)]); + await git.stage.files(cwd, [path.relative(cwd, proposal.path)]); } updated.push(proposal.path); } diff --git a/packages/coding-agent/src/commit/git/errors.ts b/packages/coding-agent/src/commit/git/errors.ts deleted file mode 100644 index 5278fc493..000000000 --- a/packages/coding-agent/src/commit/git/errors.ts +++ /dev/null @@ -1,9 +0,0 @@ -export class GitError extends Error { - constructor( - readonly command: string, - readonly stderr: string, - ) { - super(`${command} failed: ${stderr || "unknown error"}`); - this.name = "GitError"; - } -} diff --git a/packages/coding-agent/src/commit/git/index.ts b/packages/coding-agent/src/commit/git/index.ts deleted file mode 100644 index 3c634400e..000000000 --- a/packages/coding-agent/src/commit/git/index.ts +++ /dev/null @@ -1,210 +0,0 @@ -import * as fs from "node:fs/promises"; -import * as os from "node:os"; -import * as path from "node:path"; -import { logger, Snowflake } from "@oh-my-pi/pi-utils"; -import type { FileDiff, FileHunks, NumstatEntry } from "../../commit/types"; -import { parseDiffHunks, parseFileDiffs, parseFileHunks, parseNumstat } from "./diff"; -import { GitError } from "./errors"; -import { commit, push, resetStaging, runGitCommand, stageFiles } from "./operations"; - -export type HunkSelection = { - path: string; - hunks: { type: "all" } | { type: "indices"; indices: number[] } | { type: "lines"; start: number; end: number }; -}; - -export class ControlledGit { - constructor(private readonly cwd: string) {} - - async getDiff(staged: boolean): Promise { - const args = staged ? ["diff", "--cached"] : ["diff"]; - const result = await runGitCommand(this.cwd, args); - this.#ensureSuccess(result, "git diff"); - return result.stdout; - } - - async getDiffForFiles(files: string[], staged = true): Promise { - const args = staged ? ["diff", "--cached", "--", ...files] : ["diff", "--", ...files]; - const result = await runGitCommand(this.cwd, args); - this.#ensureSuccess(result, "git diff (files)"); - return result.stdout; - } - - async getChangedFiles(staged: boolean): Promise { - const args = staged ? ["diff", "--cached", "--name-only"] : ["diff", "--name-only"]; - const result = await runGitCommand(this.cwd, args); - this.#ensureSuccess(result, "git diff --name-only"); - return result.stdout - .split("\n") - .map(line => line.trim()) - .filter(Boolean); - } - - async getStat(staged: boolean): Promise { - const args = staged ? ["diff", "--cached", "--stat"] : ["diff", "--stat"]; - const result = await runGitCommand(this.cwd, args); - this.#ensureSuccess(result, "git diff --stat"); - return result.stdout; - } - - async getStatForFiles(files: string[], staged = true): Promise { - const args = staged ? ["diff", "--cached", "--stat", "--", ...files] : ["diff", "--stat", "--", ...files]; - const result = await runGitCommand(this.cwd, args); - this.#ensureSuccess(result, "git diff --stat (files)"); - return result.stdout; - } - - async getNumstat(staged: boolean): Promise { - const args = staged ? ["diff", "--cached", "--numstat"] : ["diff", "--numstat"]; - const result = await runGitCommand(this.cwd, args); - this.#ensureSuccess(result, "git diff --numstat"); - return parseNumstat(result.stdout); - } - - async getRecentCommits(count: number): Promise { - const result = await runGitCommand(this.cwd, ["log", `-n${count}`, "--pretty=format:%s"]); - this.#ensureSuccess(result, "git log"); - return result.stdout - .split("\n") - .map(line => line.trim()) - .filter(Boolean); - } - - async getStagedFiles(): Promise { - const result = await runGitCommand(this.cwd, ["diff", "--cached", "--name-only"]); - this.#ensureSuccess(result, "git diff --cached --name-only"); - return result.stdout - .split("\n") - .map(line => line.trim()) - .filter(Boolean); - } - - async getUntrackedFiles(): Promise { - const result = await runGitCommand(this.cwd, ["ls-files", "--others", "--exclude-standard"]); - this.#ensureSuccess(result, "git ls-files --others --exclude-standard"); - return result.stdout - .split("\n") - .map(line => line.trim()) - .filter(Boolean); - } - - async stageAll(): Promise { - const result = await stageFiles(this.cwd, []); - this.#ensureSuccess(result, "git add -A"); - } - - async stageFiles(files: string[]): Promise { - const result = await stageFiles(this.cwd, files); - this.#ensureSuccess(result, "git add"); - } - - async stageHunks(selections: HunkSelection[]): Promise { - if (selections.length === 0) return; - const diff = await this.getDiff(false); - const fileDiffs = parseFileDiffs(diff); - 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 GitError("git apply --cached", `No diff found for ${selection.path}`); - } - if (fileDiff.isBinary) { - if (selection.hunks.type !== "all") { - throw new GitError("git apply --cached", `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 selectedHunks = selectHunks(fileHunks, selection.hunks); - if (selectedHunks.length === 0) { - throw new GitError("git apply --cached", `No hunks selected for ${selection.path}`); - } - const header = extractFileHeader(fileDiff.content); - const filePatch = [header, ...selectedHunks.map(hunk => hunk.content)].join("\n"); - patchParts.push(filePatch); - } - - const patch = joinPatch(patchParts); - if (!patch.trim()) return; - const tempPath = path.join(os.tmpdir(), `omp-hunks-${Snowflake.next()}.patch`); - try { - await Bun.write(tempPath, patch); - const result = await runGitCommand(this.cwd, ["apply", "--cached", "--binary", tempPath]); - this.#ensureSuccess(result, "git apply --cached"); - } finally { - await fs.rm(tempPath, { force: true }); - } - } - - async resetStaging(files: string[] = []): Promise { - const result = await resetStaging(this.cwd, files); - this.#ensureSuccess(result, "git reset"); - } - - async commit(message: string): Promise { - const result = await commit(this.cwd, message); - this.#ensureSuccess(result, "git commit"); - } - - async push(): Promise { - const result = await push(this.cwd); - this.#ensureSuccess(result, "git push"); - } - - parseDiffFiles(diff: string): FileDiff[] { - return parseFileDiffs(diff); - } - - parseDiffHunks(diff: string): FileHunks[] { - return parseDiffHunks(diff); - } - - async getHunks(files: string[], staged = true): Promise { - const diff = await this.getDiffForFiles(files, staged); - return this.parseDiffHunks(diff); - } - - #ensureSuccess(result: { exitCode: number; stderr: string }, label: string): void { - if (result.exitCode !== 0) { - logger.error("commit git command failed", { label, stderr: result.stderr }); - throw new GitError(label, result.stderr); - } - } -} - -function extractFileHeader(diff: string): string { - const lines = diff.split("\n"); - const headerLines: string[] = []; - for (const line of lines) { - if (line.startsWith("@@")) break; - headerLines.push(line); - } - return headerLines.join("\n"); -} - -export function joinPatch(parts: string[]): string { - return `${parts - .map(part => (part.endsWith("\n") ? part : `${part}\n`)) - .join("\n") - .replace(/\n+$/, "")}\n`; -} - -function selectHunks(file: FileHunks, selector: HunkSelection["hunks"]): FileHunks["hunks"] { - if (selector.type === "indices") { - const wanted = new Set(selector.indices.map(value => Math.max(1, Math.floor(value)))); - 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; -} diff --git a/packages/coding-agent/src/commit/git/operations.ts b/packages/coding-agent/src/commit/git/operations.ts deleted file mode 100644 index 1a62081ce..000000000 --- a/packages/coding-agent/src/commit/git/operations.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { $ } from "bun"; - -interface GitResult { - exitCode: number; - stdout: string; - stderr: string; -} - -export async function runGitCommand(cwd: string, args: string[]): Promise { - const result = await $`git ${args}`.cwd(cwd).quiet().nothrow(); - const stdout = result.text(); - const stderr = result.stderr?.toString() ?? ""; - return { - exitCode: result.exitCode ?? 0, - stdout, - stderr, - }; -} - -export async function stageFiles(cwd: string, files: string[]): Promise { - const args = files.length === 0 ? ["add", "-A"] : ["add", "--", ...files]; - return runGitCommand(cwd, args); -} - -export async function resetStaging(cwd: string, files: string[]): Promise { - const args = files.length === 0 ? ["reset"] : ["reset", "--", ...files]; - return runGitCommand(cwd, args); -} - -export async function push(cwd: string): Promise { - return runGitCommand(cwd, ["push"]); -} - -export async function commit(cwd: string, message: string): Promise { - const child = Bun.spawn(["git", "commit", "-F", "-"], { - cwd, - stdin: Buffer.from(message), - stdout: "pipe", - stderr: "pipe", - windowsHide: true, - }); - - const [stdout, stderr, exitCode] = await Promise.all([ - new Response(child.stdout).text(), - new Response(child.stderr).text(), - child.exited, - ]); - - return { - exitCode: exitCode ?? 0, - stdout: stdout.trim(), - stderr: stderr.trim(), - }; -} diff --git a/packages/coding-agent/src/commit/pipeline.ts b/packages/coding-agent/src/commit/pipeline.ts index 3cc0e813a..517cc1fcb 100644 --- a/packages/coding-agent/src/commit/pipeline.ts +++ b/packages/coding-agent/src/commit/pipeline.ts @@ -7,6 +7,7 @@ import { renderPromptTemplate } from "../config/prompt-templates"; import { Settings } from "../config/settings"; import { discoverAuthStorage } from "../sdk"; import { loadProjectContextFiles } from "../system-prompt"; +import * as git from "../utils/git"; import { runAgenticCommit } from "./agentic"; import { extractScopeCandidates, @@ -16,7 +17,6 @@ import { validateSummary, } from "./analysis"; import { runChangelogFlow } from "./changelog"; -import { ControlledGit } from "./git"; import { runMapReduceAnalysis, shouldUseMapReduce } from "./map-reduce"; import { formatCommitMessage } from "./message"; import { resolvePrimaryModel, resolveSmolModel } from "./model-selection"; @@ -57,12 +57,11 @@ async function runLegacyCommitCommand(args: CommitCommandArgs): Promise { thinkingLevel: smolThinkingLevel, } = await resolveSmolModel(settings, modelRegistry, primaryModel, primaryApiKey); - const git = new ControlledGit(cwd); - let stagedFiles = await git.getStagedFiles(); + let stagedFiles = await git.diff.changedFiles(cwd, { cached: true }); if (stagedFiles.length === 0) { process.stdout.write("No staged changes detected, staging all changes...\n"); - await git.stageAll(); - stagedFiles = await git.getStagedFiles(); + await git.stage.files(cwd); + stagedFiles = await git.diff.changedFiles(cwd, { cached: true }); } if (stagedFiles.length === 0) { process.stderr.write("No changes to commit.\n"); @@ -71,7 +70,6 @@ async function runLegacyCommitCommand(args: CommitCommandArgs): Promise { if (!args.noChangelog) { await runChangelogFlow({ - git, cwd, model: primaryModel, apiKey: primaryApiKey, @@ -82,11 +80,11 @@ async function runLegacyCommitCommand(args: CommitCommandArgs): Promise { }); } - const diff = await git.getDiff(true); - const stat = await git.getStat(true); - const numstat = await git.getNumstat(true); + const diff = await git.diff(cwd, { cached: true }); + const stat = await git.diff(cwd, { stat: true, cached: true }); + const numstat = await git.diff.numstat(cwd, { cached: true }); const scopeCandidates = extractScopeCandidates(numstat).scopeCandidates; - const recentCommits = await git.getRecentCommits(RECENT_COMMITS_COUNT); + const recentCommits = await git.log.subjects(cwd, RECENT_COMMITS_COUNT); const contextFiles = await loadProjectContextFiles({ cwd }); const formattedContextFiles = contextFiles.map(file => ({ path: path.relative(cwd, file.path), @@ -131,10 +129,10 @@ async function runLegacyCommitCommand(args: CommitCommandArgs): Promise { return; } - await git.commit(commitMessage); + await git.commit(cwd, commitMessage); process.stdout.write("Commit created.\n"); if (args.push) { - await git.push(); + await git.push(cwd); process.stdout.write("Pushed to remote.\n"); } } diff --git a/packages/coding-agent/src/extensibility/custom-commands/bundled/ci-green/index.ts b/packages/coding-agent/src/extensibility/custom-commands/bundled/ci-green/index.ts index f7fc0859f..80319f80b 100644 --- a/packages/coding-agent/src/extensibility/custom-commands/bundled/ci-green/index.ts +++ b/packages/coding-agent/src/extensibility/custom-commands/bundled/ci-green/index.ts @@ -2,26 +2,14 @@ import { renderPromptTemplate } from "../../../../config/prompt-templates"; import type { CustomCommand, CustomCommandAPI } from "../../../../extensibility/custom-commands/types"; import type { HookCommandContext } from "../../../../extensibility/hooks/types"; import ciGreenRequestTemplate from "../../../../prompts/ci-green-request.md" with { type: "text" }; +import * as git from "../../../../utils/git"; async function getHeadTag(api: CustomCommandAPI): Promise { - const result = await api.exec("git", [ - "for-each-ref", - "--points-at", - "HEAD", - "--sort=-version:refname", - "--format=%(refname:strip=2)", - "refs/tags", - ]); - - if (result.code !== 0 || result.killed) { + try { + return (await git.ref.tags(api.cwd))[0]; + } catch { return undefined; } - - const tag = result.stdout - .split("\n") - .map(line => line.trim()) - .find(Boolean); - return tag || undefined; } export class GreenCommand implements CustomCommand { diff --git a/packages/coding-agent/src/extensibility/custom-commands/bundled/review/index.ts b/packages/coding-agent/src/extensibility/custom-commands/bundled/review/index.ts index 50593cfa1..2a0619e50 100644 --- a/packages/coding-agent/src/extensibility/custom-commands/bundled/review/index.ts +++ b/packages/coding-agent/src/extensibility/custom-commands/bundled/review/index.ts @@ -15,6 +15,7 @@ import { renderPromptTemplate } from "../../../../config/prompt-templates"; import type { CustomCommand, CustomCommandAPI } from "../../../../extensibility/custom-commands/types"; import type { HookCommandContext } from "../../../../extensibility/hooks/types"; import reviewRequestTemplate from "../../../../prompts/review-request.md" with { type: "text" }; +import * as git from "../../../../utils/git"; // ───────────────────────────────────────────────────────────────────────────── // Types @@ -258,20 +259,20 @@ export class ReviewCommand implements CustomCommand { if (!baseBranch) return undefined; const currentBranch = await getCurrentBranch(this.api); - const diffResult = await this.api.exec("git", ["diff", `${baseBranch}...${currentBranch}`], { - timeout: 30000, - }); - if (diffResult.code !== 0) { - ctx.ui.notify(`Failed to get diff: ${diffResult.stderr}`, "error"); + let diffText: string; + try { + diffText = await git.diff(this.api.cwd, { base: `${baseBranch}...${currentBranch}` }); + } catch (err) { + ctx.ui.notify(`Failed to get diff: ${err instanceof Error ? err.message : String(err)}`, "error"); return undefined; } - if (!diffResult.stdout.trim()) { + if (!diffText.trim()) { ctx.ui.notify(`No changes between ${baseBranch} and ${currentBranch}`, "warning"); return undefined; } - const stats = parseDiff(diffResult.stdout); + const stats = parseDiff(diffText); if (stats.files.length === 0) { ctx.ui.notify("No reviewable files (all changes filtered out)", "warning"); return undefined; @@ -280,7 +281,7 @@ export class ReviewCommand implements CustomCommand { return buildReviewPrompt( `Reviewing changes between \`${baseBranch}\` and \`${currentBranch}\` (PR-style)`, stats, - diffResult.stdout, + diffText, ); } @@ -292,12 +293,19 @@ export class ReviewCommand implements CustomCommand { return undefined; } - const [unstagedResult, stagedResult] = await Promise.all([ - this.api.exec("git", ["diff"], { timeout: 30000 }), - this.api.exec("git", ["diff", "--cached"], { timeout: 30000 }), - ]); + let unstagedDiff: string; + let stagedDiff: string; + try { + [unstagedDiff, stagedDiff] = await Promise.all([ + git.diff(this.api.cwd), + git.diff(this.api.cwd, { cached: true }), + ]); + } catch (err) { + ctx.ui.notify(`Failed to get diff: ${err instanceof Error ? err.message : String(err)}`, "error"); + return undefined; + } - const combinedDiff = [unstagedResult.stdout, stagedResult.stdout].filter(Boolean).join("\n"); + const combinedDiff = [unstagedDiff, stagedDiff].filter(Boolean).join("\n"); if (!combinedDiff.trim()) { ctx.ui.notify("No diff content found", "warning"); @@ -327,25 +335,26 @@ export class ReviewCommand implements CustomCommand { // Extract commit hash from selection (format: "abc1234 message") const hash = selected.split(" ")[0]; - // Get the commit diff (with timeout) - const showResult = await this.api.exec("git", ["show", "--format=", hash], { timeout: 30000 }); - if (showResult.code !== 0) { - ctx.ui.notify(`Failed to get commit: ${showResult.stderr}`, "error"); + let diffText: string; + try { + diffText = await git.show(this.api.cwd, hash, { format: "" }); + } catch (err) { + ctx.ui.notify(`Failed to get commit: ${err instanceof Error ? err.message : String(err)}`, "error"); return undefined; } - if (!showResult.stdout.trim()) { + if (!diffText.trim()) { ctx.ui.notify("Commit has no diff content", "warning"); return undefined; } - const stats = parseDiff(showResult.stdout); + const stats = parseDiff(diffText); if (stats.files.length === 0) { ctx.ui.notify("No reviewable files in commit (all changes filtered out)", "warning"); return undefined; } - return buildReviewPrompt(`Reviewing commit \`${hash}\``, stats, showResult.stdout); + return buildReviewPrompt(`Reviewing commit \`${hash}\``, stats, diffText); } case 4: { @@ -354,16 +363,21 @@ export class ReviewCommand implements CustomCommand { if (!instructions?.trim()) return undefined; // For custom, we still try to get current diff for context - const diffResult = await this.api.exec("git", ["diff", "HEAD"], { timeout: 30000 }); - const hasDiff = diffResult.code === 0 && diffResult.stdout.trim(); + let diffText: string | undefined; + try { + diffText = await git.diff(this.api.cwd, { base: "HEAD" }); + } catch { + diffText = undefined; + } + const reviewDiff = diffText?.trim(); - if (hasDiff) { - const stats = parseDiff(diffResult.stdout); + if (reviewDiff) { + const stats = parseDiff(reviewDiff); // Even if all files filtered, include the custom instructions return `${buildReviewPrompt( `Custom review: ${instructions.split("\n")[0].slice(0, 60)}…`, stats, - diffResult.stdout, + reviewDiff, )}\n\n### Additional Instructions\n\n${instructions}`; } @@ -388,12 +402,7 @@ Use the Task tool with \`agent: "reviewer"\` to execute this review.`; async function getGitBranches(api: CustomCommandAPI): Promise { try { - const result = await api.exec("git", ["branch", "-a", "--format=%(refname:short)"]); - if (result.code !== 0) return []; - return result.stdout - .split("\n") - .map(b => b.trim()) - .filter(Boolean); + return await git.branch.list(api.cwd, { all: true }); } catch { return []; } @@ -401,8 +410,7 @@ async function getGitBranches(api: CustomCommandAPI): Promise { async function getCurrentBranch(api: CustomCommandAPI): Promise { try { - const result = await api.exec("git", ["branch", "--show-current"]); - return result.stdout.trim() || "HEAD"; + return (await git.branch.current(api.cwd)) ?? "HEAD"; } catch { return "HEAD"; } @@ -410,8 +418,7 @@ async function getCurrentBranch(api: CustomCommandAPI): Promise { async function getGitStatus(api: CustomCommandAPI): Promise { try { - const result = await api.exec("git", ["status", "--porcelain"]); - return result.stdout; + return await git.status(api.cwd); } catch { return ""; } @@ -419,12 +426,7 @@ async function getGitStatus(api: CustomCommandAPI): Promise { async function getRecentCommits(api: CustomCommandAPI, count: number): Promise { try { - const result = await api.exec("git", ["log", `-${count}`, "--oneline", "--no-decorate"]); - if (result.code !== 0) return []; - return result.stdout - .split("\n") - .map(c => c.trim()) - .filter(Boolean); + return await git.log.onelines(api.cwd, count); } catch { return []; } diff --git a/packages/coding-agent/src/extensibility/plugins/marketplace/fetcher.ts b/packages/coding-agent/src/extensibility/plugins/marketplace/fetcher.ts index 1d3b6e6b9..5046acd08 100644 --- a/packages/coding-agent/src/extensibility/plugins/marketplace/fetcher.ts +++ b/packages/coding-agent/src/extensibility/plugins/marketplace/fetcher.ts @@ -8,7 +8,7 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import { isEnoent, logger } from "@oh-my-pi/pi-utils"; -import { $ } from "bun"; +import * as git from "../../../utils/git"; import type { MarketplaceCatalog, MarketplaceSourceType } from "./types"; import { isValidNameSegment } from "./types"; @@ -274,21 +274,11 @@ export async function fetchMarketplace(source: string, cacheDir: string): Promis * `promoteCloneToCache` after any duplicate/drift checks pass. */ async function cloneAndReadCatalog(url: string, cacheDir: string): Promise { - if (!Bun.which("git")) { - throw new Error("git is not installed. Install git to use git-based marketplace sources."); - } - const tmpDir = path.join(cacheDir, `.tmp-clone-${Date.now()}`); await fs.mkdir(cacheDir, { recursive: true }); logger.debug(`[marketplace] cloning ${url} → ${tmpDir}`); - - const result = await $`git clone --depth 1 --single-branch ${url} ${tmpDir}`.quiet().nothrow(); - if (result.exitCode !== 0) { - await fs.rm(tmpDir, { recursive: true, force: true }); - const stderr = result.stderr.toString().trim(); - throw new Error(`git clone failed (exit ${result.exitCode}): ${stderr || "unknown error"}`); - } + await git.clone(url, tmpDir); const catalogPath = path.join(tmpDir, CATALOG_RELATIVE_PATH); let content: string; @@ -325,48 +315,3 @@ export async function promoteCloneToCache(tmpDir: string, cacheDir: string, name await fs.rename(tmpDir, finalDir); return finalDir; } - -/** - * Clone a git repository to a target directory. Shared by fetcher (marketplace clones) - * and source-resolver (plugin source clones). - * - * @param url - Git clone URL (HTTPS, SSH, or GitHub shorthand expanded to HTTPS) - * @param targetDir - Directory to clone into (must not exist) - * @param options.ref - Optional branch/tag to clone - * @param options.sha - Optional commit SHA to checkout after clone - */ -export async function cloneGitRepo( - url: string, - targetDir: string, - options?: { ref?: string; sha?: string }, -): Promise { - if (!Bun.which("git")) { - throw new Error("git is not installed. Install git to use git-based plugin sources."); - } - - const cloneArgs = ["git", "clone", "--depth", "1"]; - if (options?.ref) { - cloneArgs.push("--branch", options.ref, "--single-branch"); - } else { - cloneArgs.push("--single-branch"); - } - cloneArgs.push(url, targetDir); - - logger.debug("[marketplace] cloning plugin source", { url, targetDir }); - - const result = await $`${cloneArgs}`.quiet().nothrow(); - if (result.exitCode !== 0) { - await fs.rm(targetDir, { recursive: true, force: true }); - const stderr = result.stderr.toString().trim(); - throw new Error(`git clone failed (exit ${result.exitCode}): ${stderr || "unknown error"}`); - } - - // If a specific SHA is requested, checkout that commit - if (options?.sha) { - const checkout = await $`git -C ${targetDir} checkout ${options.sha}`.quiet().nothrow(); - if (checkout.exitCode !== 0) { - await fs.rm(targetDir, { recursive: true, force: true }); - throw new Error(`Failed to checkout SHA ${options.sha} — shallow clone may not contain this commit`); - } - } -} diff --git a/packages/coding-agent/src/extensibility/plugins/marketplace/source-resolver.ts b/packages/coding-agent/src/extensibility/plugins/marketplace/source-resolver.ts index 2eafde5b8..ab247b643 100644 --- a/packages/coding-agent/src/extensibility/plugins/marketplace/source-resolver.ts +++ b/packages/coding-agent/src/extensibility/plugins/marketplace/source-resolver.ts @@ -14,8 +14,8 @@ import * as fs from "node:fs/promises"; import * as path from "node:path"; import { isEnoent, pathIsWithin } from "@oh-my-pi/pi-utils"; +import * as git from "../../../utils/git"; -import { cloneGitRepo } from "./fetcher"; import type { MarketplaceCatalogMetadata, MarketplacePluginEntry, PluginSource } from "./types"; export interface ResolveContext { @@ -87,7 +87,7 @@ async function resolveObjectSource( // { source: "url", url: "https://github.com/owner/repo.git" } // Despite the name, this is typically a git clone URL const targetDir = path.join(context.tmpDir, `plugin-${crypto.randomUUID()}`); - await cloneGitRepo(source.url, targetDir, { ref: source.ref, sha: source.sha }); + await git.clone(source.url, targetDir, { ref: source.ref, sha: source.sha }); return { dir: targetDir, tempCloneRoot: targetDir }; } @@ -95,7 +95,7 @@ async function resolveObjectSource( // { source: "github", repo: "owner/repo" } const url = `https://github.com/${source.repo}.git`; const targetDir = path.join(context.tmpDir, `plugin-${crypto.randomUUID()}`); - await cloneGitRepo(url, targetDir, { ref: source.ref, sha: source.sha }); + await git.clone(url, targetDir, { ref: source.ref, sha: source.sha }); return { dir: targetDir, tempCloneRoot: targetDir }; } @@ -106,7 +106,7 @@ async function resolveObjectSource( ? source.url : `https://github.com/${source.url}.git`; const cloneDir = path.join(context.tmpDir, `plugin-repo-${crypto.randomUUID()}`); - await cloneGitRepo(url, cloneDir, { ref: source.ref, sha: source.sha }); + await git.clone(url, cloneDir, { ref: source.ref, sha: source.sha }); const subdirPath = path.resolve(cloneDir, source.path); if (!pathIsWithin(cloneDir, subdirPath)) { diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 24cc65fd0..adea7792b 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -51,6 +51,7 @@ export * from "./task/executor"; export type * from "./task/types"; // Tools (detail types and utilities) export * from "./tools"; +export * from "./utils/git"; // UI components for extensions export { HookEditorComponent as ExtensionEditorComponent, diff --git a/packages/coding-agent/src/modes/components/footer.ts b/packages/coding-agent/src/modes/components/footer.ts index 85e702b63..7b83b05aa 100644 --- a/packages/coding-agent/src/modes/components/footer.ts +++ b/packages/coding-agent/src/modes/components/footer.ts @@ -5,7 +5,8 @@ import { formatNumber, getProjectDir } from "@oh-my-pi/pi-utils"; import { theme } from "../../modes/theme/theme"; import type { AgentSession } from "../../session/agent-session"; import { shortenPath } from "../../tools/render-utils"; -import { findGitHeadPathAsync, sanitizeStatusText } from "../shared"; +import * as git from "../../utils/git"; +import { sanitizeStatusText } from "../shared"; import { getContextUsageLevel, getContextUsageThemeColor } from "./status-line/context-thresholds"; /** @@ -55,13 +56,13 @@ export class FooterComponent implements Component { this.#gitWatcher = null; } - findGitHeadPathAsync().then(result => { - if (!result) { + git.head.resolve(getProjectDir()).then(head => { + if (!head) { return; } try { - this.#gitWatcher = fs.watch(result.path, () => { + this.#gitWatcher = fs.watch(head.headPath, () => { this.#cachedBranch = undefined; // Invalidate cache if (this.#onBranchChange) { this.#onBranchChange(); @@ -93,35 +94,14 @@ export class FooterComponent implements Component { * Returns null if not in a git repo, branch name otherwise. */ #getCurrentBranch(): string | null { - // Return cached value if available if (this.#cachedBranch !== undefined) { return this.#cachedBranch; } - // Note: fire-and-forget async call - will return undefined on first call - // This is acceptable since it's a cached value that will update on next render - findGitHeadPathAsync().then(result => { - if (!result) { - this.#cachedBranch = null; - if (this.#onBranchChange) { - this.#onBranchChange(); - } - return; - } - const content = result.content.trim(); - - if (content.startsWith("ref: refs/heads/")) { - this.#cachedBranch = content.slice(16); - } else { - this.#cachedBranch = "detached"; - } - if (this.#onBranchChange) { - this.#onBranchChange(); - } - }); - - // Return undefined while loading (will show on next render once loaded) - return null; + const headState = git.head.resolveSync(getProjectDir()); + this.#cachedBranch = + headState === null ? null : headState.kind === "ref" ? (headState.branchName ?? headState.ref) : "detached"; + return this.#cachedBranch; } render(width: number): string[] { diff --git a/packages/coding-agent/src/modes/components/status-line.ts b/packages/coding-agent/src/modes/components/status-line.ts index f343984d2..e2c1895cc 100644 --- a/packages/coding-agent/src/modes/components/status-line.ts +++ b/packages/coding-agent/src/modes/components/status-line.ts @@ -1,20 +1,20 @@ import * as fs from "node:fs"; import type { AssistantMessage } from "@oh-my-pi/pi-ai"; import { type Component, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui"; -import { formatCount } from "@oh-my-pi/pi-utils"; +import { formatCount, getProjectDir } from "@oh-my-pi/pi-utils"; import { $ } from "bun"; import { settings } from "../../config/settings"; import type { StatusLinePreset, StatusLineSegmentId, StatusLineSeparatorStyle } from "../../config/settings-schema"; import { theme } from "../../modes/theme/theme"; import type { AgentSession } from "../../session/agent-session"; import { calculatePromptTokens } from "../../session/compaction/compaction"; -import { findGitHeadPathSync, sanitizeStatusText } from "../shared"; +import * as git from "../../utils/git"; +import { sanitizeStatusText } from "../shared"; import { canReuseCachedPr, createPrCacheContext, isSamePrCacheContext, type PrCacheContext, - parseDefaultBranch, } from "./status-line/git-utils"; import { getPreset } from "./status-line/presets"; import { renderSegment, type SegmentContext } from "./status-line/segments"; @@ -120,7 +120,7 @@ export class StatusLineComponent implements Component { this.#gitWatcher = null; } - const gitHeadPath = findGitHeadPathSync(); + const gitHeadPath = git.repo.resolveSync(getProjectDir())?.headPath ?? null; if (!gitHeadPath) return; try { @@ -152,46 +152,33 @@ export class StatusLineComponent implements Component { this.#cachedPrContext = undefined; } #getCurrentBranch(): string | null { - const gitHeadPath = findGitHeadPathSync(); + const head = git.head.resolveSync(getProjectDir()); + const gitHeadPath = head?.headPath ?? null; if (this.#cachedBranch !== undefined && this.#cachedBranchRepoId === gitHeadPath) { return this.#cachedBranch; } this.#cachedBranchRepoId = gitHeadPath; - if (!gitHeadPath) { + if (!head) { this.#cachedBranch = null; return null; } - try { - const content = fs.readFileSync(gitHeadPath, "utf8").trim(); - - if (content.startsWith("ref: refs/heads/")) { - this.#cachedBranch = content.slice(16); - } else { - this.#cachedBranch = "detached"; - } - } catch { - this.#cachedBranch = null; - } + this.#cachedBranch = head.kind === "ref" ? (head.branchName ?? head.ref) : "detached"; return this.#cachedBranch ?? null; } #isDefaultBranch(branch: string): boolean { if (this.#defaultBranch === undefined) { - // Kick off async resolution, use hardcoded fallback until it resolves this.#defaultBranch = "main"; (async () => { - // Try origin/HEAD first, fall back to upstream/HEAD - const origin = await $`git rev-parse --abbrev-ref origin/HEAD`.quiet().nothrow(); - if (origin.exitCode === 0) { - this.#defaultBranch = parseDefaultBranch(origin.stdout.toString().trim()); - return; - } - const upstream = await $`git rev-parse --abbrev-ref upstream/HEAD`.quiet().nothrow(); - if (upstream.exitCode === 0) { - this.#defaultBranch = parseDefaultBranch(upstream.stdout.toString().trim()); + const resolved = await git.branch.default(getProjectDir()); + if (resolved) { + this.#defaultBranch = resolved; + if (this.#onBranchChange) { + this.#onBranchChange(); + } } })(); } @@ -205,42 +192,9 @@ export class StatusLineComponent implements Component { this.#gitStatusInFlight = true; - // Fire async fetch, return cached value (async () => { try { - const result = await $`git --no-optional-locks status --porcelain`.quiet().nothrow(); - - if (result.exitCode !== 0) { - this.#cachedGitStatus = null; - return; - } - - const output = result.stdout.toString(); - - let staged = 0; - let unstaged = 0; - let untracked = 0; - - for (const line of output.split("\n")) { - if (!line) continue; - const x = line[0]; - const y = line[1]; - - if (x === "?" && y === "?") { - untracked++; - continue; - } - - if (x && x !== " " && x !== "?") { - staged++; - } - - if (y && y !== " ") { - unstaged++; - } - } - - this.#cachedGitStatus = { staged, unstaged, untracked }; + this.#cachedGitStatus = await git.status.summary(getProjectDir()); } catch { this.#cachedGitStatus = null; } finally { diff --git a/packages/coding-agent/src/modes/shared.ts b/packages/coding-agent/src/modes/shared.ts index 1698845e6..af592178d 100644 --- a/packages/coding-agent/src/modes/shared.ts +++ b/packages/coding-agent/src/modes/shared.ts @@ -1,7 +1,4 @@ -import * as fs from "node:fs"; -import * as path from "node:path"; import type { TabBarTheme } from "@oh-my-pi/pi-tui"; -import { getProjectDir, isEnoent } from "@oh-my-pi/pi-utils"; import { theme } from "./theme/theme"; // ═══════════════════════════════════════════════════════════════════════════ @@ -31,42 +28,3 @@ export function getTabBarTheme(): TabBarTheme { } export { parseCommandArgs } from "../utils/command-args"; - -// ═══════════════════════════════════════════════════════════════════════════ -// Git HEAD Discovery -// ═══════════════════════════════════════════════════════════════════════════ - -/** Walk up from the project dir to find .git/HEAD. Returns path and content, or null. */ -export async function findGitHeadPathAsync(): Promise<{ path: string; content: string } | null> { - let dir = getProjectDir(); - while (true) { - const gitHeadPath = path.join(dir, ".git", "HEAD"); - try { - const content = await Bun.file(gitHeadPath).text(); - return { path: gitHeadPath, content }; - } catch (err) { - if (!isEnoent(err)) throw err; - } - const parent = path.dirname(dir); - if (parent === dir) { - return null; - } - dir = parent; - } -} - -/** Walk up from the project dir to find .git/HEAD. Returns path, or null. */ -export function findGitHeadPathSync(): string | null { - let dir = getProjectDir(); - while (true) { - const gitHeadPath = path.join(dir, ".git", "HEAD"); - if (fs.existsSync(gitHeadPath)) { - return gitHeadPath; - } - const parent = path.dirname(dir); - if (parent === dir) { - return null; - } - dir = parent; - } -} diff --git a/packages/coding-agent/src/task/index.ts b/packages/coding-agent/src/task/index.ts index 88f82aa98..a1f94e549 100644 --- a/packages/coding-agent/src/task/index.ts +++ b/packages/coding-agent/src/task/index.ts @@ -18,7 +18,6 @@ import path from "node:path"; import type { AgentTool, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; import type { Usage } from "@oh-my-pi/pi-ai"; import { $env, Snowflake } from "@oh-my-pi/pi-utils"; -import { $ } from "bun"; import type { ToolSession } from ".."; import { resolveAgentModelPatterns } from "../config/model-resolver"; import { renderPromptTemplate } from "../config/prompt-templates"; @@ -30,6 +29,7 @@ import { formatBytes, formatDuration } from "../tools/render-utils"; // Import review tools for side effects (registers subagent tool handlers) import "../tools/review"; import { generateCommitMessage } from "../utils/commit-message-generator"; +import * as git from "../utils/git"; import { discoverAgents, getAgent } from "./discovery"; import { runSubprocess } from "./executor"; import { resolveIsolationBackendForTaskExecution } from "./isolation-backend"; @@ -864,7 +864,7 @@ export class TaskTool implements AgentTool { } catch (mergeErr) { // Agent succeeded but branch commit failed — clean up stale branch const branchName = `omp/task/${task.id}`; - await $`git branch -D ${branchName}`.cwd(repoRoot).quiet().nothrow(); + await git.branch.tryDelete(repoRoot, branchName); const msg = mergeErr instanceof Error ? mergeErr.message : String(mergeErr); return { ...result, error: `Merge failed: ${msg}` }; } @@ -1031,24 +1031,13 @@ export class TaskTool implements AgentTool { if (!combinedPatch.trim()) { changesApplied = true; } else { - const combinedPatchPath = path.join(os.tmpdir(), `omp-task-combined-${Snowflake.next()}.patch`); - try { - await Bun.write(combinedPatchPath, combinedPatch); - const checkResult = await $`git apply --check --binary ${combinedPatchPath}` - .cwd(repoRoot) - .quiet() - .nothrow(); - if (checkResult.exitCode !== 0) { + changesApplied = await git.patch.canApplyText(repoRoot, combinedPatch); + if (changesApplied) { + try { + await git.patch.applyText(repoRoot, combinedPatch); + } catch { changesApplied = false; - } else { - const applyResult = await $`git apply --binary ${combinedPatchPath}` - .cwd(repoRoot) - .quiet() - .nothrow(); - changesApplied = applyResult.exitCode === 0; } - } finally { - await fs.rm(combinedPatchPath, { force: true }); } } } diff --git a/packages/coding-agent/src/task/worktree.ts b/packages/coding-agent/src/task/worktree.ts index 2e09ffc67..77e9eafd9 100644 --- a/packages/coding-agent/src/task/worktree.ts +++ b/packages/coding-agent/src/task/worktree.ts @@ -1,10 +1,11 @@ import type { Dirent } from "node:fs"; import * as fs from "node:fs/promises"; import * as os from "node:os"; -import path from "node:path"; +import * as path from "node:path"; import { projfsOverlayStart, projfsOverlayStop } from "@oh-my-pi/pi-natives"; import { getWorktreeDir, isEnoent, logger, Snowflake } from "@oh-my-pi/pi-utils"; import { $ } from "bun"; +import * as git from "../utils/git"; /** Baseline state for a single git repository. */ export interface RepoBaseline { @@ -27,14 +28,11 @@ export function getEncodedProjectName(cwd: string): string { } export async function getRepoRoot(cwd: string): Promise { - const result = await $`git rev-parse --show-toplevel`.cwd(cwd).quiet().nothrow(); - if (result.exitCode !== 0) { + const repoRoot = await git.repo.root(cwd); + if (!repoRoot) { throw new Error("Git repository not found for isolated task execution."); } - const repoRoot = result.text().trim(); - if (!repoRoot) { - throw new Error("Git repository root could not be resolved for isolated task execution."); - } + return repoRoot; } @@ -54,26 +52,16 @@ export async function ensureWorktree(baseCwd: string, id: string): Promise { // Get submodule paths so we can exclude them - const submoduleRaw = await $`git submodule --quiet foreach --recursive 'echo $sm_path'` - .cwd(repoRoot) - .quiet() - .nothrow() - .text(); - const submodulePaths = new Set( - submoduleRaw - .split("\n") - .map(l => l.trim()) - .filter(Boolean), - ); + const submodulePaths = new Set(await git.ls.submodules(repoRoot)); // Find all .git dirs/files that aren't the root or known submodules const result: string[] = []; @@ -109,14 +97,10 @@ async function discoverNestedRepos(repoRoot: string): Promise { } async function captureRepoBaseline(repoRoot: string): Promise { - const headCommit = (await $`git rev-parse HEAD`.cwd(repoRoot).quiet().text()).trim(); - const staged = await $`git diff --cached --binary`.cwd(repoRoot).quiet().text(); - const unstaged = await $`git diff --binary`.cwd(repoRoot).quiet().text(); - const untrackedRaw = await $`git ls-files --others --exclude-standard`.cwd(repoRoot).quiet().text(); - const untracked = untrackedRaw - .split("\n") - .map(line => line.trim()) - .filter(line => line.length > 0); + const headCommit = (await git.head.sha(repoRoot)) ?? ""; + const staged = await git.diff(repoRoot, { binary: true, cached: true }); + const unstaged = await git.diff(repoRoot, { binary: true }); + const untracked = await git.ls.untracked(repoRoot); return { repoRoot, headCommit, staged, unstaged, untracked }; } @@ -131,35 +115,10 @@ export async function captureBaseline(repoRoot: string): Promise { - const tempPath = path.join(os.tmpdir(), `omp-task-patch-${Snowflake.next()}.patch`); - await Bun.write(tempPath, patch); - return tempPath; -} - -async function applyPatch( - cwd: string, - patch: string, - options?: { cached?: boolean; env?: Record }, -): Promise { - if (!patch.trim()) return; - const tempPath = await writeTempPatchFile(patch); - try { - const command = options?.cached ? $`git apply --cached --binary ${tempPath}` : $`git apply --binary ${tempPath}`; - let runner = command.cwd(cwd).quiet(); - if (options?.env) { - runner = runner.env(options.env); - } - await runner; - } finally { - await fs.rm(tempPath, { force: true }); - } -} - async function applyRepoBaseline(worktreeDir: string, rb: RepoBaseline, sourceRoot: string): Promise { - await applyPatch(worktreeDir, rb.staged, { cached: true }); - await applyPatch(worktreeDir, rb.staged); - await applyPatch(worktreeDir, rb.unstaged); + await git.patch.applyText(worktreeDir, rb.staged, { cached: true }); + await git.patch.applyText(worktreeDir, rb.staged); + await git.patch.applyText(worktreeDir, rb.unstaged); for (const entry of rb.untracked) { const source = path.join(sourceRoot, entry); @@ -193,15 +152,12 @@ export async function applyBaseline(worktreeDir: string, baseline: WorktreeBasel // Commit baseline state so captureRepoDeltaPatch can cleanly subtract it. // Without this, `git add -A && git commit` by the task would include // baseline untracked files in the diff-tree output. - const hasChanges = ( - await $`git --no-optional-locks status --porcelain`.cwd(nestedDir).quiet().nothrow().text() - ).trim(); - if (hasChanges) { - await $`git add -A`.cwd(nestedDir).quiet(); - await $`git commit -m omp-baseline --allow-empty`.cwd(nestedDir).quiet(); + if ((await git.status(nestedDir)).trim().length > 0) { + await git.stage.files(nestedDir); + await git.commit(nestedDir, "omp-baseline", { allowEmpty: true }); // Update baseline to reflect the committed state — prevents double-apply // in captureRepoDeltaPatch's temp-index path - entry.baseline.headCommit = (await $`git rev-parse HEAD`.cwd(nestedDir).quiet().text()).trim(); + entry.baseline.headCommit = (await git.head.sha(nestedDir)) ?? ""; entry.baseline.staged = ""; entry.baseline.unstaged = ""; entry.baseline.untracked = []; @@ -209,32 +165,9 @@ export async function applyBaseline(worktreeDir: string, baseline: WorktreeBasel } } -async function applyPatchToIndex(cwd: string, patch: string, indexFile: string): Promise { - if (!patch.trim()) return; - const tempPath = await writeTempPatchFile(patch); - try { - await $`git apply --cached --binary ${tempPath}` - .cwd(cwd) - .env({ - GIT_INDEX_FILE: indexFile, - }) - .quiet(); - } finally { - await fs.rm(tempPath, { force: true }); - } -} - -async function listUntracked(cwd: string): Promise { - const raw = await $`git ls-files --others --exclude-standard`.cwd(cwd).quiet().text(); - return raw - .split("\n") - .map(line => line.trim()) - .filter(line => line.length > 0); -} - async function captureRepoDeltaPatch(repoDir: string, rb: RepoBaseline): Promise { // Check if HEAD advanced (task committed changes) - const currentHead = (await $`git rev-parse HEAD`.cwd(repoDir).quiet().nothrow().text()).trim(); + const currentHead = (await git.head.sha(repoDir)) ?? ""; const headAdvanced = currentHead && currentHead !== rb.headCommit; if (headAdvanced) { @@ -242,28 +175,31 @@ async function captureRepoDeltaPatch(repoDir: string, rb: RepoBaseline): Promise const parts: string[] = []; // Committed changes since baseline - const committedDiff = await $`git diff-tree -r -p --binary ${rb.headCommit} ${currentHead}` - .cwd(repoDir) - .quiet() - .nothrow() - .text(); + const committedDiff = await git.diff.tree(repoDir, rb.headCommit, currentHead, { + allowFailure: true, + binary: true, + }); if (committedDiff.trim()) parts.push(committedDiff); // Uncommitted changes on top of the new HEAD - const staged = await $`git diff --cached --binary`.cwd(repoDir).quiet().text(); - const unstaged = await $`git diff --binary`.cwd(repoDir).quiet().text(); + const staged = await git.diff(repoDir, { binary: true, cached: true }); + const unstaged = await git.diff(repoDir, { binary: true }); if (staged.trim()) parts.push(staged); if (unstaged.trim()) parts.push(unstaged); // New untracked files (relative to both baseline and current tracking) - const currentUntracked = await listUntracked(repoDir); + const currentUntracked = await git.ls.untracked(repoDir); const baselineUntracked = new Set(rb.untracked); const newUntracked = currentUntracked.filter(entry => !baselineUntracked.has(entry)); if (newUntracked.length > 0) { const nullPath = getGitNoIndexNullPath(); const untrackedDiffs = await Promise.all( newUntracked.map(entry => - $`git diff --binary --no-index ${nullPath} ${entry}`.cwd(repoDir).quiet().nothrow().text(), + git.diff(repoDir, { + allowFailure: true, + binary: true, + noIndex: { left: nullPath, right: entry }, + }), ), ); parts.push(...untrackedDiffs.filter(d => d.trim())); @@ -275,12 +211,23 @@ async function captureRepoDeltaPatch(repoDir: string, rb: RepoBaseline): Promise // HEAD unchanged: use temp index approach (subtracts baseline from delta) const tempIndex = path.join(os.tmpdir(), `omp-task-index-${Snowflake.next()}`); try { - await $`git read-tree ${rb.headCommit}`.cwd(repoDir).env({ GIT_INDEX_FILE: tempIndex }); - await applyPatchToIndex(repoDir, rb.staged, tempIndex); - await applyPatchToIndex(repoDir, rb.unstaged, tempIndex); - const diff = await $`git diff --binary`.cwd(repoDir).env({ GIT_INDEX_FILE: tempIndex }).quiet().text(); + await git.readTree(repoDir, rb.headCommit, { + env: { GIT_INDEX_FILE: tempIndex }, + }); + await git.patch.applyText(repoDir, rb.staged, { + cached: true, + env: { GIT_INDEX_FILE: tempIndex }, + }); + await git.patch.applyText(repoDir, rb.unstaged, { + cached: true, + env: { GIT_INDEX_FILE: tempIndex }, + }); + const diff = await git.diff(repoDir, { + binary: true, + env: { GIT_INDEX_FILE: tempIndex }, + }); - const currentUntracked = await listUntracked(repoDir); + const currentUntracked = await git.ls.untracked(repoDir); const baselineUntracked = new Set(rb.untracked); const newUntracked = currentUntracked.filter(entry => !baselineUntracked.has(entry)); @@ -289,7 +236,11 @@ async function captureRepoDeltaPatch(repoDir: string, rb: RepoBaseline): Promise const nullPath = getGitNoIndexNullPath(); const untrackedDiffs = await Promise.all( newUntracked.map(entry => - $`git diff --binary --no-index ${nullPath} ${entry}`.cwd(repoDir).quiet().nothrow().text(), + git.diff(repoDir, { + allowFailure: true, + binary: true, + noIndex: { left: nullPath, right: entry }, + }), ), ); return `${diff}${diff && !diff.endsWith("\n") ? "\n" : ""}${untrackedDiffs.join("\n")}`; @@ -355,29 +306,25 @@ export async function applyNestedPatches( const combinedDiff = repoPatches.map(p => p.patch).join("\n"); for (const { patch } of repoPatches) { - await applyPatch(nestedDir, patch); + await git.patch.applyText(nestedDir, patch); } // Commit so nested repo history reflects the task changes - const hasChanges = ( - await $`git --no-optional-locks status --porcelain`.cwd(nestedDir).quiet().nothrow().text() - ).trim(); - if (hasChanges) { + if ((await git.status(nestedDir)).trim().length > 0) { const msg = (await commitMessage?.(combinedDiff)) ?? "changes from isolated task(s)"; - await $`git add -A`.cwd(nestedDir).quiet(); - await $`git commit -m ${msg}`.cwd(nestedDir).quiet(); + await git.stage.files(nestedDir); + await git.commit(nestedDir, msg); } } } export async function cleanupWorktree(dir: string): Promise { try { - const commonDirRaw = await $`git rev-parse --git-common-dir`.cwd(dir).quiet().nothrow().text(); - const commonDir = commonDirRaw.trim(); - if (commonDir) { - const resolvedCommon = path.resolve(dir, commonDir); - const repoRoot = path.dirname(resolvedCommon); - await $`git worktree remove -f ${dir}`.cwd(repoRoot).quiet().nothrow(); + const repository = await git.repo.resolve(dir); + const commonDir = repository?.commonDir ?? ""; + if (commonDir && path.basename(commonDir) === ".git") { + const repoRoot = path.dirname(commonDir); + await git.worktree.tryRemove(repoRoot, dir); } } finally { await fs.rm(dir, { recursive: true, force: true }); @@ -518,33 +465,31 @@ export async function commitToBranch( // Only create a branch if the root repo has changes if (rootPatch.trim()) { - await $`git branch ${branchName} HEAD`.cwd(repoRoot).quiet(); + await git.branch.create(repoRoot, branchName); const tmpDir = path.join(os.tmpdir(), `omp-branch-${Snowflake.next()}`); try { - await $`git worktree add ${tmpDir} ${branchName}`.cwd(repoRoot).quiet(); - const patchPath = path.join(os.tmpdir(), `omp-branch-patch-${Snowflake.next()}.patch`); + await git.worktree.add(repoRoot, tmpDir, branchName); try { - await Bun.write(patchPath, rootPatch); - const applyResult = await $`git apply --binary ${patchPath}`.cwd(tmpDir).quiet().nothrow(); - if (applyResult.exitCode !== 0) { - const stderr = applyResult.stderr.toString().slice(0, 2000); + await git.patch.applyText(tmpDir, rootPatch); + } catch (err) { + if (err instanceof git.GitCommandError) { + const stderr = err.result.stderr.slice(0, 2000); logger.error("commitToBranch: git apply failed", { taskId, - exitCode: applyResult.exitCode, + exitCode: err.result.exitCode, stderr, patchSize: rootPatch.length, patchHead: rootPatch.slice(0, 500), }); throw new Error(`git apply failed for task ${taskId}: ${stderr}`); } - } finally { - await fs.rm(patchPath, { force: true }); + throw err; } - await $`git add -A`.cwd(tmpDir).quiet(); + await git.stage.files(tmpDir); const msg = (commitMessage && (await commitMessage(rootPatch))) || fallbackMessage; - await $`git commit -m ${msg}`.cwd(tmpDir).quiet(); + await git.commit(tmpDir, msg); } finally { - await $`git worktree remove -f ${tmpDir}`.cwd(repoRoot).quiet().nothrow(); + await git.worktree.tryRemove(repoRoot, tmpDir); await fs.rm(tmpDir, { recursive: true, force: true }); } } @@ -571,11 +516,16 @@ export async function mergeTaskBranches( const failed: string[] = []; for (const { branchName } of branches) { - const result = await $`git cherry-pick ${branchName}`.cwd(repoRoot).quiet().nothrow(); - - if (result.exitCode !== 0) { - await $`git cherry-pick --abort`.cwd(repoRoot).quiet().nothrow(); - const stderr = result.stderr.toString().trim(); + try { + await git.cherryPick(repoRoot, branchName); + } catch (err) { + await git.cherryPick.abort(repoRoot); + const stderr = + err instanceof git.GitCommandError + ? err.result.stderr.trim() + : err instanceof Error + ? err.message + : String(err); failed.push(branchName); return { merged, @@ -593,6 +543,6 @@ export async function mergeTaskBranches( /** Clean up temporary task branches. */ export async function cleanupTaskBranches(repoRoot: string, branches: string[]): Promise { for (const branch of branches) { - await $`git branch -D ${branch}`.cwd(repoRoot).quiet().nothrow(); + await git.branch.tryDelete(repoRoot, branch); } } diff --git a/packages/coding-agent/src/tools/gh.ts b/packages/coding-agent/src/tools/gh.ts index d89b11c33..7c7c86a3d 100644 --- a/packages/coding-agent/src/tools/gh.ts +++ b/packages/coding-agent/src/tools/gh.ts @@ -3,7 +3,6 @@ import * as path from "node:path"; import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; import { abortableSleep, isEnoent, untilAborted } from "@oh-my-pi/pi-utils"; import { type Static, Type } from "@sinclair/typebox"; -import { $ } from "bun"; import { renderPromptTemplate } from "../config/prompt-templates"; import ghIssueViewDescription from "../prompts/tools/gh-issue-view.md" with { type: "text" }; import ghPrCheckoutDescription from "../prompts/tools/gh-pr-checkout.md" with { type: "text" }; @@ -14,6 +13,7 @@ import ghRepoViewDescription from "../prompts/tools/gh-repo-view.md" with { type import ghRunWatchDescription from "../prompts/tools/gh-run-watch.md" with { type: "text" }; import ghSearchIssuesDescription from "../prompts/tools/gh-search-issues.md" with { type: "text" }; import ghSearchPrsDescription from "../prompts/tools/gh-search-prs.md" with { type: "text" }; +import * as git from "../utils/git"; import type { ToolSession } from "."; import { isGhAvailable, runGhCommand, runGhJson, runGhText } from "./gh-cli"; import type { OutputMeta } from "./output-meta"; @@ -401,19 +401,6 @@ interface GhPrViewData extends GhIssueViewData { reviewDecision?: string; } -interface GitCommandResult { - exitCode: number; - stdout: string; - stderr: string; -} - -interface GitWorktreeEntry { - path: string; - head?: string; - branch?: string; - detached: boolean; -} - interface GhPrReviewCommit { oid?: string | null; } @@ -641,142 +628,45 @@ function stripHeadsRef(value: string | undefined): string | undefined { return value.startsWith("refs/heads/") ? value.slice("refs/heads/".length) : value; } -function formatGitFailure(args: string[], result: GitCommandResult): string { - const output = normalizeOptionalString(result.stderr) ?? normalizeOptionalString(result.stdout); - if (output) { - return output; - } - - return `git ${args.join(" ")} failed with exit code ${result.exitCode}`; -} - -async function runGitCommand(cwd: string, args: string[], signal?: AbortSignal): Promise { - return untilAborted(signal, async () => { - throwIfAborted(signal); - const child = Bun.spawn(["git", ...args], { - cwd, - stdin: "ignore", - stdout: "pipe", - stderr: "pipe", - windowsHide: true, - signal, - }); - throwIfAborted(signal); - - if (!child.stdout || !child.stderr) { - throw new ToolError("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, - ]); - throwIfAborted(signal); - - return { - exitCode: exitCode ?? 0, - stdout: normalizeBlock(stdout), - stderr: normalizeBlock(stderr), - }; - }); -} - -async function runGitTextChecked(cwd: string, args: string[], signal?: AbortSignal): Promise { - const result = await runGitChecked(cwd, args, signal); - - const text = normalizeOptionalString(result.stdout); - if (!text) { - throw new ToolError(`git ${args.join(" ")} returned empty output.`); - } - - return text; -} - -async function runGitChecked(cwd: string, args: string[], signal?: AbortSignal): Promise { - const result = await runGitCommand(cwd, args, signal); - if (result.exitCode !== 0) { - throw new ToolError(formatGitFailure(args, result)); - } - - return result; -} - -async function tryRunGitText(cwd: string, args: string[], signal?: AbortSignal): Promise { - const result = await runGitCommand(cwd, args, signal); - if (result.exitCode !== 0) { - return undefined; - } - - return normalizeOptionalString(result.stdout); -} - -async function resolveGitRepoRoot(cwd: string, signal?: AbortSignal): Promise { - return runGitTextChecked(cwd, ["rev-parse", "--show-toplevel"], signal); -} - -async function resolvePrimaryGitRepoRoot(repoRoot: string, signal?: AbortSignal): Promise { - const commonDir = await runGitTextChecked( - repoRoot, - ["rev-parse", "--path-format=absolute", "--git-common-dir"], - signal, - ); - if (path.basename(commonDir) === ".git") { - return path.dirname(commonDir); +async function requireGitRepoRoot(cwd: string, signal?: AbortSignal): Promise { + const repoRoot = await git.repo.root(cwd, signal); + if (!repoRoot) { + throw new ToolError("Current git repository is unavailable."); } return repoRoot; } -function parseGitWorktreeList(text: string): GitWorktreeEntry[] { - const trimmed = text.trim(); - if (!trimmed) { - return []; +async function requirePrimaryGitRepoRoot(cwd: string, signal?: AbortSignal): Promise { + const primaryRepoRoot = await git.repo.primaryRoot(cwd, signal); + if (!primaryRepoRoot) { + throw new ToolError("Current git repository is unavailable."); } - return trimmed - .split(/\n\s*\n/) - .map(block => block.trim()) - .filter(Boolean) - .map(block => { - const entry: GitWorktreeEntry = { - path: "", - detached: false, - }; - for (const line of block.split("\n")) { - if (line.startsWith("worktree ")) { - entry.path = line.slice("worktree ".length); - continue; - } - if (line.startsWith("HEAD ")) { - entry.head = line.slice("HEAD ".length); - continue; - } - if (line.startsWith("branch ")) { - entry.branch = line.slice("branch ".length); - continue; - } - if (line === "detached") { - entry.detached = true; - } - } - return entry; - }); + return primaryRepoRoot; } -async function listGitWorktrees(repoRoot: string, signal?: AbortSignal): Promise { - const output = await runGitTextChecked(repoRoot, ["worktree", "list", "--porcelain"], signal); - return parseGitWorktreeList(output); +async function requireCurrentGitBranch(cwd: string, signal?: AbortSignal): Promise { + const branch = await git.branch.current(cwd, signal); + if (!branch) { + throw new ToolError("Current git branch is unavailable. Pass `branch` or `run` explicitly."); + } + + return branch; } -async function gitRefExists(repoRoot: string, ref: string, signal?: AbortSignal): Promise { - const result = await runGitCommand(repoRoot, ["show-ref", "--verify", "--quiet", ref], signal); - return result.exitCode === 0; +async function requireCurrentGitHead(cwd: string, signal?: AbortSignal): Promise { + const headSha = await git.head.sha(cwd, signal); + if (!headSha) { + throw new ToolError("Current git HEAD is unavailable. Pass `run` explicitly."); + } + + return headSha; } async function ensureGitWorktreePathAvailable( worktreePath: string, - existingWorktrees: GitWorktreeEntry[], + existingWorktrees: git.GitWorktreeEntry[], ): Promise { const normalizedTarget = path.resolve(worktreePath); const conflictingWorktree = existingWorktrees.find(entry => path.resolve(entry.path) === normalizedTarget); @@ -804,15 +694,10 @@ function selectPrCloneUrl(originUrl: string | undefined, repo: Pick> { - const remoteList = await tryRunGitText(repoRoot, ["remote"], signal); - const remotes = - remoteList - ?.split("\n") - .map(value => value.trim()) - .filter(Boolean) ?? []; + const remotes = await git.remote.list(repoRoot, signal); const urls = new Map(); for (const remoteName of remotes) { - const remoteUrl = await tryRunGitText(repoRoot, ["remote", "get-url", remoteName], signal); + const remoteUrl = await git.remote.url(repoRoot, remoteName, signal); if (remoteUrl) { urls.set(remoteName, remoteUrl); } @@ -826,7 +711,7 @@ async function ensurePrRemote( signal?: AbortSignal, ): Promise<{ name: string; url: string }> { if (!data.isCrossRepository) { - const originUrl = normalizeOptionalString(await tryRunGitText(repoRoot, ["remote", "get-url", "origin"], signal)); + const originUrl = await git.remote.url(repoRoot, "origin", signal); if (!originUrl) { throw new ToolError("origin remote is unavailable for this repository."); } @@ -844,7 +729,7 @@ async function ensurePrRemote( signal, { repoProvided: true }, ); - const originUrl = await tryRunGitText(repoRoot, ["remote", "get-url", "origin"], signal); + const originUrl = await git.remote.url(repoRoot, "origin", signal); const remoteUrl = selectPrCloneUrl(originUrl, repoSummary); if (!remoteUrl) { throw new ToolError(`Could not determine a clone URL for ${headRepository}.`); @@ -867,10 +752,7 @@ async function ensurePrRemote( suffix += 1; } - const result = await runGitCommand(repoRoot, ["remote", "add", remoteName, remoteUrl], signal); - if (result.exitCode !== 0) { - throw new ToolError(formatGitFailure(["remote", "add", remoteName, remoteUrl], result)); - } + await git.remote.add(repoRoot, remoteName, remoteUrl, signal); return { name: remoteName, @@ -878,28 +760,6 @@ async function ensurePrRemote( }; } -async function setBranchConfig( - repoRoot: string, - localBranch: string, - key: string, - value: string, - signal?: AbortSignal, -): Promise { - const result = await runGitCommand(repoRoot, ["config", `branch.${localBranch}.${key}`, value], signal); - if (result.exitCode !== 0) { - throw new ToolError(formatGitFailure(["config", `branch.${localBranch}.${key}`, value], result)); - } -} - -async function getBranchConfig( - repoRoot: string, - localBranch: string, - key: string, - signal?: AbortSignal, -): Promise { - return tryRunGitText(repoRoot, ["config", "--get", `branch.${localBranch}.${key}`], signal); -} - async function resolvePrBranchPushTarget( repoRoot: string, localBranch: string, @@ -912,13 +772,18 @@ async function resolvePrBranchPushTarget( maintainerCanModify?: boolean; isCrossRepository: boolean; }> { - const pushRemote = await getBranchConfig(repoRoot, localBranch, "pushRemote", signal); - const remote = await getBranchConfig(repoRoot, localBranch, "remote", signal); - const mergeRef = await getBranchConfig(repoRoot, localBranch, "merge", signal); - const headRef = await getBranchConfig(repoRoot, localBranch, "ompPrHeadRef", signal); - const prUrl = await getBranchConfig(repoRoot, localBranch, "ompPrUrl", signal); - const maintainerCanModifyValue = await getBranchConfig(repoRoot, localBranch, "ompPrMaintainerCanModify", signal); - const isCrossRepositoryValue = await getBranchConfig(repoRoot, localBranch, "ompPrIsCrossRepository", signal); + const pushRemote = await git.config.getBranch(repoRoot, localBranch, "pushRemote", signal); + const remote = await git.config.getBranch(repoRoot, localBranch, "remote", signal); + const mergeRef = await git.config.getBranch(repoRoot, localBranch, "merge", signal); + const headRef = await git.config.getBranch(repoRoot, localBranch, "ompPrHeadRef", signal); + const prUrl = await git.config.getBranch(repoRoot, localBranch, "ompPrUrl", signal); + const maintainerCanModifyValue = await git.config.getBranch( + repoRoot, + localBranch, + "ompPrMaintainerCanModify", + signal, + ); + const isCrossRepositoryValue = await git.config.getBranch(repoRoot, localBranch, "ompPrIsCrossRepository", signal); const remoteName = pushRemote ?? remote; if (!remoteName) { @@ -933,7 +798,7 @@ async function resolvePrBranchPushTarget( return { remoteName, remoteBranch, - remoteUrl: await tryRunGitText(repoRoot, ["remote", "get-url", remoteName], signal), + remoteUrl: await git.remote.url(repoRoot, remoteName, signal), prUrl, maintainerCanModify: maintainerCanModifyValue === undefined @@ -1487,44 +1352,6 @@ function buildCommitRunWatchDetails( }; } -async function resolveCurrentGitBranch(cwd: string, signal?: AbortSignal): Promise { - return untilAborted(signal, async () => { - throwIfAborted(signal); - const result = await $`git symbolic-ref --short HEAD`.cwd(cwd).quiet().nothrow(); - throwIfAborted(signal); - - if (result.exitCode !== 0) { - throw new ToolError("Current git branch is unavailable. Pass `branch` or `run` explicitly."); - } - - const branch = normalizeOptionalString(result.text()); - if (!branch) { - throw new ToolError("Current git branch is unavailable. Pass `branch` or `run` explicitly."); - } - - return branch; - }); -} - -async function resolveCurrentGitHead(cwd: string, signal?: AbortSignal): Promise { - return untilAborted(signal, async () => { - throwIfAborted(signal); - const result = await $`git rev-parse HEAD`.cwd(cwd).quiet().nothrow(); - throwIfAborted(signal); - - if (result.exitCode !== 0) { - throw new ToolError("Current git HEAD is unavailable. Pass `run` explicitly."); - } - - const headSha = normalizeOptionalString(result.text()); - if (!headSha) { - throw new ToolError("Current git HEAD is unavailable. Pass `run` explicitly."); - } - - return headSha; - }); -} - async function resolveGitHubRepo( cwd: string, repo: string | undefined, @@ -2273,68 +2100,56 @@ export class GhPrCheckoutTool implements AgentTool entry.branch === toLocalBranchRef(localBranch)); const remote = await ensurePrRemote(repoRoot, data, signal); - await runGitChecked( + await git.fetch( repoRoot, - ["fetch", remote.name, `+refs/heads/${headRefName}:refs/remotes/${remote.name}/${headRefName}`], + remote.name, + `refs/heads/${headRefName}`, + `refs/remotes/${remote.name}/${headRefName}`, signal, ); if (!existingWorktree) { const localBranchRef = toLocalBranchRef(localBranch); - const localBranchExists = await gitRefExists(repoRoot, localBranchRef, signal); + const localBranchExists = await git.ref.exists(repoRoot, localBranchRef, signal); if (localBranchExists) { - const existingOid = await runGitTextChecked(repoRoot, ["rev-parse", localBranchRef], signal); + const existingOid = await git.ref.resolve(repoRoot, localBranchRef, signal); if (existingOid !== headRefOid) { if (!force) { throw new ToolError( - `local branch ${localBranch} already exists at ${formatShortSha(existingOid) ?? existingOid}; pass force=true to reset it`, + `local branch ${localBranch} already exists at ${formatShortSha(existingOid ?? undefined) ?? existingOid ?? "unknown commit"}; pass force=true to reset it`, ); } - const resetResult = await runGitCommand( - repoRoot, - ["branch", "--force", localBranch, `refs/remotes/${remote.name}/${headRefName}`], - signal, - ); - if (resetResult.exitCode !== 0) { - throw new ToolError(formatGitFailure(["branch", "--force", localBranch], resetResult)); - } + await git.branch.force(repoRoot, localBranch, `refs/remotes/${remote.name}/${headRefName}`, signal); } } else { - const createResult = await runGitCommand( - repoRoot, - ["branch", localBranch, `refs/remotes/${remote.name}/${headRefName}`], - signal, - ); - if (createResult.exitCode !== 0) { - throw new ToolError(formatGitFailure(["branch", localBranch], createResult)); - } + await git.branch.create(repoRoot, localBranch, `refs/remotes/${remote.name}/${headRefName}`, signal); } } - await setBranchConfig(repoRoot, localBranch, "remote", remote.name, signal); - await setBranchConfig(repoRoot, localBranch, "merge", `refs/heads/${headRefName}`, signal); - await setBranchConfig(repoRoot, localBranch, "pushRemote", remote.name, signal); - await setBranchConfig(repoRoot, localBranch, "ompPrHeadRef", headRefName, signal); - await setBranchConfig(repoRoot, localBranch, "ompPrUrl", data.url ?? "", signal); - await setBranchConfig( + await git.config.setBranch(repoRoot, localBranch, "remote", remote.name, signal); + await git.config.setBranch(repoRoot, localBranch, "merge", `refs/heads/${headRefName}`, signal); + await git.config.setBranch(repoRoot, localBranch, "pushRemote", remote.name, signal); + await git.config.setBranch(repoRoot, localBranch, "ompPrHeadRef", headRefName, signal); + await git.config.setBranch(repoRoot, localBranch, "ompPrUrl", data.url ?? "", signal); + await git.config.setBranch( repoRoot, localBranch, "ompPrIsCrossRepository", String(Boolean(data.isCrossRepository)), signal, ); - await setBranchConfig( + await git.config.setBranch( repoRoot, localBranch, "ompPrMaintainerCanModify", @@ -2346,14 +2161,7 @@ export class GhPrCheckoutTool implements AgentTool> { return untilAborted(signal, async () => { - const repoRoot = await resolveGitRepoRoot(this.session.cwd, signal); + const repoRoot = await requireGitRepoRoot(this.session.cwd, signal); const localBranch = - normalizeOptionalString(params.branch) ?? (await resolveCurrentGitBranch(repoRoot, signal)); - const refExists = await gitRefExists(repoRoot, toLocalBranchRef(localBranch), signal); + normalizeOptionalString(params.branch) ?? (await requireCurrentGitBranch(repoRoot, signal)); + const refExists = await git.ref.exists(repoRoot, toLocalBranchRef(localBranch), signal); if (!refExists) { throw new ToolError(`local branch ${localBranch} does not exist`); } const target = await resolvePrBranchPushTarget(repoRoot, localBranch, signal); - const currentBranch = await tryRunGitText(repoRoot, ["branch", "--show-current"], signal); + const currentBranch = await git.branch.current(repoRoot, signal); const sourceRef = currentBranch === localBranch ? "HEAD" : toLocalBranchRef(localBranch); const refspec = `${sourceRef}:refs/heads/${target.remoteBranch}`; - const pushArgs = ["push"]; - if (params.forceWithLease) { - pushArgs.push("--force-with-lease"); - } - pushArgs.push(target.remoteName, refspec); - - const pushResult = await runGitCommand(repoRoot, pushArgs, signal); - if (pushResult.exitCode !== 0) { - throw new ToolError(formatGitFailure(pushArgs, pushResult)); - } + await git.push(repoRoot, { + forceWithLease: params.forceWithLease, + refspec, + remote: target.remoteName, + signal, + }); return buildTextResult( formatPrPushResult({ @@ -2617,10 +2421,10 @@ export class GhRunWatchTool implements AgentTool; + 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; + +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 (!Bun.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 = options.readOnly ? withNoOptionalLocks(args) : [...args]; + const child = Bun.spawn(["git", ...commandArgs], { + cwd, + env: options.env ? { ...process.env, ...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]; +} + +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; +} + +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 getEntryTypeSync(gitEntryPath: string): EntryType | null { + try { + const stat = fs.statSync(gitEntryPath); + if (stat.isDirectory()) return "directory"; + if (stat.isFile()) return "file"; + return null; + } catch (err) { + if (isEnoent(err)) return null; + throw err; + } +} + +async function getEntryType(gitEntryPath: string): Promise { + try { + const stat = await fs.promises.stat(gitEntryPath); + if (stat.isDirectory()) return "directory"; + if (stat.isFile()) return "file"; + return null; + } catch (err) { + if (isEnoent(err)) return null; + throw err; + } +} + +function readOptionalTextSync(filePath: string): string | null { + try { + return fs.readFileSync(filePath, "utf8"); + } catch (err) { + if (isEnoent(err)) return null; + throw err; + } +} + +async function readOptionalText(filePath: string): Promise { + try { + return await Bun.file(filePath).text(); + } catch (err) { + if (isEnoent(err)) return null; + throw err; + } +} + +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 working tree diff. */ + async hunks(cwd: string, selections: HunkSelection[], signal?: AbortSignal): Promise { + if (selections.length === 0) return; + const rawDiff = await diff(cwd, { cached: false, 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 }); + }, + + /** 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); +} + +// ════════════════════════════════════════════════════════════════════════════ +// 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 new remote. */ + async add(cwd: string, name: string, url: string, signal?: AbortSignal): Promise { + await runEffect(cwd, ["remote", "add", name, url], { signal }); + }, +}; + +// ════════════════════════════════════════════════════════════════════════════ +// 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 })); + }, +}; + +// ════════════════════════════════════════════════════════════════════════════ +// 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: 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 }); +} + +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); +} diff --git a/packages/coding-agent/test/commit-agentic-attribution.test.ts b/packages/coding-agent/test/commit-agentic-attribution.test.ts index 30304440c..6b16b8246 100644 --- a/packages/coding-agent/test/commit-agentic-attribution.test.ts +++ b/packages/coding-agent/test/commit-agentic-attribution.test.ts @@ -32,7 +32,6 @@ describe("commit agent prompt attribution", () => { await runCommitAgentSession({ cwd: "/tmp", - git: {} as never, model, settings: Settings.isolated(), modelRegistry: {} as never, diff --git a/packages/coding-agent/test/join-patch.test.ts b/packages/coding-agent/test/join-patch.test.ts index bcaa4e453..fb31912d8 100644 --- a/packages/coding-agent/test/join-patch.test.ts +++ b/packages/coding-agent/test/join-patch.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { joinPatch } from "@oh-my-pi/pi-coding-agent/commit/git"; +import { patch } from "@oh-my-pi/pi-coding-agent/utils/git"; describe("joinPatch", () => { test("preserves space character in empty context line at end of patch", () => { @@ -14,7 +14,7 @@ describe("joinPatch", () => { " \n", // Empty context line = space + newline ]; - const result = joinPatch(parts); + const result = patch.join(parts); // The result should end with a space character (the empty context line) // but NOT start/end with multiple newlines @@ -24,7 +24,7 @@ describe("joinPatch", () => { test("normalizes multiple trailing newlines in parts", () => { const parts = ["line1\n", "line2\n", "line3"]; - const result = joinPatch(parts); + const result = patch.join(parts); // Should join with single newlines and end with one newline expect(result.endsWith("\n")).toBe(true); @@ -32,7 +32,7 @@ describe("joinPatch", () => { test("adds newline to parts that are missing them", () => { const parts = ["line1", "line2"]; - const result = joinPatch(parts); + const result = patch.join(parts); // Should add newlines to both parts expect(result.includes("line1\n")).toBe(true);