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:
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user