Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions packages/coding-agent/src/config/settings-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1157,6 +1157,17 @@ export const SETTINGS_SCHEMA = {
// Tools
// ────────────────────────────────────────────────────────────────────────

// Git checkpoint tool
"tools.gitCommitCheckpoint.enabled": {
type: "boolean",
default: false,
ui: {
tab: "tools",
label: "Git Checkpoint",
description: "Expose `git_commit_checkpoint` so the agent can land WIP checkpoints for every dirty repo",
},
},

// Todo tool
"todo.enabled": {
type: "boolean",
Expand Down
28 changes: 28 additions & 0 deletions packages/coding-agent/src/prompts/tools/git-commit-checkpoint.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
Commit outstanding work in the current project as a WIP checkpoint.

<when>
Call this at the end of a scope boundary — after a focused batch of direct edits, after a `task` batch returns with changes, or before yielding back to the user. The goal is to stop carrying uncommitted state across unrelated pieces of work.

Typical triggers:
- You finished a small mechanical change yourself and are about to move on to a different concern.
- A `task` batch merged and your working tree now has subagent commits plus your own uncommitted touches.
- The `task` tool already commits dirty state automatically before dispatching an isolated task. You do not need to checkpoint manually before `task` calls.
</when>

<behavior>
- Discovers every dirty repo under the project root (including nested repos) and commits each one separately with the project's agentic commit pipeline in silent mode.
- Stages all tracked + untracked changes before generating each commit message.
- Discovery aborts before staging if it encounters a nested repository with the same `remote.origin.url` as the project root or crosses a filesystem boundary under the project root.
- Commits are unsigned WIP checkpoints — the user coalesces them later via `/commit` or `omp commit`.
- Clean repos are skipped silently. If every repo is clean the call returns `status: "clean"` without doing anything.
- Errors committing one repo do not prevent the others from being committed; per-repo errors are reported in the result.
</behavior>

<parameters>
- `reason` (required): brief label for what scope is closing, e.g. `"after login refactor"`, `"end of scope"`. Used only for agent bookkeeping and surfaced in the transcript — it is not written into the commit message itself.
</parameters>

<avoid>
- Do not call this after every single edit — coalesce related edits into one scope, then checkpoint.
- Do not use this to publish work. It is a local WIP checkpoint, not a release commit.
</avoid>
127 changes: 127 additions & 0 deletions packages/coding-agent/src/task/auto-commit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import * as path from "node:path";
import type { ModelRegistry } from "../config/model-registry";
import type { Settings } from "../config/settings";
import { generateCommitMessage } from "../utils/commit-message-generator";
import * as git from "../utils/git";
import { discoverNestedRepos, getOutermostRepoRoot } from "./worktree";

export interface DirtyRepoReport {
root: string;
repos: string[];
}

/**
* Enumerate every dirty git repository under the outermost repo root containing `cwd`.
* Used by the `git_commit_checkpoint` tool and the task dispatcher to decide which repos
* need committing.
* Discovery prunes hidden child directories and fails before `git status` if
* nested-repo traversal would cross filesystem boundaries.
*/
export async function dirtyRepos(cwd: string): Promise<DirtyRepoReport> {
const root = await getOutermostRepoRoot(cwd);
const nestedRepos = await discoverNestedRepos(root);
const allRepos = [root, ...nestedRepos.map(relativePath => path.join(root, relativePath))];
const statuses = await Promise.all(
allRepos.map(async repoPath => ({
repoPath,
status: await git.status(repoPath, {
porcelainV1: true,
untrackedFiles: "all",
}),
})),
);
return {
root,
repos: statuses.filter(entry => entry.status.trim().length > 0).map(entry => entry.repoPath),
};
}

export interface CommitDirtyRepoEntry {
repoPath: string;
status: "committed" | "skipped" | "failed";
sha?: string;
filesChanged: number;
message?: string;
reason?: "no-changes";
error?: string;
}

export interface CommitDirtyReposOptions {
cwd: string;
modelRegistry: ModelRegistry | undefined;
settings: Settings;
sessionId?: string;
}

/**
* Commit every dirty repo under `cwd` using a model-generated commit message per repo.
*
* Shared by:
* - the `git_commit_checkpoint` tool (LLM-invoked scope closer), and
* - the `task` dispatcher (automatic pre-task safety commit: cherry-picking the task's
* branch onto a dirty parent is the primary cause of merge failures; committing
* first collapses that hazard entirely).
*
* Discovery runs before `git add -A`, so hidden child task/cache directories are
* never staged as a side effect of checkpointing.
* Entries with no staged content after `git add -A` are returned as `skipped`.
* Throws only when the caller passes no `modelRegistry` AND a commit would be required —
* otherwise each repo's failure is reported in its entry so a partial failure does not
* block siblings.
*/
export async function commitDirtyRepos(options: CommitDirtyReposOptions): Promise<CommitDirtyRepoEntry[]> {
const { cwd, modelRegistry, settings, sessionId } = options;
const { repos } = await dirtyRepos(cwd);
if (repos.length === 0) return [];
if (!modelRegistry) {
throw new Error("A model registry is required to generate a commit message.");
}

const entries: CommitDirtyRepoEntry[] = [];
for (const repoPath of repos) {
try {
entries.push(await commitSingleRepo(repoPath, modelRegistry, settings, sessionId));
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
entries.push({
repoPath,
status: "failed",
filesChanged: 0,
error: message,
});
}
}
return entries;
}

async function commitSingleRepo(
repoPath: string,
modelRegistry: ModelRegistry,
settings: Settings,
sessionId: string | undefined,
): Promise<CommitDirtyRepoEntry> {
await git.stage.files(repoPath);
const stagedFiles = await git.diff.changedFiles(repoPath, { cached: true });
if (stagedFiles.length === 0) {
return {
repoPath,
status: "skipped",
filesChanged: 0,
reason: "no-changes",
};
}
const diff = await git.diff(repoPath, { cached: true });
const message = await generateCommitMessage(diff, modelRegistry, settings, sessionId);
if (!message) {
throw new Error("Could not generate a commit message.");
}
await git.commit(repoPath, message);
const sha = (await git.head.short(repoPath)) ?? undefined;
return {
repoPath,
status: "committed",
sha,
filesChanged: stagedFiles.length,
message,
};
}
44 changes: 39 additions & 5 deletions packages/coding-agent/src/task/worktree.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Dirent } from "node:fs";
import type * as nodeFs from "node:fs";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
Expand Down Expand Up @@ -36,6 +36,24 @@ export async function getRepoRoot(cwd: string): Promise<string> {
return repoRoot;
}

/**
* Walk up from the immediate git root to find the outermost enclosing .git directory.
* Checkpoint discovery needs the full workspace root when invoked from inside a nested repo.
*/
export async function getOutermostRepoRoot(cwd: string): Promise<string> {
let root = await getRepoRoot(cwd);
let dir = path.dirname(root);
while (dir.length < root.length) {
try {
await fs.access(path.join(dir, ".git"));
root = dir;
} catch {
break;
}
dir = path.dirname(dir);
}
return root;
}
const PROJFS_UNAVAILABLE_PREFIX = "PROJFS_UNAVAILABLE:";
const GIT_NO_INDEX_NULL_PATH = process.platform === "win32" ? "NUL" : "/dev/null";

Expand All @@ -58,25 +76,41 @@ export async function ensureWorktree(baseCwd: string, id: string): Promise<strin
return worktreeDir;
}

function shouldPruneRepoDiscoveryDir(name: string): boolean {
return name.startsWith(".") || name === "node_modules";
}

/** Find nested git repositories (non-submodule) under the given root. */
async function discoverNestedRepos(repoRoot: string): Promise<string[]> {
export async function discoverNestedRepos(repoRoot: string): Promise<string[]> {
// Get submodule paths so we can exclude them
const submodulePaths = new Set(await git.ls.submodules(repoRoot));

const rootStats = await fs.stat(repoRoot);
// Find all .git dirs/files that aren't the root or known submodules
const result: string[] = [];
async function walk(dir: string): Promise<void> {
let entries: Dirent[];
let entries: nodeFs.Dirent[];
try {
entries = await fs.readdir(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
if (entry.name === "node_modules" || entry.name === ".git") continue;
if (!entry.isDirectory()) continue;
if (shouldPruneRepoDiscoveryDir(entry.name)) continue;
const full = path.join(dir, entry.name);
const rel = path.relative(repoRoot, full);

const stats = await fs.stat(full).catch(err => {
if (isEnoent(err)) return null;
throw err;
});
if (!stats) continue;
if (stats.dev !== rootStats.dev) {
throw new Error(
`Checkpoint/task discovery refuses to cross filesystem device boundaries at "${rel}" before staging. The directory is on a different filesystem device than the repository root.`,
);
}

// Check if this directory is itself a git repo
const gitDir = path.join(full, ".git");
let hasGit = false;
Expand Down
112 changes: 112 additions & 0 deletions packages/coding-agent/src/tools/git-commit-checkpoint.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
import { prompt } from "@oh-my-pi/pi-utils";
import { type Static, Type } from "@sinclair/typebox";
import gitCommitCheckpointDescription from "../prompts/tools/git-commit-checkpoint.md" with { type: "text" };
import { type CommitDirtyRepoEntry, commitDirtyRepos } from "../task/auto-commit";
import type { ToolSession } from "./index";
import type { OutputMeta } from "./output-meta";
import { shortenPath } from "./render-utils";
import { ToolError } from "./tool-errors";
import { toolResult } from "./tool-result";

const gitCommitCheckpointSchema = Type.Object({
reason: Type.String({
description:
"Short label for the scope being closed, e.g. 'after login refactor', 'end of scope'. Used only for agent bookkeeping and surfaced in the transcript — it is not written into the commit message itself.",
}),
});

type GitCommitCheckpointParams = Static<typeof gitCommitCheckpointSchema>;

export type GitCommitCheckpointRepoEntry = CommitDirtyRepoEntry;

export interface GitCommitCheckpointToolDetails {
overallStatus: "committed" | "clean" | "partial" | "failed";
reason?: string;
repos: GitCommitCheckpointRepoEntry[];
meta?: OutputMeta;
}

export class GitCommitCheckpointTool
implements AgentTool<typeof gitCommitCheckpointSchema, GitCommitCheckpointToolDetails>
{
readonly name = "git_commit_checkpoint";
readonly label = "GitCommitCheckpoint";
readonly description: string;
readonly parameters = gitCommitCheckpointSchema;
readonly strict = true;

constructor(private readonly session: ToolSession) {
this.description = prompt.render(gitCommitCheckpointDescription);
}

static createIf(session: ToolSession): GitCommitCheckpointTool | null {
if ((session.taskDepth ?? 0) !== 0) return null;
if (!session.settings.get("tools.gitCommitCheckpoint.enabled")) return null;
return new GitCommitCheckpointTool(session);
}

async execute(
_toolCallId: string,
params: GitCommitCheckpointParams,
_signal?: AbortSignal,
_onUpdate?: AgentToolUpdateCallback<GitCommitCheckpointToolDetails>,
_context?: AgentToolContext,
): Promise<AgentToolResult<GitCommitCheckpointToolDetails>> {
const cwd = this.session.cwd;
const entries = await commitDirtyRepos({
cwd,
modelRegistry: this.session.modelRegistry,
settings: this.session.settings,
sessionId: this.session.getSessionId?.() ?? undefined,
});

if (entries.length === 0) {
const details: GitCommitCheckpointToolDetails = {
overallStatus: "clean",
reason: params.reason,
repos: [],
};
return toolResult<GitCommitCheckpointToolDetails>(details)
.text(`Nothing to commit — all repos under ${shortenPath(cwd)} are clean.`)
.done();
}
const committed = entries.filter(entry => entry.status === "committed").length;
const failed = entries.filter(entry => entry.status === "failed").length;
const overallStatus: GitCommitCheckpointToolDetails["overallStatus"] =
failed === entries.length ? "failed" : failed > 0 ? "partial" : committed === 0 ? "clean" : "committed";

const details: GitCommitCheckpointToolDetails = {
overallStatus,
reason: params.reason,
repos: entries,
};
const text = formatResultText(entries);
if (overallStatus === "failed") {
throw new ToolError(text);
}
return toolResult<GitCommitCheckpointToolDetails>(details).text(text).done();
}
}

function formatResultText(entries: GitCommitCheckpointRepoEntry[]): string {
if (entries.length === 0) {
return "No dirty repos.";
}
const lines: string[] = [];
for (const entry of entries) {
const repoLabel = shortenPath(entry.repoPath);
if (entry.status === "committed") {
const sha = entry.sha ?? "unknown";
const fileWord = entry.filesChanged === 1 ? "file" : "files";
const subject = entry.message?.trim().split("\n")[0];
const suffix = subject ? ` — ${subject}` : "";
lines.push(`${repoLabel}: ${sha} (${entry.filesChanged} ${fileWord})${suffix}`);
} else if (entry.status === "skipped") {
lines.push(`${repoLabel}: skipped (${entry.reason ?? "no-changes"})`);
} else {
lines.push(`${repoLabel}: failed — ${entry.error ?? "unknown error"}`);
}
}
return lines.join("\n");
}
2 changes: 2 additions & 0 deletions packages/coding-agent/src/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { DebugTool } from "./debug";
import { ExitPlanModeTool } from "./exit-plan-mode";
import { FindTool } from "./find";
import { GithubTool } from "./gh";
import { GitCommitCheckpointTool } from "./git-commit-checkpoint";
import { GrepTool } from "./grep";
import { InspectImageTool } from "./inspect-image";
import { IrcTool } from "./irc";
Expand Down Expand Up @@ -220,6 +221,7 @@ export const BUILTIN_TOOLS: Record<string, ToolFactory> = {
read: s => new ReadTool(s),
inspect_image: s => new InspectImageTool(s),
browser: s => new BrowserTool(s),
git_commit_checkpoint: GitCommitCheckpointTool.createIf,
checkpoint: CheckpointTool.createIf,
rewind: RewindTool.createIf,
task: TaskTool.create,
Expand Down
Loading
Loading