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