100 lines
3.0 KiB
TypeScript
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;
|
|
});
|
|
}
|