diff.ts

 1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
 2//
 3// SPDX-License-Identifier: GPL-3.0-or-later
 4
 5import type { AgentTool } from "@mariozechner/pi-agent-core";
 6import { Type } from "@sinclair/typebox";
 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 working tree.",
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 (
34      String(params.ref ?? "")
35        .trim()
36        .startsWith("-")
37    ) {
38      throw new ToolInputError("ref must not start with '-'");
39    }
40    if (params.ref2 && !String(params.ref2).trim()) {
41      throw new ToolInputError("ref2 must be a non-empty string");
42    }
43    if (
44      String(params.ref2 ?? "")
45        .trim()
46        .startsWith("-")
47    ) {
48      throw new ToolInputError("ref2 must not start with '-'");
49    }
50    if (params.path && !String(params.path).trim()) {
51      throw new ToolInputError("path must be a non-empty string");
52    }
53    if (
54      String(params.path ?? "")
55        .trim()
56        .startsWith("-")
57    ) {
58      throw new ToolInputError("path must not start with '-'");
59    }
60    if (params.ref) args.push(params.ref);
61    if (params.ref2) args.push(params.ref2);
62    if (params.path) args.push("--", params.path);
63
64    const raw = await git.diff(args);
65    const truncation = truncateHead(raw);
66
67    let text = truncation.content;
68    if (truncation.truncated) {
69      text += `\n\n[truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)})]`;
70    }
71
72    return {
73      content: [{ type: "text", text }],
74      details: {
75        path: params.path ?? null,
76        ...(truncation.truncated ? { truncation } : {}),
77      },
78    };
79  },
80});