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
@@ -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");
}
}