Files
locode/src/tools/git.ts
T
kimandClaude Sonnet 5 2a35f40c73 Add plugin/hook/skill support, images, @mentions, dashboard, and stability fixes
Builds out locode's Claude Code plugin parity: MCP servers, slash commands,
sub-agents, hooks (12 lifecycle events), and skills, all loadable from a
local path or git URL. Also adds image support (read_file, /import), @-mention
file autocomplete, a /dashboard stats view, /export with an editable filename
prompt, an expanded git tool (reset/stash/merge/rebase/delete_branch), and a
redesigned status bar.

On top of that, a review pass found and fixed 10 correctness/stability bugs:
argument-injection in git reset/merge/rebase (a ref like "--hard" was parsed
as a flag), tool-call image results interleaving with native tool_call_id
messages and breaking OpenAI-compatible message ordering, backgrounded bash
jobs still being silently killed by their original timeout with the kill
masked as a clean exit, a SubagentStart hook's block being ignored, the
sub-agent timeout clock starting before the hook it should exclude, a
template-expansion bug that could re-substitute $1..$9 placeholders,
unbounded background-job output buffers, a missing directory-target fallback
in /export, and image size caps checked after reading the whole file instead
of before.

Session saves and exports now go through a shared atomic
write-then-rename helper so a crash can't leave a truncated file.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 18:17:45 +09:00

299 lines
13 KiB
TypeScript

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<string> {
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<z.infer<typeof statusSchema>> = {
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<z.infer<typeof commitSchema>> = {
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<typeof commitSchema>,
cwd: string,
): Promise<string> {
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<typeof commitSchema>, cwd: string): Promise<unknown> {
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 };
}
}