import { execa } from "execa"; import { z } from "zod"; import { truncate } from "../utils/truncate.js"; import type { ToolDef } from "./types.js"; async function tryGit(args: string[], cwd: string): Promise { try { const result = await execa("git", args, { cwd, reject: false }); return result.exitCode === 0 ? result.stdout : ""; } catch { return ""; } } function runGit(args: string[], cwd: string) { return execa("git", args, { cwd, reject: false }); } // --- git_status: read-only inspection, auto-runs like grep/read_file --- const statusSchema = z.object({ operation: z.enum(["status", "diff", "log", "show", "branches"]), paths: z.array(z.string()).optional().describe("Limit `status`/`diff` to these paths, relative to the working directory."), staged: z.boolean().optional().describe("For `diff`: show staged changes (--cached) instead of the working tree diff."), ref: z.string().optional().describe("For `show`: the commit/ref to inspect (default HEAD). For `log`: start from this ref."), maxCount: z.number().int().min(1).max(200).optional().describe("For `log`: maximum number of commits to return (default 20)."), }); export const gitStatusTool: ToolDef> = { name: "git_status", description: "Inspect the git repository at the working directory: `status` (working tree state), `diff` (unstaged or, with staged:true, " + "staged changes), `log` (recent commits), `show` (a specific commit/ref, default HEAD), or `branches` (local branches with " + "upstream tracking info). Read-only — runs automatically without confirmation.", schema: statusSchema, mutating: false, handler: async ({ operation, paths, staged, ref, maxCount }, ctx) => { const pathArgs = paths?.length ? ["--", ...paths] : []; const args: string[] = (() => { switch (operation) { case "status": return ["status", "--short", "--branch", ...pathArgs]; case "diff": return ["diff", ...(staged ? ["--cached"] : []), ...pathArgs]; case "log": return ["log", `--max-count=${maxCount ?? 20}`, "--oneline", "--decorate", ...(ref ? [ref] : [])]; case "show": return ["show", ref ?? "HEAD"]; case "branches": return ["branch", "--list", "-vv"]; } })(); const result = await runGit(args, ctx.cwd); if (result.exitCode !== 0) { return { error: truncate(result.stderr || result.stdout || `git ${operation} failed (exit ${result.exitCode})`) }; } return { output: truncate(result.stdout) }; }, }; // --- git_commit: mutating operations, gated behind a confirmation prompt --- // Ref-like arguments (branch names, refs, remote names) are passed to git as bare positional // args with no `--` separator. A value starting with `-` would otherwise be parsed by git as a // flag instead of a ref/branch (e.g. ref: "--hard" turning a plain reset into a hard reset) — // reject it at the schema boundary so it can never reach execa's argv. function refLike(description: string) { return z .string() .refine((v) => !v.startsWith("-"), { message: 'must not start with "-" (would be parsed as a git flag, not a ref/branch name)' }) .describe(description) .optional(); } const commitSchema = z.object({ operation: z.enum([ "add", "commit", "create_branch", "checkout", "push", "reset", "stash", "merge", "rebase", "delete_branch", ]), paths: z.array(z.string()).optional().describe("For `add`: paths to stage. For `stash push`: paths to stash. Omit to stage all changes."), message: z.string().optional().describe("For `commit`: commit message. For `stash push`: stash message."), branchName: refLike("Branch name. Required for create_branch/checkout/merge/rebase/delete_branch."), createIfMissing: z.boolean().optional().describe("For `checkout`: create the branch if it doesn't exist yet (like `checkout -b`)."), remote: refLike('For `push`: remote name (default "origin").'), setUpstream: z.boolean().optional().describe("For `push`: set upstream tracking — needed the first time a new branch is pushed."), ref: refLike("For `reset`: ref to reset to (default HEAD). For `merge`/`rebase`: alternative ref if branchName omitted."), mode: z.enum(["soft", "mixed", "hard"]).optional().describe("For `reset`: reset mode (default mixed)."), strategy: z.enum(["fast-forward", "no-ff", "ff-only"]).optional().describe("For `merge`: merge strategy."), stashRef: refLike("For `stash pop`: stash reference (default stash@{0})."), force: z.boolean().optional().describe("For `delete_branch`: force delete an unmerged branch."), }); export const gitCommitTool: ToolDef> = { name: "git_commit", description: "Make changes to the git repository at the working directory: `add` (stage paths, or all changes if omitted), `commit` " + "(requires `message`; commit staged changes), `create_branch` (requires `branchName`; branch at current HEAD without switching), " + "`checkout` (requires `branchName`; switch branches, or create-and-switch with createIfMissing), `push` (push current branch), " + "`reset` (reset current branch; default mixed, default ref HEAD), `stash` (push or pop a stash), `merge` (merge branchName into " + "current branch), `rebase` (rebase current branch onto branchName), or `delete_branch` (requires `branchName`; delete a local branch). " + "Each mutating use requires the user's confirmation with a preview of the affected state.", schema: commitSchema, mutating: true, preview: async (args, ctx) => buildPreview(args, ctx.cwd), handler: async (args, ctx) => runMutatingOperation(args, ctx.cwd), }; async function buildPreview( { operation, paths, message, branchName, createIfMissing, remote, setUpstream, ref, mode, strategy, stashRef, force, }: z.infer, cwd: string, ): Promise { switch (operation) { case "add": { const target = paths?.length ? paths.join(", ") : "all changes"; const status = await tryGit(["status", "--short", ...(paths?.length ? ["--", ...paths] : [])], cwd); return status ? `Stage ${target}:\n\n${status}` : `Stage ${target} (no changes detected).`; } case "commit": { const staged = await tryGit(["diff", "--cached"], cwd); if (!staged) return "Nothing is staged — this commit will fail. Use the `add` operation first."; return `Commit message: "${message ?? "(none provided — this will fail)"}"\n\n${staged}`; } case "create_branch": return `Create new branch "${branchName}" pointing at current HEAD (does not switch to it).`; case "checkout": { const status = await tryGit(["status", "--short"], cwd); const action = createIfMissing ? `Create and switch to new branch "${branchName}"` : `Switch to branch "${branchName}"`; return status ? `${action}.\n\nUncommitted changes that will carry over:\n${status}` : `${action}. Working tree is clean.`; } case "push": { const remoteName = remote ?? "origin"; const branch = (await tryGit(["rev-parse", "--abbrev-ref", "HEAD"], cwd)).trim() || "HEAD"; const ahead = (await tryGit(["log", "--oneline", "@{u}.."], cwd)) || (await tryGit(["log", "--oneline", "-n", "10"], cwd)); const upstreamNote = setUpstream ? " (setting upstream tracking)" : ""; return ahead ? `Push "${branch}" to "${remoteName}"${upstreamNote} — commits to push:\n\n${ahead}` : `Push "${branch}" to "${remoteName}"${upstreamNote}.`; } case "reset": { const targetRef = ref ?? "HEAD"; const modeText = mode ?? "mixed"; const diff = modeText === "hard" ? await tryGit(["diff", "--stat", "HEAD"], cwd) : await tryGit(["diff", "--cached", "--stat"], cwd); return `Reset (${modeText}) to ${targetRef}.${diff ? `\n\nFiles affected:\n${diff}` : ""}`; } case "stash": { if (stashRef) { const stash = await tryGit(["stash", "show", "-p", stashRef], cwd); return stash ? `Pop stash ${stashRef}:\n\n${stash}` : `Pop stash ${stashRef}.`; } const status = await tryGit(["status", "--short", ...(paths?.length ? ["--", ...paths] : [])], cwd); return status ? `Stash ${message ? `"${message}"` : "changes"}${paths?.length ? ` (paths: ${paths.join(", ")})` : ""}:\n\n${status}` : "No local changes to stash."; } case "merge": { const into = (await tryGit(["rev-parse", "--abbrev-ref", "HEAD"], cwd)).trim() || "HEAD"; const target = branchName ?? ref; const log = target ? await tryGit(["log", "--oneline", `${into}..${target}`], cwd) : ""; return target ? `Merge "${target}" into "${into}"${strategy ? ` (strategy: ${strategy})` : ""}.${log ? `\n\nCommits to merge:\n${log}` : ""}` : "merge requires `branchName` or `ref`."; } case "rebase": { const onto = branchName ?? ref; const current = (await tryGit(["rev-parse", "--abbrev-ref", "HEAD"], cwd)).trim() || "HEAD"; const log = onto ? await tryGit(["log", "--oneline", `${onto}..${current}`], cwd) : ""; return onto ? `Rebase "${current}" onto "${onto}".${log ? `\n\nCommits that will be replayed:\n${log}` : ""}` : "rebase requires `branchName` or `ref`."; } case "delete_branch": { const log = branchName ? await tryGit(["log", "--oneline", `${branchName}..HEAD`], cwd) : ""; const note = force ? " (force delete)" : ""; return branchName ? `Delete local branch "${branchName}"${note}.${log ? `\n\nCommits on ${branchName} not in HEAD:\n${log}` : ""}` : "delete_branch requires `branchName`."; } } } async function runMutatingOperation(args: z.infer, cwd: string): Promise { const { operation, paths, message, branchName, createIfMissing, remote, setUpstream, ref, mode, strategy, stashRef, force, } = args; const gitArgs: string[] = (() => { switch (operation) { case "add": return ["add", ...(paths?.length ? paths : ["-A"])]; case "commit": { if (!message) throw new Error("`message` is required for the commit operation."); return ["commit", "-m", message]; } case "create_branch": { if (!branchName) throw new Error("`branchName` is required for the create_branch operation."); return ["branch", branchName]; } case "checkout": { if (!branchName) throw new Error("`branchName` is required for the checkout operation."); return createIfMissing ? ["checkout", "-b", branchName] : ["checkout", branchName]; } case "push": return ["push", ...(setUpstream ? ["-u"] : []), remote ?? "origin", "HEAD"]; case "reset": { const targetRef = ref ?? "HEAD"; return ["reset", ...(mode ? [`--${mode}`] : []), targetRef]; } case "stash": { if (stashRef) { return ["stash", "pop", stashRef]; } const stashArgs = ["stash", "push"]; if (message) stashArgs.push("-m", message); if (paths?.length) stashArgs.push("--", ...paths); return stashArgs; } case "merge": { const target = branchName ?? ref; if (!target) throw new Error("`merge` requires `branchName` or `ref`."); const mergeArgs = ["merge"]; if (strategy === "no-ff") mergeArgs.push("--no-ff"); if (strategy === "ff-only") mergeArgs.push("--ff-only"); mergeArgs.push(target); return mergeArgs; } case "rebase": { const onto = branchName ?? ref; if (!onto) throw new Error("`rebase` requires `branchName` or `ref`."); return ["rebase", onto]; } case "delete_branch": { if (!branchName) throw new Error("`branchName` is required for the delete_branch operation."); return ["branch", force ? "-D" : "-d", branchName]; } } })(); const result = await runGit(gitArgs, cwd); if (result.exitCode !== 0) { return { error: truncate(result.stderr || result.stdout || `git ${operation} failed (exit ${result.exitCode})`) }; } const output = truncate(result.stdout || result.stderr); switch (operation) { case "add": return { staged: paths?.length ? paths : ["(all changes)"] }; case "create_branch": return { created: branchName }; case "checkout": return { switchedTo: branchName }; case "reset": return { resetTo: ref ?? "HEAD", mode: mode ?? "mixed" }; case "stash": return stashRef ? { popped: stashRef } : { stashed: message ?? "(no message)" }; case "merge": return { merged: branchName ?? ref, output }; case "rebase": return { rebasedOnto: branchName ?? ref, output }; case "delete_branch": return { deleted: branchName }; default: return { output }; } }