1import { Type } from "@sinclair/typebox";
2import type { AgentTool } from "@mariozechner/pi-agent-core";
3import simpleGit from "simple-git";
4import { ToolInputError } from "../../../util/errors.js";
5
6// Trust boundary: refs and paths are passed directly to simple-git, which is
7// scoped to the workspace. The user chose to clone this repo, so its contents
8// are trusted. See AGENTS.md § Workspace Sandboxing.
9
10const DEFAULT_LOG_LIMIT = 20;
11
12const LogSchema = Type.Object({
13 path: Type.Optional(Type.String({ description: "Filter to commits touching this path" })),
14 author: Type.Optional(Type.String({ description: "Filter by author name/email" })),
15 since: Type.Optional(Type.String({ description: "Commits after this date (e.g., 2024-01-01)" })),
16 until: Type.Optional(Type.String({ description: "Commits before this date" })),
17 n: Type.Optional(Type.Number({ description: "Maximum number of commits (default: 20)" })),
18 oneline: Type.Optional(Type.Boolean({ description: "Compact one-line format (default: false)" })),
19});
20
21export const createGitLogTool = (workspacePath: string): AgentTool => ({
22 name: "git_log",
23 label: "Git Log",
24 description: "View commit history. Supports filtering by path, author, date range, and count.",
25 parameters: LogSchema as any,
26 execute: async (_toolCallId: string, params: any) => {
27 const git = simpleGit(workspacePath);
28 const options: string[] = [];
29
30 const limit = params.n !== undefined ? params.n : DEFAULT_LOG_LIMIT;
31 if (typeof limit !== "number" || Number.isNaN(limit) || limit <= 0) {
32 throw new ToolInputError("n must be a positive number");
33 }
34 options.push("-n", String(Math.floor(limit)));
35 if (params.oneline) options.push("--oneline");
36 if (params.author !== undefined) {
37 if (!String(params.author).trim()) {
38 throw new ToolInputError("author must be a non-empty string");
39 }
40 options.push(`--author=${params.author}`);
41 }
42 if (params.since !== undefined) {
43 if (!String(params.since).trim()) {
44 throw new ToolInputError("since must be a non-empty string");
45 }
46 options.push(`--since=${params.since}`);
47 }
48 if (params.until !== undefined) {
49 if (!String(params.until).trim()) {
50 throw new ToolInputError("until must be a non-empty string");
51 }
52 options.push(`--until=${params.until}`);
53 }
54
55 const result = await git.log(options.concat(params.path ? ["--", params.path] : []));
56
57 const text = result.all
58 .map((entry) =>
59 params.oneline
60 ? `${entry.hash} ${entry.message}`
61 : `${entry.hash} ${entry.date} ${entry.author_name} ${entry.message}`,
62 )
63 .join("\n");
64
65 return {
66 content: [{ type: "text", text }],
67 details: { count: result.all.length },
68 };
69 },
70});