log.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";
 9
10// Trust boundary: refs and paths are passed directly to simple-git, which is
11// scoped to the workspace. The user chose to clone this repo, so its contents
12// are trusted. See AGENTS.md § Workspace Sandboxing.
13
14const DEFAULT_LOG_LIMIT = 20;
15
16const LogSchema = Type.Object({
17  path: Type.Optional(
18    Type.String({ description: "Filter to commits touching this path" }),
19  ),
20  author: Type.Optional(
21    Type.String({ description: "Filter by author name/email" }),
22  ),
23  since: Type.Optional(
24    Type.String({ description: "Commits after this date (e.g., 2024-01-01)" }),
25  ),
26  until: Type.Optional(
27    Type.String({ description: "Commits before this date" }),
28  ),
29  n: Type.Optional(
30    Type.Number({ description: "Maximum number of commits (default: 20)" }),
31  ),
32  oneline: Type.Optional(
33    Type.Boolean({ description: "Compact one-line format (default: false)" }),
34  ),
35});
36
37export const createGitLogTool = (workspacePath: string): AgentTool => ({
38  name: "git_log",
39  label: "Git Log",
40  description:
41    "View commit history. Supports filtering by path, author, date range, and count.",
42  parameters: LogSchema as any,
43  execute: async (_toolCallId: string, params: any) => {
44    const git = simpleGit(workspacePath);
45    const options: string[] = [];
46
47    const limit = params.n !== undefined ? params.n : DEFAULT_LOG_LIMIT;
48    if (typeof limit !== "number" || Number.isNaN(limit) || limit <= 0) {
49      throw new ToolInputError("n must be a positive number");
50    }
51    options.push("-n", String(Math.floor(limit)));
52    if (params.oneline) options.push("--oneline");
53    if (
54      String(params.path ?? "")
55        .trim()
56        .startsWith("-")
57    ) {
58      throw new ToolInputError("path must not start with '-'");
59    }
60    if (params.author !== undefined) {
61      if (!String(params.author).trim()) {
62        throw new ToolInputError("author must be a non-empty string");
63      }
64      if (String(params.author).trim().startsWith("-")) {
65        throw new ToolInputError("author must not start with '-'");
66      }
67      options.push(`--author=${params.author}`);
68    }
69    if (params.since !== undefined) {
70      if (!String(params.since).trim()) {
71        throw new ToolInputError("since must be a non-empty string");
72      }
73      options.push(`--since=${params.since}`);
74    }
75    if (params.until !== undefined) {
76      if (!String(params.until).trim()) {
77        throw new ToolInputError("until must be a non-empty string");
78      }
79      options.push(`--until=${params.until}`);
80    }
81
82    const result = await git.log(
83      options.concat(params.path ? ["--", params.path] : []),
84    );
85
86    const text = result.all
87      .map((entry) =>
88        params.oneline
89          ? `${entry.hash} ${entry.message}`
90          : `${entry.hash} ${entry.date} ${entry.author_name} ${entry.message}`,
91      )
92      .join("\n");
93
94    return {
95      content: [{ type: "text", text }],
96      details: { count: result.all.length },
97    };
98  },
99});