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.
This commit is contained in:
can1357
2026-04-02 07:35:44 +02:00
parent 8fab9fb600
commit 418b6801a0
32 changed files with 1705 additions and 1153 deletions
+24
View File
@@ -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
-4
View File
@@ -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"
+25 -30
View File
@@ -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<string | null> {
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<string | null> {
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<EnsureAutoresearchBranchResult> {
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<string> {
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<boolean> {
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<string>, rawPath: string): void {
const normalizedPath = normalizeStatusPath(rawPath);
if (normalizedPath.length === 0) return;
@@ -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<KeepCommitResult> {
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<string> {
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 {
@@ -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<Api>;
thinkingLevel?: ThinkingLevel;
settings: Settings;
@@ -46,7 +44,6 @@ export async function runCommitAgentSession(input: CommitAgentInput): Promise<Co
const spawns = "quick_task";
const tools = createCommitTools({
cwd: input.cwd,
git: input.git,
authStorage: input.authStorage,
modelRegistry: input.modelRegistry,
settings: input.settings,
@@ -4,7 +4,6 @@ import { $env, getProjectDir, isEnoent } from "@oh-my-pi/pi-utils";
import { applyChangelogProposals } from "../../commit/changelog";
import { detectChangelogBoundaries } from "../../commit/changelog/detect";
import { parseUnreleasedSection } from "../../commit/changelog/parse";
import { ControlledGit } from "../../commit/git";
import { formatCommitMessage } from "../../commit/message";
import { resolvePrimaryModel, resolveSmolModel } from "../../commit/model-selection";
import type { CommitCommandArgs, ConventionalAnalysis } from "../../commit/types";
@@ -12,6 +11,7 @@ import { ModelRegistry } from "../../config/model-registry";
import { renderPromptTemplate } from "../../config/prompt-templates";
import { Settings } from "../../config/settings";
import { discoverAuthStorage, discoverContextFiles } from "../../sdk";
import * as git from "../../utils/git";
import { type ExistingChangelogEntries, runCommitAgentSession } from "./agent";
import { generateFallbackProposal } from "./fallback";
import splitConfirmPrompt from "./prompts/split-confirm.md" with { type: "text" };
@@ -20,25 +20,24 @@ import { computeDependencyOrder } from "./topo-sort";
import { detectTrivialChange } from "./trivial";
interface CommitExecutionContext {
git: ControlledGit;
cwd: string;
dryRun: boolean;
push: boolean;
}
export async function runAgenticCommit(args: CommitCommandArgs): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
try {
commitState = await runCommitAgentSession({
cwd,
git,
model: agentModel,
thinkingLevel: agentThinkingLevel,
settings,
@@ -169,7 +167,6 @@ export async function runAgenticCommit(args: CommitCommandArgs): Promise<void> {
}
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<void> {
}
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");
}
}
@@ -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<typeof gitFileDiffSchema> {
export function createGitFileDiffTool(cwd: string, state: CommitAgentState): CustomTool<typeof gitFileDiffSchema> {
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);
@@ -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<typeof gitHunkSchema> {
export function createGitHunkTool(cwd: string): CustomTool<typeof gitHunkSchema> {
return {
name: "git_hunk",
label: "Git Hunk",
@@ -23,7 +23,7 @@ export function createGitHunkTool(git: ControlledGit): CustomTool<typeof gitHunk
parameters: gitHunkSchema,
async execute(_toolCallId, params) {
const staged = params.staged ?? true;
const hunks = await git.getHunks([params.file], staged);
const hunks = await git.diff.hunks(cwd, [params.file], { cached: staged });
const fileHunks = hunks.find(entry => entry.filename === params.file) ?? {
filename: params.file,
isBinary: false,
@@ -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<typeof gitOverviewSchema> {
export function createGitOverviewTool(cwd: string, state: CommitAgentState): CustomTool<typeof gitOverviewSchema> {
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,
@@ -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<CustomTool<any, any>> {
const tools: Array<CustomTool<any, any>> = [
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<CustomTool<
tools.push(
createProposeChangelogTool(options.state, options.changelogTargets),
createProposeCommitTool(options.git, options.state),
createSplitCommitTool(options.git, options.state, options.changelogTargets),
createProposeCommitTool(options.cwd, options.state),
createSplitCommitTool(options.cwd, options.state, options.changelogTargets),
);
return tools;
@@ -9,9 +9,9 @@ import {
validateTypeConsistency,
} from "../../../commit/agentic/validation";
import { validateAnalysis } from "../../../commit/analysis/validation";
import type { ControlledGit } from "../../../commit/git";
import type { CommitType, ConventionalAnalysis, 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 proposeCommitSchema = Type.Object({
@@ -49,10 +49,7 @@ function normalizeDetails(
}));
}
export function createProposeCommitTool(
git: ControlledGit,
state: CommitAgentState,
): CustomTool<typeof proposeCommitSchema> {
export function createProposeCommitTool(cwd: string, state: CommitAgentState): CustomTool<typeof proposeCommitSchema> {
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,
@@ -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<typeof recentCommitsSchema> {
export function createRecentCommitsTool(cwd: string): CustomTool<typeof recentCommitsSchema> {
return {
name: "recent_commits",
label: "Recent Commits",
@@ -33,7 +33,7 @@ export function createRecentCommitsTool(git: ControlledGit): CustomTool<typeof r
parameters: recentCommitsSchema,
async execute(_toolCallId, params) {
const count = params.count ?? 8;
const commits = await git.getRecentCommits(count);
const commits = await git.log.subjects(cwd, count);
const verbs: Record<string, number> = {};
const scopes: Record<string, number> = {};
const lengths: number[] = [];
@@ -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<typeof splitCommitSchema> {
@@ -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<string>();
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;
@@ -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<Api>;
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<string, string[]> };
@@ -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);
}
@@ -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";
}
}
@@ -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<string> {
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<string> {
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<string[]> {
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<string> {
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<string> {
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<NumstatEntry[]> {
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<string[]> {
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<string[]> {
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<string[]> {
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<void> {
const result = await stageFiles(this.cwd, []);
this.#ensureSuccess(result, "git add -A");
}
async stageFiles(files: string[]): Promise<void> {
const result = await stageFiles(this.cwd, files);
this.#ensureSuccess(result, "git add");
}
async stageHunks(selections: HunkSelection[]): Promise<void> {
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<void> {
const result = await resetStaging(this.cwd, files);
this.#ensureSuccess(result, "git reset");
}
async commit(message: string): Promise<void> {
const result = await commit(this.cwd, message);
this.#ensureSuccess(result, "git commit");
}
async push(): Promise<void> {
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<FileHunks[]> {
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;
}
@@ -1,54 +0,0 @@
import { $ } from "bun";
interface GitResult {
exitCode: number;
stdout: string;
stderr: string;
}
export async function runGitCommand(cwd: string, args: string[]): Promise<GitResult> {
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<GitResult> {
const args = files.length === 0 ? ["add", "-A"] : ["add", "--", ...files];
return runGitCommand(cwd, args);
}
export async function resetStaging(cwd: string, files: string[]): Promise<GitResult> {
const args = files.length === 0 ? ["reset"] : ["reset", "--", ...files];
return runGitCommand(cwd, args);
}
export async function push(cwd: string): Promise<GitResult> {
return runGitCommand(cwd, ["push"]);
}
export async function commit(cwd: string, message: string): Promise<GitResult> {
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(),
};
}
+10 -12
View File
@@ -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<void> {
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<void> {
if (!args.noChangelog) {
await runChangelogFlow({
git,
cwd,
model: primaryModel,
apiKey: primaryApiKey,
@@ -82,11 +80,11 @@ async function runLegacyCommitCommand(args: CommitCommandArgs): Promise<void> {
});
}
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<void> {
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");
}
}
@@ -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<string | undefined> {
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 {
@@ -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<string[]> {
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<string[]> {
async function getCurrentBranch(api: CustomCommandAPI): Promise<string> {
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<string> {
async function getGitStatus(api: CustomCommandAPI): Promise<string> {
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<string> {
async function getRecentCommits(api: CustomCommandAPI, count: number): Promise<string[]> {
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 [];
}
@@ -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<FetchResult> {
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<void> {
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`);
}
}
}
@@ -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)) {
+1
View File
@@ -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,
@@ -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[] {
@@ -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 {
-42
View File
@@ -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;
}
}
+7 -18
View File
@@ -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<TaskSchema, TaskToolDetails, Theme> {
} 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<TaskSchema, TaskToolDetails, Theme> {
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 });
}
}
}
+84 -134
View File
@@ -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<string> {
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<strin
const encodedProject = getEncodedProjectName(repoRoot);
const worktreeDir = getWorktreeDir(encodedProject, id);
await fs.mkdir(path.dirname(worktreeDir), { recursive: true });
await $`git worktree remove -f ${worktreeDir}`.cwd(repoRoot).quiet().nothrow();
await git.worktree.tryRemove(repoRoot, worktreeDir);
await fs.rm(worktreeDir, { recursive: true, force: true });
await $`git worktree add --detach ${worktreeDir} HEAD`.cwd(repoRoot).quiet();
await git.worktree.add(repoRoot, worktreeDir, "HEAD", { detach: true });
return worktreeDir;
}
/** Find nested git repositories (non-submodule) under the given root. */
async function discoverNestedRepos(repoRoot: string): Promise<string[]> {
// 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<string[]> {
}
async function captureRepoBaseline(repoRoot: string): Promise<RepoBaseline> {
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<WorktreeBaselin
return { root, nested };
}
async function writeTempPatchFile(patch: string): Promise<string> {
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<string, string> },
): Promise<void> {
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<void> {
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<void> {
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<string[]> {
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<string> {
// 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<void> {
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<void> {
for (const branch of branches) {
await $`git branch -D ${branch}`.cwd(repoRoot).quiet().nothrow();
await git.branch.tryDelete(repoRoot, branch);
}
}
+75 -271
View File
@@ -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<GitCommandResult> {
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<string> {
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<GitCommandResult> {
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<string | undefined> {
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<string> {
return runGitTextChecked(cwd, ["rev-parse", "--show-toplevel"], signal);
}
async function resolvePrimaryGitRepoRoot(repoRoot: string, signal?: AbortSignal): Promise<string> {
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<string> {
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<string> {
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<GitWorktreeEntry[]> {
const output = await runGitTextChecked(repoRoot, ["worktree", "list", "--porcelain"], signal);
return parseGitWorktreeList(output);
async function requireCurrentGitBranch(cwd: string, signal?: AbortSignal): Promise<string> {
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<boolean> {
const result = await runGitCommand(repoRoot, ["show-ref", "--verify", "--quiet", ref], signal);
return result.exitCode === 0;
async function requireCurrentGitHead(cwd: string, signal?: AbortSignal): Promise<string> {
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<void> {
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<GhRepoViewDa
}
async function getRemoteUrls(repoRoot: string, signal?: AbortSignal): Promise<Map<string, string>> {
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<string, string>();
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<void> {
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<string | undefined> {
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<string> {
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<string> {
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<typeof ghPrCheckoutSchema, Gh
const headRefName = requireNonEmpty(data.headRefName, "head branch");
const headRefOid = requireNonEmpty(data.headRefOid, "head commit");
const repoRoot = await resolveGitRepoRoot(this.session.cwd, signal);
const primaryRepoRoot = await resolvePrimaryGitRepoRoot(repoRoot, signal);
const repoRoot = await requireGitRepoRoot(this.session.cwd, signal);
const primaryRepoRoot = await requirePrimaryGitRepoRoot(repoRoot, signal);
const localBranch = requestedBranch ?? `pr-${prNumber}`;
const worktreePath = requestedWorktree
? path.resolve(this.session.cwd, requestedWorktree)
: path.join(primaryRepoRoot, ".worktrees", localBranch);
const existingWorktrees = await listGitWorktrees(repoRoot, signal);
const existingWorktrees = await git.worktree.list(repoRoot, signal);
const existingWorktree = existingWorktrees.find(entry => 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<typeof ghPrCheckoutSchema, Gh
if (!existingWorktree) {
await ensureGitWorktreePathAvailable(finalWorktreePath, existingWorktrees);
await fs.mkdir(path.dirname(finalWorktreePath), { recursive: true });
const addResult = await runGitCommand(
repoRoot,
["worktree", "add", finalWorktreePath, localBranch],
signal,
);
if (addResult.exitCode !== 0) {
throw new ToolError(formatGitFailure(["worktree", "add", finalWorktreePath, localBranch], addResult));
}
await git.worktree.add(repoRoot, finalWorktreePath, localBranch, { signal });
}
return buildTextResult(
@@ -2400,28 +2208,24 @@ export class GhPrPushTool implements AgentTool<typeof ghPrPushSchema, GhToolDeta
_context?: AgentToolContext,
): Promise<AgentToolResult<GhToolDetails>> {
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<typeof ghRunWatchSchema, GhTool
}
}
const branch = branchInput ?? (await resolveCurrentGitBranch(this.session.cwd, signal));
const branch = branchInput ?? (await requireCurrentGitBranch(this.session.cwd, signal));
const headSha = branchInput
? await resolveGitHubBranchHead(this.session.cwd, repo, branch, signal)
: await resolveCurrentGitHead(this.session.cwd, signal);
: await requireCurrentGitHead(this.session.cwd, signal);
let pollCount = 0;
let settledSuccessSignature: string | undefined;
File diff suppressed because it is too large Load Diff
@@ -32,7 +32,6 @@ describe("commit agent prompt attribution", () => {
await runCommitAgentSession({
cwd: "/tmp",
git: {} as never,
model,
settings: Settings.isolated(),
modelRegistry: {} as never,
@@ -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);