/** * Auto-Commit Extension for Pi * * Automatically commits all working-tree changes after Pi finishes responding to * a prompt (the `agent_end` event). The commit message is generated by the * currently selected model from the staged diff, the list of changed files, and * the assistant's description of the work it just did. * * Behaviour: * - Runs after every completed agent response (one commit per user prompt). * - Stages everything (`git add -A`) and commits all open changes. * - Skips silently when there is nothing to commit or the cwd is not a git repo. * - Skips (and notifies) on unresolved merge conflicts, so it never commits * conflict markers. * - If the model call cannot happen (no model / no API key) or fails, it falls * back to a short subject derived from the assistant's last response. * - The commit-message model call is a STANDALONE call (pi-ai `complete()`); it * does not touch the Pi session, so it never produces a visible agent turn. * - Success is reported in the FOOTER status line ("\u2713 autocommit: "), * not as an inline notification, so the autocommit flow stays out of the main * conversation thread. The indicator is cleared at the start of the next * prompt. Errors/warnings (conflicts, suspended with pending changes, commit * failures) still notify inline so they are not missed. * - Failures never interrupt the agent; they are surfaced via notifications. * * Manual commands: * - `/commit [message]` commit all open changes now; if no message is given, * auto-generate one from the diff (works regardless of * the autocommit toggle). * - `/undo` revert the last commit with `git reset --soft HEAD~1`, * bringing the changes back as uncommitted (and warns if * autocommit is on, since they will re-commit next prompt). * * Toggle (persisted per project): * - `/autocommit` show current status * - `/autocommit on` enable (default) * - `/autocommit off` suspend — leave all changes uncommitted * - `/autocommit toggle` flip on/off * State is stored per cwd at /autocommit-state/.json so * suspending in one messy project does not disable autocommit elsewhere. When * suspended, a "autocommit: paused" footer status is shown and you get a * warning whenever a response leaves changes uncommitted. * * Install (global, auto-discovered): ~/.pi/agent/extensions/autocommit.ts * Reload after adding/removing with /reload. * * Requires: a git repository in the project cwd and a configured git identity. */ import { complete, type Message } from "@earendil-works/pi-ai"; import type { AgentMessage } from "@earendil-works/pi-agent-core"; import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; import { getAgentDir, truncateHead } from "@earendil-works/pi-coding-agent"; import { readFile, writeFile, mkdir } from "node:fs/promises"; import { dirname, join } from "node:path"; const SUBJECT_MAX = 72; const BODY_MAX = 1000; const DIFF_MAX_BYTES = 8000; const DIFF_MAX_LINES = 200; const GIT_TIMEOUT_MS = 30_000; const STATE_DIR_NAME = "autocommit-state"; const FOOTER_SUBJECT_MAX = 60; interface GitResult { code: number; stdout: string; stderr: string; } interface AutocommitState { enabled: boolean; } /** In-memory flag for the current session/project, initialized from disk on session_start. */ let enabled = true; /** Run a git command in the project cwd, returning trimmed stdout/stderr + code. */ async function git( pi: ExtensionAPI, args: string[], ctx: { cwd: string; signal?: AbortSignal }, ): Promise { const r = await pi.exec("git", args, { cwd: ctx.cwd, signal: ctx.signal, timeout: GIT_TIMEOUT_MS, }); return { code: r.code, stdout: r.stdout.trimEnd(), stderr: r.stderr.trim() }; } /** Encode a cwd the same way Pi encodes it for session dirs. */ function encodeCwd(cwd: string): string { return `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`; } /** Per-project state file path under the Pi agent config directory. */ function stateFilePath(cwd: string): string { return join(getAgentDir(), STATE_DIR_NAME, `${encodeCwd(cwd)}.json`); } /** Read persisted enabled state for a cwd. Defaults to true when missing/corrupt. */ async function readState(cwd: string): Promise { try { const raw = await readFile(stateFilePath(cwd), "utf8"); const data = JSON.parse(raw) as Partial; return data.enabled !== false; } catch { return true; } } /** Persist enabled state for a cwd. */ async function writeState(cwd: string, value: boolean): Promise { const file = stateFilePath(cwd); await mkdir(dirname(file), { recursive: true }); await writeFile(file, `${JSON.stringify({ enabled: value } satisfies AutocommitState, null, 2)}\n`, "utf8"); } /** Truncate a commit subject for the narrow footer status line. */ function truncateForFooter(s: string, max: number): string { const t = s.trim(); if (t.length <= max) return t; const cut = t.slice(0, max - 1); const lastSpace = cut.lastIndexOf(" "); return (lastSpace > 0 ? cut.slice(0, lastSpace) : cut) + "\u2026"; } /** * Refresh the autocommit footer status (TUI/RPC only). * Shows "paused" when suspended, otherwise clears the slot. The per-commit * "\u2713 autocommit: " indicator is set directly after a successful * commit and cleared again at the start of the next prompt (agent_start), so * autocommit feedback lives in the footer, away from the conversation thread. */ function refreshStatus(ctx: ExtensionContext): void { if (!ctx.hasUI) return; if (!enabled) { ctx.ui.setStatus("autocommit", ctx.ui.theme.fg("warning", "autocommit: paused")); } else { ctx.ui.setStatus("autocommit", undefined); } } /** * Commit all open changes with a model-generated (or user-provided) message. * Used by both the automatic `agent_end` hook and the manual `/commit` command. * * @param mode "footer" — success in footer status (for autocommit hook); * "notify" — success as inline notification (for /commit). * @param userMsg a user-supplied message; skips model generation when present. * @param event the agent_end event (for prompt/summary context); omitted by * manual callers that do not have an event. */ async function commitNow( pi: ExtensionAPI, ctx: ExtensionContext, mode: "footer" | "notify", userMsg?: string, event?: { messages: AgentMessage[] }, ): Promise<{ ok: boolean; subject?: string; error?: string }> { // Not inside a git repo — silent skip. const rev = await git(pi, ["rev-parse", "--git-dir"], ctx); if (rev.code !== 0) return { ok: false }; // Never auto-commit unresolved merge conflict markers. const unmerged = await git(pi, ["diff", "--name-only", "--diff-filter=U"], ctx); if (unmerged.stdout.trim().length > 0) { if (ctx.hasUI) ctx.ui.notify("autocommit: unresolved merge conflicts — skipping", "warning"); return { ok: false, error: "conflict" }; } // Stage all changes and confirm something is actually staged. await git(pi, ["add", "-A"], ctx); const status = await git(pi, ["diff", "--cached", "--name-only"], ctx); if (status.stdout.trim().length === 0) return { ok: false, error: "clean" }; // Gather context for the model-based message generator. const { prompt, summary } = event ? summarizeRun(event.messages) : { prompt: "", summary: "" }; const nameStatus = (await git(pi, ["diff", "--cached", "--name-status"], ctx)).stdout; const rawDiff = (await git(pi, ["diff", "--cached"], ctx)).stdout; const diff = truncateHead(rawDiff, { maxLines: DIFF_MAX_LINES, maxBytes: DIFF_MAX_BYTES }).content; // Build or generate the commit message. let message: string | undefined; if (userMsg) { message = userMsg; } else { // Try to generate a model-written commit message; fall back if unavailable. if (ctx.model) { const auth = await ctx.modelRegistry.getApiKeyAndHeaders(ctx.model); if (auth.ok && auth.apiKey) { const systemPrompt = [ "You write git commit messages. Given the user's request, a summary of the work", "done, the changed files, and a diff, write a concise, descriptive commit message", "in plain language (free-form, no Conventional Commits prefixes like feat:/fix:).", "", "Rules:", "- First line is the subject: a single line, at most 72 characters, imperative", ' mood (e.g. "Add input validation to the login form").', "- Optionally leave a blank line and add a short body explaining the why/what", " if it adds useful context.", "- Output ONLY the commit message. No preamble, no explanation, no code fences,", " no surrounding quotes.", ].join("\n"); const userText = [ `User's request:\n${prompt || "(none)"}`, "", `What was done:\n${summary || "(no assistant summary)"}`, "", `Changed files (git --name-status):\n${nameStatus || "(none)"}`, "", `Diff (may be truncated):\n${diff || "(empty)"}`, ].join("\n"); const msgParam: Message = { role: "user", content: [{ type: "text", text: userText }], timestamp: Date.now(), }; try { const response = await complete( ctx.model, { systemPrompt, messages: [msgParam] }, { apiKey: auth.apiKey, headers: auth.headers, signal: ctx.signal }, ); if (response.stopReason !== "aborted" && response.stopReason !== "error") { message = response.content .filter((c): c is { type: "text"; text: string } => c.type === "text") .map((c) => c.text) .join("\n") .trim() || undefined; } } catch { /* fall back below */ } } } if (!message) message = fallbackSubject(summary, nameStatus); } const cleaned = sanitizeCommitMessage(message); const { subject, body } = splitSubjectBody(cleaned); const commitArgs = body ? ["commit", "-m", subject, "-m", body] : ["commit", "-m", subject]; const commit = await git(pi, commitArgs, ctx); if (commit.code === 0) { // "footer" mode: success in the footer status (away from conversation). // "notify" mode: success as an inline notification. if (mode === "footer" && ctx.hasUI) { ctx.ui.setStatus( "autocommit", ctx.ui.theme.fg("success", `\u2713 autocommit: ${truncateForFooter(subject, FOOTER_SUBJECT_MAX)}`), ); } else if (mode === "notify" && ctx.hasUI) { ctx.ui.notify(`autocommit: committed \u2014 ${subject}`, "info"); } return { ok: true, subject }; } const err = commit.stderr || commit.stdout || "unknown error"; if (ctx.hasUI) ctx.ui.notify(`autocommit: commit failed \u2014 ${err}`, "error"); return { ok: false, error: err }; } function errMessage(err: unknown): string { return err instanceof Error ? err.message : String(err); } /** Extract concatenated text from a message's content (string or content blocks). */ export function messageText(msg: AgentMessage): string { const content = (msg as { content?: unknown }).content; if (typeof content === "string") return content; if (!Array.isArray(content)) return ""; return content .filter( (c): c is { type: "text"; text: string } => !!c && typeof c === "object" && (c as { type?: string }).type === "text", ) .map((c) => c.text) .join("\n"); } /** Pull the user's prompt and the assistant's final summary out of this run. */ export function summarizeRun(messages: AgentMessage[]): { prompt: string; summary: string } { let prompt = ""; let summary = ""; for (const msg of messages) { const role = (msg as { role?: string }).role; if (role === "user") { if (!prompt) prompt = messageText(msg).trim(); } else if (role === "assistant") { const text = messageText(msg).trim(); if (text) summary = text; // keep the last assistant message with text } } return { prompt, summary }; } /** Clean up model output into a usable commit message (strip fences/preamble/quotes). */ export function sanitizeCommitMessage(raw: string): string { let text = raw.trim(); // Strip surrounding code fences (```...``` or ~~~...~~~), optionally lang-tagged. const fence = text.match(/^```[^\n]*\n([\s\S]*?)\n?```$/); if (fence) { text = fence[1].trim(); } else { const tilde = text.match(/^~~~[^\n]*\n([\s\S]*?)\n?~~~$/); if (tilde) text = tilde[1].trim(); } // Strip wrapping single/double quotes. if ( (text.startsWith('"') && text.endsWith('"')) || (text.startsWith("'") && text.endsWith("'")) ) { text = text.slice(1, -1).trim(); } // Drop leading preamble lines like "Here is the commit message:". const lines = text.split("\n"); while (lines.length > 0) { const first = lines[0].trim().toLowerCase(); if ( first === "" || first.startsWith("here is") || first.startsWith("here's") || first.startsWith("here\u2019s") || first.startsWith("commit message") || first.startsWith("the commit message") || first.startsWith("this commit") || first.startsWith("sure") || first.startsWith("certainly") || first.startsWith("of course") || first.startsWith("absolutely") || first.startsWith("ok,") || first.startsWith("okay,") ) { lines.shift(); } else { break; } } return lines.join("\n").trim(); } /** Split a cleaned message into a subject (<=72 chars) and an optional body. */ export function splitSubjectBody(cleaned: string): { subject: string; body: string } { const lines = cleaned.split("\n"); let subject = (lines[0] ?? "").trim(); if (subject.length > SUBJECT_MAX) { const cut = subject.slice(0, SUBJECT_MAX - 1); const lastSpace = cut.lastIndexOf(" "); subject = (lastSpace > 0 ? cut.slice(0, lastSpace) : cut) + "\u2026"; } const bodyText = lines .slice(1) .join("\n") .trim(); const body = bodyText.length > BODY_MAX ? bodyText.slice(0, BODY_MAX - 1) + "\u2026" : bodyText; return { subject, body }; } /** Build a fallback subject from the assistant's last response or the file list. */ export function fallbackSubject(summary: string, nameStatus: string): string { const firstLine = summary.split("\n").find((l) => l.trim()) ?? ""; const base = firstLine || nameStatus || "Update files"; return base.length > SUBJECT_MAX ? base.slice(0, SUBJECT_MAX - 1) + "\u2026" : base; } export default function (pi: ExtensionAPI) { // Load persisted state for this project on startup / session switch. pi.on("session_start", async (_event, ctx) => { enabled = await readState(ctx.cwd); refreshStatus(ctx); }); // Clear the per-commit footer indicator at the start of the next prompt so // the "\u2713 autocommit: ..." footer is transient and stays out of the // conversation. Also re-asserts the paused indicator when suspended. pi.on("agent_start", async (_event, ctx) => { refreshStatus(ctx); }); // Clear our footer status when the session runtime is torn down. pi.on("session_shutdown", async (_event, ctx) => { if (ctx.hasUI) ctx.ui.setStatus("autocommit", undefined); }); // /autocommit [on|off|toggle|status] — persisted per project. pi.registerCommand("autocommit", { description: "Toggle auto-commit on/off (persisted per project). Usage: /autocommit [on|off|toggle|status]", handler: async (args, ctx) => { const arg = args.trim().toLowerCase(); if (arg === "" || arg === "status") { if (ctx.hasUI) { ctx.ui.notify( `autocommit is ${enabled ? "ON" : "OFF (suspended)"} for ${ctx.cwd}`, enabled ? "info" : "warning", ); } return; } let next: boolean; if (arg === "on" || arg === "enable" || arg === "true" || arg === "1") { next = true; } else if ( arg === "off" || arg === "disable" || arg === "false" || arg === "0" || arg === "pause" || arg === "suspend" ) { next = false; } else if (arg === "toggle") { next = !enabled; } else { if (ctx.hasUI) { ctx.ui.notify("Usage: /autocommit [on|off|toggle|status]", "warning"); } return; } enabled = next; try { await writeState(ctx.cwd, next); } catch (err) { if (ctx.hasUI) { ctx.ui.notify(`autocommit: could not persist state \u2014 ${errMessage(err)}`, "error"); } } refreshStatus(ctx); if (ctx.hasUI) { ctx.ui.notify( `autocommit ${next ? "enabled" : "disabled (suspended)"} for ${ctx.cwd}`, next ? "info" : "warning", ); } }, }); // /commit [message] — stage everything and commit now; works regardless of // the autocommit toggle. Uses model-generated message when no argument given. pi.registerCommand("commit", { description: "Commit all open changes now with an auto-generated (or explicit) message. Usage: /commit [message]", handler: async (args, ctx) => { const userMsg = args.trim(); const result = await commitNow(pi, ctx, "notify", userMsg || undefined); if (!result.ok && result.error === "clean" && ctx.hasUI) { ctx.ui.notify("autocommit: nothing to commit \u2014 working tree is clean", "warning"); } }, }); // /undo — revert the last commit with `git reset --soft HEAD~1`, bringing // changes back as uncommitted (staged). Warns if autocommit is on. pi.registerCommand("undo", { description: "Revert the last commit (soft reset), bringing changes back as uncommitted.", handler: async (_args, ctx) => { const head = await git(pi, ["rev-parse", "--verify", "HEAD"], ctx); if (head.code !== 0) { if (ctx.hasUI) ctx.ui.notify("autocommit: nothing to undo \u2014 no commits yet", "warning"); return; } const log = await git(pi, ["log", "-1", "--format=%s"], ctx); const lastSubject = log.stdout.trim() || "(unknown)"; const reset = await git(pi, ["reset", "--soft", "HEAD~1"], ctx); if (reset.code !== 0) { if (ctx.hasUI) { ctx.ui.notify( `autocommit: undo failed \u2014 ${reset.stderr || reset.stdout || "unknown error"}`, "error", ); } return; } if (ctx.hasUI) { ctx.ui.notify(`autocommit: undid \u201c${lastSubject}\u201d \u2014 changes are back as uncommitted`, "info"); if (enabled) { ctx.ui.notify( "autocommit is on \u2014 the changes will be recommitted after your next prompt (use /autocommit off to prevent this)", "warning", ); } } }, }); pi.on("agent_end", async (event, ctx) => { try { if (!enabled) { refreshStatus(ctx); const rev = await git(pi, ["rev-parse", "--git-dir"], ctx); if (rev.code !== 0) return; const status = await git(pi, ["status", "--porcelain"], ctx); if (status.code === 0 && status.stdout.trim().length > 0) { if (ctx.hasUI) { ctx.ui.notify("autocommit: suspended \u2014 uncommitted changes left as-is", "warning"); } } return; } await commitNow(pi, ctx, "footer", undefined, event); } catch (err) { const msg = errMessage(err); if (ctx.hasUI) ctx.ui.notify(`autocommit: error \u2014 ${msg}`, "error"); } }); }