1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
2//
3// SPDX-License-Identifier: GPL-3.0-or-later
4
5import { Type } from "@sinclair/typebox";
6import type { AgentTool } from "@mariozechner/pi-agent-core";
7import simpleGit from "simple-git";
8import { ToolInputError } from "../../../util/errors.js";
9import { formatSize, truncateHead } from "../../../util/truncate.js";
10
11// Trust boundary: refs and paths are passed directly to simple-git, which is
12// scoped to the workspace. The user chose to clone this repo, so its contents
13// are trusted. See AGENTS.md § Workspace Sandboxing.
14
15const DiffSchema = Type.Object({
16 ref: Type.Optional(Type.String({ description: "Base ref (optional)" })),
17 ref2: Type.Optional(Type.String({ description: "Compare ref (optional)" })),
18 path: Type.Optional(Type.String({ description: "Limit diff to path" })),
19});
20
21export const createGitDiffTool = (workspacePath: string): AgentTool => ({
22 name: "git_diff",
23 label: "Git Diff",
24 description: "Show diff between refs, or between a ref and the working tree. Omit both refs for unstaged changes.",
25 parameters: DiffSchema as any,
26 execute: async (_toolCallId: string, params: any) => {
27 const git = simpleGit(workspacePath);
28 const args: string[] = [];
29
30 if (params.ref && !String(params.ref).trim()) {
31 throw new ToolInputError("ref must be a non-empty string");
32 }
33 if (params.ref2 && !String(params.ref2).trim()) {
34 throw new ToolInputError("ref2 must be a non-empty string");
35 }
36 if (params.path && !String(params.path).trim()) {
37 throw new ToolInputError("path must be a non-empty string");
38 }
39 if (params.ref) args.push(params.ref);
40 if (params.ref2) args.push(params.ref2);
41 if (params.path) args.push("--", params.path);
42
43 const raw = await git.diff(args);
44 const truncation = truncateHead(raw);
45
46 let text = truncation.content;
47 if (truncation.truncated) {
48 text += `\n\n[truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)})]`;
49 }
50
51 return {
52 content: [{ type: "text", text }],
53 details: { path: params.path ?? null, ...(truncation.truncated ? { truncation } : {}) },
54 };
55 },
56});