src / tools / git.ts
src / tools / git.ts
import { tool, type Tool } from "@lmstudio/sdk";
import { existsSync } from "fs";
import { mkdir, stat, unlink, writeFile } from "fs/promises";
import { join } from "path";
import { z } from "zod";
import { BACKUP_DIR, snapshot } from "../backup";
import { runCommand, type CommandResult } from "../shell";
import { clamp, type Workspace } from "../workspace";
/** No pager (exec has no tty to page into) and no octal-escaped unicode paths. */
const GIT = "git --no-pager -c core.quotepath=false";
/** Unit separator: safe in every shell, and never appears in a git field. */
const SEP = "\x1f";
const MAX_DIFF_CHARS = 6000;
const MAX_SHOW_CHARS = 6000;
const MAX_FILE_CHARS = 8000;
const MAX_ENTRIES_PER_GROUP = 40;
const MAX_LOG_COMMITS = 50;
const MAX_BLAME_SPAN = 200;
const MAX_ADD_PATHS = 50;
const MAX_COMMIT_MESSAGE_CHARS = 2000;
const COMMIT_MSG_NAME = ".commit-message.tmp";
/**
* cmd.exe and sh disagree about how to escape these, so a path carrying one is
* refused outright instead of escaped. Every argument that reaches the command
* line is either validated here or is a constant.
*/
const SHELL_UNSAFE = /["`$&|;<>^%!*?\r\n]/;
const REVISION_OK = /^[A-Za-z0-9][A-Za-z0-9._/^~@{}-]*$/;
const BRANCH_OK = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/;
const STATE_LABELS: Record<string, string> = {
M: "modified",
A: "added",
D: "deleted",
R: "renamed",
C: "copied",
T: "type changed",
U: "unmerged",
};
function quoteArg(value: string): string | undefined {
if (value === "" || SHELL_UNSAFE.test(value)) return undefined;
return `"${value}"`;
}
/** Flattens a git field to one printable line so it cannot wreck the layout. */
function oneLine(text: string, max: number): string {
const flat = text.replace(/[\u0000-\u001f\u007f\ufeff]/g, " ").replace(/\s+/g, " ").trim();
return flat.length > max ? `${flat.slice(0, max)}...` : flat;
}
function renderBranchLine(record: string): string {
let body = record.slice(3).trim();
let unborn = false;
if (body.startsWith("No commits yet on ")) {
unborn = true;
body = body.slice("No commits yet on ".length);
}
if (body.startsWith("HEAD (no branch)")) {
return "Detached HEAD -- not on any branch. Ask the human before committing here.";
}
let divergence = "";
const bracket = /\s+\[([^\]]+)\]$/.exec(body);
if (bracket !== null) {
divergence = bracket[1];
body = body.slice(0, bracket.index);
}
let branch = body;
let upstream = "";
const dots = body.indexOf("...");
if (dots >= 0) {
branch = body.slice(0, dots);
upstream = body.slice(dots + 3);
}
const parts = [`On branch ${branch}`];
if (upstream === "") {
parts.push("no upstream branch");
} else {
parts.push(divergence === "" ? `in sync with ${upstream}` : `${divergence} vs ${upstream}`);
}
if (unborn) parts.push("no commits yet");
return `${parts.join(", ")}.`;
}
/** Turns `git status --porcelain -z --branch` into something a small model can read. */
function renderStatus(raw: string): string {
const records = raw.split("\0").filter((record) => record !== "");
let header = "On an unknown branch.";
const staged: string[] = [];
const unstaged: string[] = [];
const untracked: string[] = [];
const conflicted: string[] = [];
for (let i = 0; i < records.length; i++) {
const record = records[i];
if (record.startsWith("## ")) {
header = renderBranchLine(record);
continue;
}
if (record.length < 4) continue;
const index = record[0];
const worktree = record[1];
let path = record.slice(3);
// In -z mode a rename or copy puts the previous path in the next record.
if (index === "R" || index === "C" || worktree === "R" || worktree === "C") {
const original = records[i + 1];
if (original !== undefined) {
path = `${path} (was ${original})`;
i++;
}
}
if (index === "!") continue;
if (index === "?") {
untracked.push(path);
continue;
}
if (
index === "U" ||
worktree === "U" ||
(index === "A" && worktree === "A") ||
(index === "D" && worktree === "D")
) {
conflicted.push(path);
continue;
}
if (index !== " ") staged.push(`${STATE_LABELS[index] ?? index}: ${path}`);
if (worktree !== " ") unstaged.push(`${STATE_LABELS[worktree] ?? worktree}: ${path}`);
}
const sections = [header];
const addSection = (title: string, items: string[]): void => {
if (items.length === 0) return;
const shown = items.slice(0, MAX_ENTRIES_PER_GROUP);
const hidden = items.length - shown.length;
const body = shown.map((item) => ` ${item}`).join("\n");
const tail = hidden > 0 ? `\n ...and ${hidden} more (list truncated)` : "";
sections.push(`${title} (${items.length}):\n${body}${tail}`);
};
addSection("Conflicts -- resolve these first", conflicted);
addSection("Staged for the next commit", staged);
addSection("Changed but not staged", unstaged);
addSection("Untracked", untracked);
if (sections.length === 1) sections.push("Working tree is clean -- nothing to commit.");
return sections.join("\n\n");
}
export function gitTools(ws: Workspace): Tool[] {
// Every git tool spawns a process, so without shell permission none can exist.
if (!ws.allowShell) return [];
let repoConfirmed = false;
const git = (args: string): Promise<CommandResult> =>
runCommand(`${GIT} ${args}`, ws.runOptions);
const missingGit = (result: CommandResult): boolean =>
/not recognized|not found|ENOENT/i.test(`${result.error} ${result.stderr}`);
/** One plain sentence when git is unusable here, otherwise undefined. */
const repoProblem = async (): Promise<string | undefined> => {
if (repoConfirmed) return undefined;
const result = await git("rev-parse --git-dir");
if (result.ok) {
repoConfirmed = true;
return undefined;
}
if (missingGit(result)) {
return "git is not installed or is not on PATH, so the git tools cannot be used here.";
}
return (
`This folder is not a git repository, so there is no history to read (${ws.root}). ` +
`Ask the human to run 'git init' if it should be one.`
);
};
const failure = (result: CommandResult, hint: string): string => {
if (result.timedOut) {
return `Error: git did not finish within ${ws.commandTimeoutSec}s and was killed. ${hint}`;
}
if (/maxBuffer/i.test(result.error)) {
return (
`Error: git printed more than the ${ws.maxFileSizeKb} KB output limit. ` +
`Narrow the request with a path or a smaller limit.`
);
}
const detail = (result.stderr.trim() || result.error).split(/\r?\n/).slice(0, 4).join("\n");
return `Error: ${clamp(detail, 400, "git error")}\n${hint}`;
};
/** Containment errors from resolveInRoot are meant to escape -- do not wrap this. */
const resolved = (path: string): { abs: string; rel: string } => {
const abs = ws.resolveInRoot(path);
return { abs, rel: ws.rel(abs).split("\\").join("/") };
};
const tools: Tool[] = [];
tools.push(
tool({
name: "git_status",
description:
"Show what has changed in this workspace's git repository: the current branch, how far " +
"ahead or behind its upstream it is, and which files are staged, modified, untracked or " +
"conflicted. Call it before committing and whenever you need to know what you have " +
"touched. Read-only and safe to repeat.",
parameters: {},
implementation: async (_params, ctx) => {
ctx.status("Reading git status");
const problem = await repoProblem();
if (problem !== undefined) return problem;
const result = await git("status --porcelain=v1 -z --branch --untracked-files=normal");
if (!result.ok) {
return failure(result, "Nothing was changed. Try git_status again in a moment.");
}
return renderStatus(result.stdout);
},
}),
);
tools.push(
tool({
name: "git_diff",
description:
"Show the exact line-by-line changes in the repository. Use it to review your own edits " +
"before committing, or to see what someone changed in a file. Returns unified diff text, " +
"truncated with a file summary if it is very large. Read-only and safe to repeat.",
parameters: {
path: z
.string()
.default("")
.describe("Limit the diff to one file or folder. Leave empty for the whole workspace."),
staged: z
.boolean()
.default(false)
.describe(
"false = edits you have not staged yet. true = what is already staged for the next commit.",
),
context_lines: z
.number()
.int()
.default(3)
.describe("Unchanged lines shown around each change. 3 is usually right; max 10."),
},
implementation: async ({ path, staged, context_lines }, ctx) => {
const rel = path.trim() === "" ? "" : resolved(path).rel;
const problem = await repoProblem();
if (problem !== undefined) return problem;
let scope = "";
if (rel !== "" && rel !== ".") {
const spec = quoteArg(rel);
if (spec === undefined) {
return `Error: the path "${path}" contains characters that cannot be passed to git safely. Ask the human to rename it.`;
}
scope = ` -- ${spec}`;
}
const where = rel === "" || rel === "." ? "the workspace" : rel;
const unified = Math.min(Math.max(context_lines, 0), 10);
const cached = staged ? "--cached " : "";
ctx.status(`Diffing ${where}`);
const result = await git(`diff ${cached}--no-color --unified=${unified}${scope}`);
if (!result.ok) {
return failure(result, "Use git_status to check the path exists and has changes.");
}
const diff = result.stdout;
if (diff.trim() === "") {
return staged
? `Nothing is staged for ${where}. Use git_add to stage changes first.`
: `No unstaged changes in ${where}. Try staged=true to see what is already staged.`;
}
if (diff.length <= MAX_DIFF_CHARS) return diff;
const summary = await git(`diff ${cached}--no-color --stat${scope}`);
const files = summary.ok ? clamp(summary.stdout.trim(), 1000, "file summary") : "";
return (
`${clamp(diff, MAX_DIFF_CHARS, "diff")}\n\n` +
`Only the first part of the diff is shown. Every file it touches:\n${files}\n\n` +
`Call git_diff again with a specific path to read the rest.`
);
},
}),
);
tools.push(
tool({
name: "git_log",
description:
"List recent commits, newest first, one line each: short sha, how long ago, author, and " +
"subject. Use it to see what has been happening in the repo or to find the sha of a " +
"commit you then pass to git_show. Read-only and safe to repeat.",
parameters: {
limit: z
.number()
.int()
.default(10)
.describe(`How many commits to list. Capped at ${MAX_LOG_COMMITS}.`),
path: z
.string()
.default("")
.describe("Only commits that touched this file or folder. Leave empty for all commits."),
},
implementation: async ({ limit, path }, ctx) => {
const rel = path.trim() === "" ? "" : resolved(path).rel;
const problem = await repoProblem();
if (problem !== undefined) return problem;
let scope = "";
if (rel !== "" && rel !== ".") {
const spec = quoteArg(rel);
if (spec === undefined) {
return `Error: the path "${path}" contains characters that cannot be passed to git safely.`;
}
scope = ` -- ${spec}`;
}
const count = Math.min(Math.max(limit, 1), MAX_LOG_COMMITS);
ctx.status(`Reading last ${count} commit(s)`);
const format = `--pretty=format:%h${SEP}%ad${SEP}%an${SEP}%s`;
const result = await git(
`log --no-color --max-count=${count} --date=relative ${format}${scope}`,
);
if (!result.ok) {
const text = `${result.stderr} ${result.error}`;
if (/does not have any commits|unknown revision/i.test(text)) {
return "This repository has no commits yet.";
}
return failure(result, "Check the path with git_status.");
}
const lines = result.stdout.split(/\r?\n/).filter((line) => line.trim() !== "");
if (lines.length === 0) {
return rel === ""
? "This repository has no commits yet."
: `No commits have touched ${rel}.`;
}
const rendered = lines.map((line) => {
const [sha = "?", when = "", who = "", subject = ""] = line.split(SEP);
return `${sha} ${oneLine(when, 20)} ${oneLine(who, 24)} ${oneLine(subject, 100)}`;
});
const header = rel === "" ? `${rendered.length} most recent commit(s):` : `${rendered.length} commit(s) touching ${rel}:`;
return `${header}\n${rendered.join("\n")}`;
},
}),
);
tools.push(
tool({
name: "git_show",
description:
"Inspect one commit. With no path it returns that commit's message and the list of files " +
"it changed. With a path it returns that file's exact contents as of that commit, which " +
"is how you see what a file looked like before an edit. Read-only and safe to repeat.",
parameters: {
revision: z
.string()
.default("HEAD")
.describe(
"Which commit: a short sha from git_log, 'HEAD' for the newest commit, or 'HEAD~1' for the one before it.",
),
path: z
.string()
.default("")
.describe("Leave empty for the message and file list. Give a file path for its contents."),
},
implementation: async ({ revision, path }, ctx) => {
const wanted = path.trim() === "" ? "" : resolved(path).rel;
const rev = revision.trim();
if (rev.length > 200 || !REVISION_OK.test(rev)) {
return `Error: "${revision}" is not a usable revision. Use a short sha from git_log, 'HEAD', or 'HEAD~1'.`;
}
const problem = await repoProblem();
if (problem !== undefined) return problem;
if (wanted === "" || wanted === ".") {
ctx.status(`Reading commit ${rev}`);
const result = await git(
`log --no-color --max-count=1 --stat --first-parent --format=medium ${rev}`,
);
if (!result.ok) {
return failure(result, `Check the sha with git_log; "${rev}" may not exist.`);
}
if (result.stdout.trim() === "") return `No commit matches "${rev}".`;
return clamp(result.stdout.trim(), MAX_SHOW_CHARS, "commit details");
}
// "<rev>:./<path>" makes git resolve the path relative to the workspace,
// not to the repository root, which may be further up.
const target = quoteArg(`${rev}:./${wanted}`);
if (target === undefined) {
return `Error: the path "${path}" contains characters that cannot be passed to git safely.`;
}
ctx.status(`Reading ${wanted} at ${rev}`);
const result = await git(`show --no-color ${target}`);
if (!result.ok) {
const text = `${result.stderr} ${result.error}`;
if (/does not exist|exists on disk, but not in/i.test(text)) {
return `"${wanted}" did not exist at commit ${rev}. Use git_show with no path to see which files that commit changed.`;
}
return failure(result, "Check the sha with git_log and the path with git_status.");
}
return clamp(result.stdout, MAX_FILE_CHARS, `${wanted} at ${rev}`);
},
}),
);
tools.push(
tool({
name: "git_blame",
description:
"Show which commit last changed each line in a range of a file, with the author and date. " +
"Use it when you need to know who wrote a specific line or when it appeared. Read-only " +
"and safe to repeat.",
parameters: {
path: z.string().describe("File path relative to the workspace root."),
start_line: z.number().int().default(1).describe("First line to look at, counting from 1."),
end_line: z
.number()
.int()
.default(40)
.describe(`Last line to look at. At most ${MAX_BLAME_SPAN} lines are returned.`),
},
implementation: async ({ path, start_line, end_line }, ctx) => {
const { abs, rel } = resolved(path);
const problem = await repoProblem();
if (problem !== undefined) return problem;
if (rel === "." || !existsSync(abs)) {
return `Error: "${path}" is not a file in this workspace. Use list_directory to find the right path.`;
}
const spec = quoteArg(rel);
if (spec === undefined) {
return `Error: the path "${path}" contains characters that cannot be passed to git safely.`;
}
const start = Math.max(start_line, 1);
let end = Math.max(end_line, start);
let capped = false;
if (end - start + 1 > MAX_BLAME_SPAN) {
end = start + MAX_BLAME_SPAN - 1;
capped = true;
}
ctx.status(`Blaming ${rel} lines ${start}-${end}`);
const result = await git(`blame -L ${start},${end} --date=short -w -- ${spec}`);
if (!result.ok) {
const text = `${result.stderr} ${result.error}`;
if (/no such path|has only \d+ lines|no matches found/i.test(text)) {
return `Error: ${oneLine(result.stderr || result.error, 200)}. Read the file first to check its length, then call git_blame with a smaller range.`;
}
return failure(result, "The file may be untracked -- check it with git_status.");
}
if (result.stdout.trim() === "") return `No blame information for ${rel} lines ${start}-${end}.`;
const lines = result.stdout
.split(/\r?\n/)
.filter((line) => line.trim() !== "")
.map((line) => (line.length > 160 ? `${line.slice(0, 160)}...` : line));
const note = capped ? `\n\n[range capped at ${MAX_BLAME_SPAN} lines]` : "";
return `${rel} lines ${start}-${end}:\n${clamp(lines.join("\n"), MAX_DIFF_CHARS, "blame")}${note}`;
},
}),
);
if (!ws.allowGitWrite) return tools;
tools.push(
tool({
name: "git_add",
description:
"Stage files so the next git_commit includes them. Pass one path, or several separated by " +
"commas, or '.' for everything that changed. Staging the same file twice is harmless. " +
"Returns the full list of what is staged afterwards.",
parameters: {
paths: z
.string()
.default(".")
.describe(
"Path(s) to stage, relative to the workspace root, separated by commas. '.' stages every change.",
),
},
implementation: async ({ paths }, ctx) => {
const wanted = paths
.split(/[,\n]/)
.map((entry) => entry.trim())
.filter((entry) => entry !== "");
const chosen = wanted.length === 0 ? ["."] : wanted.slice(0, MAX_ADD_PATHS);
const specs: string[] = [];
for (const candidate of chosen) {
const { rel } = resolved(candidate);
const spec = quoteArg(rel);
if (spec === undefined) {
return `Error: "${candidate}" contains characters that cannot be passed to git safely. Ask the human to stage that one.`;
}
specs.push(spec);
}
const problem = await repoProblem();
if (problem !== undefined) return problem;
ctx.status(`Staging ${chosen.length} path(s)`);
// The backup folder is this plugin's safety net, never something to commit.
const exclude = `":(exclude)${BACKUP_DIR}"`;
const result = await git(`add -- ${specs.join(" ")} ${exclude}`);
if (!result.ok) {
return failure(result, "Check the path with git_status, then call git_add again.");
}
const listed = await git("diff --cached --name-only");
if (!listed.ok || listed.stdout.trim() === "") {
return `Staged ${chosen.join(", ")}. Nothing new appeared in the index -- those files may already be committed and unchanged.`;
}
const names = listed.stdout.split(/\r?\n/).filter((name) => name.trim() !== "");
const shown = names.slice(0, MAX_ENTRIES_PER_GROUP);
const hidden = names.length - shown.length;
return (
`${names.length} file(s) staged for the next commit:\n${shown.map((n) => ` ${n}`).join("\n")}` +
(hidden > 0 ? `\n ...and ${hidden} more` : "")
);
},
}),
);
tools.push(
tool({
name: "git_commit",
description:
"Commit everything currently staged, using the message you supply. Stage files with " +
"git_add first. This only records the commit locally -- it never pushes, never amends, " +
"and never rewrites history; if the human wants the work pushed to a remote, tell them to " +
"do it themselves. Calling it twice in a row is harmless: the second call finds nothing " +
"staged and does nothing.",
parameters: {
message: z
.string()
.describe(
"The commit message. First line: a short summary of what changed and why. Add detail on later lines if useful.",
),
},
implementation: async ({ message }, ctx) => {
const text = message.replace(/\r/g, "").trim();
if (text === "") {
return "Error: the commit message is empty. Write one line describing what changed, then call git_commit again.";
}
const problem = await repoProblem();
if (problem !== undefined) return problem;
const staged = await git("diff --cached --name-only");
if (!staged.ok) {
return failure(staged, "Check the repository state with git_status.");
}
if (staged.stdout.trim() === "") {
return "Nothing is staged, so there is nothing to commit. Use git_add to stage the files you changed, then call git_commit again.";
}
const body =
text.length > MAX_COMMIT_MESSAGE_CHARS
? `${text.slice(0, MAX_COMMIT_MESSAGE_CHARS)}\n\n[message truncated by the tool]`
: text;
// Passed through a file rather than -m so quotes and newlines in the
// message can never be reinterpreted by the shell.
const messageDir = join(ws.root, BACKUP_DIR);
const messageFile = join(messageDir, COMMIT_MSG_NAME);
try {
await mkdir(messageDir, { recursive: true });
await writeFile(messageFile, `${body}\n`, "utf-8");
} catch (caught) {
return `Error: could not write the temporary commit message file (${(caught as Error).message}). Check the workspace is writable.`;
}
ctx.status("Committing staged changes");
const result = await git(
`commit --cleanup=whitespace -F "${BACKUP_DIR}/${COMMIT_MSG_NAME}"`,
);
await unlink(messageFile).catch(() => undefined);
if (!result.ok) {
return failure(
result,
"If git asks for an identity, ask the human to set user.name and user.email; do not work around it.",
);
}
const output = result.stdout.trim() || result.stderr.trim() || "Commit created.";
return clamp(output, 1500, "commit output");
},
}),
);
tools.push(
tool({
name: "git_create_branch",
description:
"Create a new local branch and switch to it, so later commits land there instead of on " +
"the current branch. Use it before starting a change the human may want to throw away. " +
"It never deletes or pushes branches; if an existing branch needs to be switched to or " +
"removed, ask the human. Calling it twice with the same name is harmless.",
parameters: {
name: z
.string()
.describe("Branch name, e.g. 'fix-login-timeout'. Letters, digits, dot, dash, slash."),
},
implementation: async ({ name }, ctx) => {
const branch = name.trim();
if (
branch.length > 100 ||
!BRANCH_OK.test(branch) ||
branch.includes("..") ||
branch.endsWith("/") ||
branch.endsWith(".lock")
) {
return `Error: "${name}" is not a valid branch name. Use letters, digits, dots, dashes and slashes, e.g. 'fix-login-timeout'.`;
}
const problem = await repoProblem();
if (problem !== undefined) return problem;
const current = await git("rev-parse --abbrev-ref HEAD");
if (current.ok && current.stdout.trim() === branch) {
return `Already on branch "${branch}" -- nothing to do.`;
}
ctx.status(`Creating branch ${branch}`);
const result = await git(`checkout -b "${branch}"`);
if (!result.ok) {
if (/already exists/i.test(`${result.stderr} ${result.error}`)) {
return `Error: a branch named "${branch}" already exists. Pick a different name, or ask the human if they want you to work on the existing one.`;
}
return failure(result, "Check for uncommitted changes with git_status.");
}
return `Created branch "${branch}" and switched to it. New commits will land here instead of on the previous branch.`;
},
}),
);
tools.push(
tool({
name: "git_restore",
description:
"Undo changes to one file. With staged=false it throws away your uncommitted edits and " +
"puts the file back to its last committed state -- a backup is taken first so the edits " +
"can still be recovered. With staged=true it only unstages the file and leaves it on disk " +
"untouched. One file at a time; it never resets or cleans the whole repository.",
parameters: {
path: z.string().describe("The single file to restore, relative to the workspace root."),
staged: z
.boolean()
.default(false)
.describe(
"false = discard the edits on disk (backed up first). true = only unstage it, keeping the edits.",
),
},
implementation: async ({ path, staged }, ctx) => {
const { abs, rel } = resolved(path);
if (rel === "." || rel === "") {
return "Error: name one file to restore. Restoring the whole workspace at once is not allowed -- ask the human if that is really what is wanted.";
}
const spec = quoteArg(rel);
if (spec === undefined) {
return `Error: the path "${path}" contains characters that cannot be passed to git safely.`;
}
const problem = await repoProblem();
if (problem !== undefined) return problem;
if (staged) {
ctx.status(`Unstaging ${rel}`);
const result = await git(`restore --staged -- ${spec}`);
if (!result.ok) {
return failure(
result,
"If git reports 'restore' is not a command, its version is older than 2.23 -- tell the human.",
);
}
return `Unstaged ${rel}. The file on disk is unchanged; it is simply no longer queued for the next commit.`;
}
let backup: string | undefined;
try {
if (existsSync(abs)) {
const info = await stat(abs);
if (info.isDirectory()) {
return `Error: "${rel}" is a folder. Restore one file at a time so a single call cannot wipe out a whole tree.`;
}
backup = await snapshot(ws, abs);
}
} catch (caught) {
return `Error: could not back up ${rel} before restoring it (${(caught as Error).message}). Nothing was changed.`;
}
ctx.warn(`Discarding uncommitted changes to ${rel}.`);
ctx.status(`Restoring ${rel}`);
const result = await git(`restore -- ${spec}`);
if (!result.ok) {
return failure(
result,
"The file may be untracked, in which case git has no version to restore -- check git_status.",
);
}
const note =
backup === undefined
? ""
: ` The previous contents were backed up to ${ws.rel(backup)}.`;
return `Restored ${rel} to its last committed state.${note}`;
},
}),
);
return tools;
}
import { tool, type Tool } from "@lmstudio/sdk";
import { existsSync } from "fs";
import { mkdir, stat, unlink, writeFile } from "fs/promises";
import { join } from "path";
import { z } from "zod";
import { BACKUP_DIR, snapshot } from "../backup";
import { runCommand, type CommandResult } from "../shell";
import { clamp, type Workspace } from "../workspace";
/** No pager (exec has no tty to page into) and no octal-escaped unicode paths. */
const GIT = "git --no-pager -c core.quotepath=false";
/** Unit separator: safe in every shell, and never appears in a git field. */
const SEP = "\x1f";
const MAX_DIFF_CHARS = 6000;
const MAX_SHOW_CHARS = 6000;
const MAX_FILE_CHARS = 8000;
const MAX_ENTRIES_PER_GROUP = 40;
const MAX_LOG_COMMITS = 50;
const MAX_BLAME_SPAN = 200;
const MAX_ADD_PATHS = 50;
const MAX_COMMIT_MESSAGE_CHARS = 2000;
const COMMIT_MSG_NAME = ".commit-message.tmp";
/**
* cmd.exe and sh disagree about how to escape these, so a path carrying one is
* refused outright instead of escaped. Every argument that reaches the command
* line is either validated here or is a constant.
*/
const SHELL_UNSAFE = /["`$&|;<>^%!*?\r\n]/;
const REVISION_OK = /^[A-Za-z0-9][A-Za-z0-9._/^~@{}-]*$/;
const BRANCH_OK = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/;
const STATE_LABELS: Record<string, string> = {
M: "modified",
A: "added",
D: "deleted",
R: "renamed",
C: "copied",
T: "type changed",
U: "unmerged",
};
function quoteArg(value: string): string | undefined {
if (value === "" || SHELL_UNSAFE.test(value)) return undefined;
return `"${value}"`;
}
/** Flattens a git field to one printable line so it cannot wreck the layout. */
function oneLine(text: string, max: number): string {
const flat = text.replace(/[\u0000-\u001f\u007f\ufeff]/g, " ").replace(/\s+/g, " ").trim();
return flat.length > max ? `${flat.slice(0, max)}...` : flat;
}
function renderBranchLine(record: string): string {
let body = record.slice(3).trim();
let unborn = false;
if (body.startsWith("No commits yet on ")) {
unborn = true;
body = body.slice("No commits yet on ".length);
}
if (body.startsWith("HEAD (no branch)")) {
return "Detached HEAD -- not on any branch. Ask the human before committing here.";
}
let divergence = "";
const bracket = /\s+\[([^\]]+)\]$/.exec(body);
if (bracket !== null) {
divergence = bracket[1];
body = body.slice(0, bracket.index);
}
let branch = body;
let upstream = "";
const dots = body.indexOf("...");
if (dots >= 0) {
branch = body.slice(0, dots);
upstream = body.slice(dots + 3);
}
const parts = [`On branch ${branch}`];
if (upstream === "") {
parts.push("no upstream branch");
} else {
parts.push(divergence === "" ? `in sync with ${upstream}` : `${divergence} vs ${upstream}`);
}
if (unborn) parts.push("no commits yet");
return `${parts.join(", ")}.`;
}
/** Turns `git status --porcelain -z --branch` into something a small model can read. */
function renderStatus(raw: string): string {
const records = raw.split("\0").filter((record) => record !== "");
let header = "On an unknown branch.";
const staged: string[] = [];
const unstaged: string[] = [];
const untracked: string[] = [];
const conflicted: string[] = [];
for (let i = 0; i < records.length; i++) {
const record = records[i];
if (record.startsWith("## ")) {
header = renderBranchLine(record);
continue;
}
if (record.length < 4) continue;
const index = record[0];
const worktree = record[1];
let path = record.slice(3);
// In -z mode a rename or copy puts the previous path in the next record.
if (index === "R" || index === "C" || worktree === "R" || worktree === "C") {
const original = records[i + 1];
if (original !== undefined) {
path = `${path} (was ${original})`;
i++;
}
}
if (index === "!") continue;
if (index === "?") {
untracked.push(path);
continue;
}
if (
index === "U" ||
worktree === "U" ||
(index === "A" && worktree === "A") ||
(index === "D" && worktree === "D")
) {
conflicted.push(path);
continue;
}
if (index !== " ") staged.push(`${STATE_LABELS[index] ?? index}: ${path}`);
if (worktree !== " ") unstaged.push(`${STATE_LABELS[worktree] ?? worktree}: ${path}`);
}
const sections = [header];
const addSection = (title: string, items: string[]): void => {
if (items.length === 0) return;
const shown = items.slice(0, MAX_ENTRIES_PER_GROUP);
const hidden = items.length - shown.length;
const body = shown.map((item) => ` ${item}`).join("\n");
const tail = hidden > 0 ? `\n ...and ${hidden} more (list truncated)` : "";
sections.push(`${title} (${items.length}):\n${body}${tail}`);
};
addSection("Conflicts -- resolve these first", conflicted);
addSection("Staged for the next commit", staged);
addSection("Changed but not staged", unstaged);
addSection("Untracked", untracked);
if (sections.length === 1) sections.push("Working tree is clean -- nothing to commit.");
return sections.join("\n\n");
}
export function gitTools(ws: Workspace): Tool[] {
// Every git tool spawns a process, so without shell permission none can exist.
if (!ws.allowShell) return [];
let repoConfirmed = false;
const git = (args: string): Promise<CommandResult> =>
runCommand(`${GIT} ${args}`, ws.runOptions);
const missingGit = (result: CommandResult): boolean =>
/not recognized|not found|ENOENT/i.test(`${result.error} ${result.stderr}`);
/** One plain sentence when git is unusable here, otherwise undefined. */
const repoProblem = async (): Promise<string | undefined> => {
if (repoConfirmed) return undefined;
const result = await git("rev-parse --git-dir");
if (result.ok) {
repoConfirmed = true;
return undefined;
}
if (missingGit(result)) {
return "git is not installed or is not on PATH, so the git tools cannot be used here.";
}
return (
`This folder is not a git repository, so there is no history to read (${ws.root}). ` +
`Ask the human to run 'git init' if it should be one.`
);
};
const failure = (result: CommandResult, hint: string): string => {
if (result.timedOut) {
return `Error: git did not finish within ${ws.commandTimeoutSec}s and was killed. ${hint}`;
}
if (/maxBuffer/i.test(result.error)) {
return (
`Error: git printed more than the ${ws.maxFileSizeKb} KB output limit. ` +
`Narrow the request with a path or a smaller limit.`
);
}
const detail = (result.stderr.trim() || result.error).split(/\r?\n/).slice(0, 4).join("\n");
return `Error: ${clamp(detail, 400, "git error")}\n${hint}`;
};
/** Containment errors from resolveInRoot are meant to escape -- do not wrap this. */
const resolved = (path: string): { abs: string; rel: string } => {
const abs = ws.resolveInRoot(path);
return { abs, rel: ws.rel(abs).split("\\").join("/") };
};
const tools: Tool[] = [];
tools.push(
tool({
name: "git_status",
description:
"Show what has changed in this workspace's git repository: the current branch, how far " +
"ahead or behind its upstream it is, and which files are staged, modified, untracked or " +
"conflicted. Call it before committing and whenever you need to know what you have " +
"touched. Read-only and safe to repeat.",
parameters: {},
implementation: async (_params, ctx) => {
ctx.status("Reading git status");
const problem = await repoProblem();
if (problem !== undefined) return problem;
const result = await git("status --porcelain=v1 -z --branch --untracked-files=normal");
if (!result.ok) {
return failure(result, "Nothing was changed. Try git_status again in a moment.");
}
return renderStatus(result.stdout);
},
}),
);
tools.push(
tool({
name: "git_diff",
description:
"Show the exact line-by-line changes in the repository. Use it to review your own edits " +
"before committing, or to see what someone changed in a file. Returns unified diff text, " +
"truncated with a file summary if it is very large. Read-only and safe to repeat.",
parameters: {
path: z
.string()
.default("")
.describe("Limit the diff to one file or folder. Leave empty for the whole workspace."),
staged: z
.boolean()
.default(false)
.describe(
"false = edits you have not staged yet. true = what is already staged for the next commit.",
),
context_lines: z
.number()
.int()
.default(3)
.describe("Unchanged lines shown around each change. 3 is usually right; max 10."),
},
implementation: async ({ path, staged, context_lines }, ctx) => {
const rel = path.trim() === "" ? "" : resolved(path).rel;
const problem = await repoProblem();
if (problem !== undefined) return problem;
let scope = "";
if (rel !== "" && rel !== ".") {
const spec = quoteArg(rel);
if (spec === undefined) {
return `Error: the path "${path}" contains characters that cannot be passed to git safely. Ask the human to rename it.`;
}
scope = ` -- ${spec}`;
}
const where = rel === "" || rel === "." ? "the workspace" : rel;
const unified = Math.min(Math.max(context_lines, 0), 10);
const cached = staged ? "--cached " : "";
ctx.status(`Diffing ${where}`);
const result = await git(`diff ${cached}--no-color --unified=${unified}${scope}`);
if (!result.ok) {
return failure(result, "Use git_status to check the path exists and has changes.");
}
const diff = result.stdout;
if (diff.trim() === "") {
return staged
? `Nothing is staged for ${where}. Use git_add to stage changes first.`
: `No unstaged changes in ${where}. Try staged=true to see what is already staged.`;
}
if (diff.length <= MAX_DIFF_CHARS) return diff;
const summary = await git(`diff ${cached}--no-color --stat${scope}`);
const files = summary.ok ? clamp(summary.stdout.trim(), 1000, "file summary") : "";
return (
`${clamp(diff, MAX_DIFF_CHARS, "diff")}\n\n` +
`Only the first part of the diff is shown. Every file it touches:\n${files}\n\n` +
`Call git_diff again with a specific path to read the rest.`
);
},
}),
);
tools.push(
tool({
name: "git_log",
description:
"List recent commits, newest first, one line each: short sha, how long ago, author, and " +
"subject. Use it to see what has been happening in the repo or to find the sha of a " +
"commit you then pass to git_show. Read-only and safe to repeat.",
parameters: {
limit: z
.number()
.int()
.default(10)
.describe(`How many commits to list. Capped at ${MAX_LOG_COMMITS}.`),
path: z
.string()
.default("")
.describe("Only commits that touched this file or folder. Leave empty for all commits."),
},
implementation: async ({ limit, path }, ctx) => {
const rel = path.trim() === "" ? "" : resolved(path).rel;
const problem = await repoProblem();
if (problem !== undefined) return problem;
let scope = "";
if (rel !== "" && rel !== ".") {
const spec = quoteArg(rel);
if (spec === undefined) {
return `Error: the path "${path}" contains characters that cannot be passed to git safely.`;
}
scope = ` -- ${spec}`;
}
const count = Math.min(Math.max(limit, 1), MAX_LOG_COMMITS);
ctx.status(`Reading last ${count} commit(s)`);
const format = `--pretty=format:%h${SEP}%ad${SEP}%an${SEP}%s`;
const result = await git(
`log --no-color --max-count=${count} --date=relative ${format}${scope}`,
);
if (!result.ok) {
const text = `${result.stderr} ${result.error}`;
if (/does not have any commits|unknown revision/i.test(text)) {
return "This repository has no commits yet.";
}
return failure(result, "Check the path with git_status.");
}
const lines = result.stdout.split(/\r?\n/).filter((line) => line.trim() !== "");
if (lines.length === 0) {
return rel === ""
? "This repository has no commits yet."
: `No commits have touched ${rel}.`;
}
const rendered = lines.map((line) => {
const [sha = "?", when = "", who = "", subject = ""] = line.split(SEP);
return `${sha} ${oneLine(when, 20)} ${oneLine(who, 24)} ${oneLine(subject, 100)}`;
});
const header = rel === "" ? `${rendered.length} most recent commit(s):` : `${rendered.length} commit(s) touching ${rel}:`;
return `${header}\n${rendered.join("\n")}`;
},
}),
);
tools.push(
tool({
name: "git_show",
description:
"Inspect one commit. With no path it returns that commit's message and the list of files " +
"it changed. With a path it returns that file's exact contents as of that commit, which " +
"is how you see what a file looked like before an edit. Read-only and safe to repeat.",
parameters: {
revision: z
.string()
.default("HEAD")
.describe(
"Which commit: a short sha from git_log, 'HEAD' for the newest commit, or 'HEAD~1' for the one before it.",
),
path: z
.string()
.default("")
.describe("Leave empty for the message and file list. Give a file path for its contents."),
},
implementation: async ({ revision, path }, ctx) => {
const wanted = path.trim() === "" ? "" : resolved(path).rel;
const rev = revision.trim();
if (rev.length > 200 || !REVISION_OK.test(rev)) {
return `Error: "${revision}" is not a usable revision. Use a short sha from git_log, 'HEAD', or 'HEAD~1'.`;
}
const problem = await repoProblem();
if (problem !== undefined) return problem;
if (wanted === "" || wanted === ".") {
ctx.status(`Reading commit ${rev}`);
const result = await git(
`log --no-color --max-count=1 --stat --first-parent --format=medium ${rev}`,
);
if (!result.ok) {
return failure(result, `Check the sha with git_log; "${rev}" may not exist.`);
}
if (result.stdout.trim() === "") return `No commit matches "${rev}".`;
return clamp(result.stdout.trim(), MAX_SHOW_CHARS, "commit details");
}
// "<rev>:./<path>" makes git resolve the path relative to the workspace,
// not to the repository root, which may be further up.
const target = quoteArg(`${rev}:./${wanted}`);
if (target === undefined) {
return `Error: the path "${path}" contains characters that cannot be passed to git safely.`;
}
ctx.status(`Reading ${wanted} at ${rev}`);
const result = await git(`show --no-color ${target}`);
if (!result.ok) {
const text = `${result.stderr} ${result.error}`;
if (/does not exist|exists on disk, but not in/i.test(text)) {
return `"${wanted}" did not exist at commit ${rev}. Use git_show with no path to see which files that commit changed.`;
}
return failure(result, "Check the sha with git_log and the path with git_status.");
}
return clamp(result.stdout, MAX_FILE_CHARS, `${wanted} at ${rev}`);
},
}),
);
tools.push(
tool({
name: "git_blame",
description:
"Show which commit last changed each line in a range of a file, with the author and date. " +
"Use it when you need to know who wrote a specific line or when it appeared. Read-only " +
"and safe to repeat.",
parameters: {
path: z.string().describe("File path relative to the workspace root."),
start_line: z.number().int().default(1).describe("First line to look at, counting from 1."),
end_line: z
.number()
.int()
.default(40)
.describe(`Last line to look at. At most ${MAX_BLAME_SPAN} lines are returned.`),
},
implementation: async ({ path, start_line, end_line }, ctx) => {
const { abs, rel } = resolved(path);
const problem = await repoProblem();
if (problem !== undefined) return problem;
if (rel === "." || !existsSync(abs)) {
return `Error: "${path}" is not a file in this workspace. Use list_directory to find the right path.`;
}
const spec = quoteArg(rel);
if (spec === undefined) {
return `Error: the path "${path}" contains characters that cannot be passed to git safely.`;
}
const start = Math.max(start_line, 1);
let end = Math.max(end_line, start);
let capped = false;
if (end - start + 1 > MAX_BLAME_SPAN) {
end = start + MAX_BLAME_SPAN - 1;
capped = true;
}
ctx.status(`Blaming ${rel} lines ${start}-${end}`);
const result = await git(`blame -L ${start},${end} --date=short -w -- ${spec}`);
if (!result.ok) {
const text = `${result.stderr} ${result.error}`;
if (/no such path|has only \d+ lines|no matches found/i.test(text)) {
return `Error: ${oneLine(result.stderr || result.error, 200)}. Read the file first to check its length, then call git_blame with a smaller range.`;
}
return failure(result, "The file may be untracked -- check it with git_status.");
}
if (result.stdout.trim() === "") return `No blame information for ${rel} lines ${start}-${end}.`;
const lines = result.stdout
.split(/\r?\n/)
.filter((line) => line.trim() !== "")
.map((line) => (line.length > 160 ? `${line.slice(0, 160)}...` : line));
const note = capped ? `\n\n[range capped at ${MAX_BLAME_SPAN} lines]` : "";
return `${rel} lines ${start}-${end}:\n${clamp(lines.join("\n"), MAX_DIFF_CHARS, "blame")}${note}`;
},
}),
);
if (!ws.allowGitWrite) return tools;
tools.push(
tool({
name: "git_add",
description:
"Stage files so the next git_commit includes them. Pass one path, or several separated by " +
"commas, or '.' for everything that changed. Staging the same file twice is harmless. " +
"Returns the full list of what is staged afterwards.",
parameters: {
paths: z
.string()
.default(".")
.describe(
"Path(s) to stage, relative to the workspace root, separated by commas. '.' stages every change.",
),
},
implementation: async ({ paths }, ctx) => {
const wanted = paths
.split(/[,\n]/)
.map((entry) => entry.trim())
.filter((entry) => entry !== "");
const chosen = wanted.length === 0 ? ["."] : wanted.slice(0, MAX_ADD_PATHS);
const specs: string[] = [];
for (const candidate of chosen) {
const { rel } = resolved(candidate);
const spec = quoteArg(rel);
if (spec === undefined) {
return `Error: "${candidate}" contains characters that cannot be passed to git safely. Ask the human to stage that one.`;
}
specs.push(spec);
}
const problem = await repoProblem();
if (problem !== undefined) return problem;
ctx.status(`Staging ${chosen.length} path(s)`);
// The backup folder is this plugin's safety net, never something to commit.
const exclude = `":(exclude)${BACKUP_DIR}"`;
const result = await git(`add -- ${specs.join(" ")} ${exclude}`);
if (!result.ok) {
return failure(result, "Check the path with git_status, then call git_add again.");
}
const listed = await git("diff --cached --name-only");
if (!listed.ok || listed.stdout.trim() === "") {
return `Staged ${chosen.join(", ")}. Nothing new appeared in the index -- those files may already be committed and unchanged.`;
}
const names = listed.stdout.split(/\r?\n/).filter((name) => name.trim() !== "");
const shown = names.slice(0, MAX_ENTRIES_PER_GROUP);
const hidden = names.length - shown.length;
return (
`${names.length} file(s) staged for the next commit:\n${shown.map((n) => ` ${n}`).join("\n")}` +
(hidden > 0 ? `\n ...and ${hidden} more` : "")
);
},
}),
);
tools.push(
tool({
name: "git_commit",
description:
"Commit everything currently staged, using the message you supply. Stage files with " +
"git_add first. This only records the commit locally -- it never pushes, never amends, " +
"and never rewrites history; if the human wants the work pushed to a remote, tell them to " +
"do it themselves. Calling it twice in a row is harmless: the second call finds nothing " +
"staged and does nothing.",
parameters: {
message: z
.string()
.describe(
"The commit message. First line: a short summary of what changed and why. Add detail on later lines if useful.",
),
},
implementation: async ({ message }, ctx) => {
const text = message.replace(/\r/g, "").trim();
if (text === "") {
return "Error: the commit message is empty. Write one line describing what changed, then call git_commit again.";
}
const problem = await repoProblem();
if (problem !== undefined) return problem;
const staged = await git("diff --cached --name-only");
if (!staged.ok) {
return failure(staged, "Check the repository state with git_status.");
}
if (staged.stdout.trim() === "") {
return "Nothing is staged, so there is nothing to commit. Use git_add to stage the files you changed, then call git_commit again.";
}
const body =
text.length > MAX_COMMIT_MESSAGE_CHARS
? `${text.slice(0, MAX_COMMIT_MESSAGE_CHARS)}\n\n[message truncated by the tool]`
: text;
// Passed through a file rather than -m so quotes and newlines in the
// message can never be reinterpreted by the shell.
const messageDir = join(ws.root, BACKUP_DIR);
const messageFile = join(messageDir, COMMIT_MSG_NAME);
try {
await mkdir(messageDir, { recursive: true });
await writeFile(messageFile, `${body}\n`, "utf-8");
} catch (caught) {
return `Error: could not write the temporary commit message file (${(caught as Error).message}). Check the workspace is writable.`;
}
ctx.status("Committing staged changes");
const result = await git(
`commit --cleanup=whitespace -F "${BACKUP_DIR}/${COMMIT_MSG_NAME}"`,
);
await unlink(messageFile).catch(() => undefined);
if (!result.ok) {
return failure(
result,
"If git asks for an identity, ask the human to set user.name and user.email; do not work around it.",
);
}
const output = result.stdout.trim() || result.stderr.trim() || "Commit created.";
return clamp(output, 1500, "commit output");
},
}),
);
tools.push(
tool({
name: "git_create_branch",
description:
"Create a new local branch and switch to it, so later commits land there instead of on " +
"the current branch. Use it before starting a change the human may want to throw away. " +
"It never deletes or pushes branches; if an existing branch needs to be switched to or " +
"removed, ask the human. Calling it twice with the same name is harmless.",
parameters: {
name: z
.string()
.describe("Branch name, e.g. 'fix-login-timeout'. Letters, digits, dot, dash, slash."),
},
implementation: async ({ name }, ctx) => {
const branch = name.trim();
if (
branch.length > 100 ||
!BRANCH_OK.test(branch) ||
branch.includes("..") ||
branch.endsWith("/") ||
branch.endsWith(".lock")
) {
return `Error: "${name}" is not a valid branch name. Use letters, digits, dots, dashes and slashes, e.g. 'fix-login-timeout'.`;
}
const problem = await repoProblem();
if (problem !== undefined) return problem;
const current = await git("rev-parse --abbrev-ref HEAD");
if (current.ok && current.stdout.trim() === branch) {
return `Already on branch "${branch}" -- nothing to do.`;
}
ctx.status(`Creating branch ${branch}`);
const result = await git(`checkout -b "${branch}"`);
if (!result.ok) {
if (/already exists/i.test(`${result.stderr} ${result.error}`)) {
return `Error: a branch named "${branch}" already exists. Pick a different name, or ask the human if they want you to work on the existing one.`;
}
return failure(result, "Check for uncommitted changes with git_status.");
}
return `Created branch "${branch}" and switched to it. New commits will land here instead of on the previous branch.`;
},
}),
);
tools.push(
tool({
name: "git_restore",
description:
"Undo changes to one file. With staged=false it throws away your uncommitted edits and " +
"puts the file back to its last committed state -- a backup is taken first so the edits " +
"can still be recovered. With staged=true it only unstages the file and leaves it on disk " +
"untouched. One file at a time; it never resets or cleans the whole repository.",
parameters: {
path: z.string().describe("The single file to restore, relative to the workspace root."),
staged: z
.boolean()
.default(false)
.describe(
"false = discard the edits on disk (backed up first). true = only unstage it, keeping the edits.",
),
},
implementation: async ({ path, staged }, ctx) => {
const { abs, rel } = resolved(path);
if (rel === "." || rel === "") {
return "Error: name one file to restore. Restoring the whole workspace at once is not allowed -- ask the human if that is really what is wanted.";
}
const spec = quoteArg(rel);
if (spec === undefined) {
return `Error: the path "${path}" contains characters that cannot be passed to git safely.`;
}
const problem = await repoProblem();
if (problem !== undefined) return problem;
if (staged) {
ctx.status(`Unstaging ${rel}`);
const result = await git(`restore --staged -- ${spec}`);
if (!result.ok) {
return failure(
result,
"If git reports 'restore' is not a command, its version is older than 2.23 -- tell the human.",
);
}
return `Unstaged ${rel}. The file on disk is unchanged; it is simply no longer queued for the next commit.`;
}
let backup: string | undefined;
try {
if (existsSync(abs)) {
const info = await stat(abs);
if (info.isDirectory()) {
return `Error: "${rel}" is a folder. Restore one file at a time so a single call cannot wipe out a whole tree.`;
}
backup = await snapshot(ws, abs);
}
} catch (caught) {
return `Error: could not back up ${rel} before restoring it (${(caught as Error).message}). Nothing was changed.`;
}
ctx.warn(`Discarding uncommitted changes to ${rel}.`);
ctx.status(`Restoring ${rel}`);
const result = await git(`restore -- ${spec}`);
if (!result.ok) {
return failure(
result,
"The file may be untracked, in which case git has no version to restore -- check git_status.",
);
}
const note =
backup === undefined
? ""
: ` The previous contents were backed up to ${ws.rel(backup)}.`;
return `Restored ${rel} to its last committed state.${note}`;
},
}),
);
return tools;
}