Files
dotfiles/pi/.pi/agent/extensions/git-checkout-guard.ts
2026-03-19 07:58:49 +01:00

100 lines
3.0 KiB
TypeScript

/**
* Git Checkout Guard Extension
*
* Prevents models from using `git checkout` or `git restore` to silently
* discard uncommitted changes in files.
*/
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { isToolCallEventType } from "@mariozechner/pi-coding-agent";
import { execSync } from "child_process";
/**
* Parse file paths from a git checkout/restore command.
* Returns null if the command doesn't look like a file-restore operation.
*/
function parseFileRestoreArgs(command: string): string[] | null {
// Normalize whitespace
const cmd = command.trim().replace(/\s+/g, " ");
// Match: git checkout -- <files>
// Match: git checkout <ref> -- <files>
const checkoutDashDash = cmd.match(/\bgit\s+checkout\b.*?\s--\s+(.+)/);
if (checkoutDashDash) {
return checkoutDashDash[1].trim().split(/\s+/);
}
// Match: git restore [--staged] [--source=<ref>] <files>
// (git restore always operates on files)
const restore = cmd.match(/\bgit\s+restore\s+(.+)/);
if (restore) {
// Filter out flags like --staged, --source=..., --worktree, --patch
const args = restore[1].trim().split(/\s+/);
const files = args.filter((a) => !a.startsWith("-"));
return files.length > 0 ? files : null;
}
return null;
}
/**
* Check which of the given file paths have uncommitted changes (staged or unstaged).
* Returns the subset that are dirty.
*/
function getDirtyFiles(files: string[], cwd: string): string[] {
const dirty: string[] = [];
for (const file of files) {
try {
// --porcelain output is empty for clean files
const out = execSync(`git status --porcelain -- ${JSON.stringify(file)}`, {
cwd,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
if (out.length > 0) {
dirty.push(file);
}
} catch {
// Not a git repo or other error — skip
}
}
return dirty;
}
export default function (pi: ExtensionAPI) {
pi.on("tool_call", async (event, ctx) => {
if (!isToolCallEventType("bash", event)) return undefined;
const command: string = event.input.command ?? "";
const files = parseFileRestoreArgs(command);
if (!files || files.length === 0) return undefined;
const cwd = process.cwd();
const dirty = getDirtyFiles(files, cwd);
if (dirty.length === 0) return undefined; // nothing to protect
const fileList = dirty.map((f) => `${f}`).join("\n");
if (!ctx.hasUI) {
return {
block: true,
reason: `git-checkout-guard: the following files have uncommitted changes and cannot be silently reverted:\n${fileList}\nShow the diff to the user and ask for explicit confirmation first.`,
};
}
const choice = await ctx.ui.select(
`⚠️ git-checkout-guard\n\nThe command:\n ${command}\n\nwould discard uncommitted changes in:\n${fileList}\n\nProceed?`,
["No, cancel (show diff instead)", "Yes, discard changes anyway"],
);
if (choice !== "Yes, discard changes anyway") {
return {
block: true,
reason: `Blocked by git-checkout-guard. Run \`git diff ${dirty.join(" ")}\` and review before discarding.`,
};
}
return undefined;
});
}