fix: truncate git tool output and enforce default log limit (#8, #9)

Amolith and Shelley created

- git_show, git_diff, git_blame: apply truncateHead() consistent with
  filesystem tools (DEFAULT_MAX_LINES=2000, DEFAULT_MAX_BYTES=50KB),
  appending a [truncated] notice when output is clipped.
- git_log: apply default limit of 20 when n is omitted, matching the
  schema description.
- Add test/git-tools.test.ts covering both truncation and default-limit
  behavior.

Co-authored-by: Shelley <shelley@exe.dev>

Change summary

src/agent/tools/git/blame.ts |  11 ++
src/agent/tools/git/diff.ts  |  12 ++
src/agent/tools/git/log.ts   |  11 +-
src/agent/tools/git/show.ts  |  11 ++
test/git-tools.test.ts       | 131 ++++++++++++++++++++++++++++++++++++++
5 files changed, 165 insertions(+), 11 deletions(-)

Detailed changes

src/agent/tools/git/blame.ts 🔗

@@ -2,6 +2,7 @@ import { Type } from "@sinclair/typebox";
 import type { AgentTool } from "@mariozechner/pi-ai";
 import simpleGit from "simple-git";
 import { ToolInputError } from "../../../util/errors.js";
+import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, truncateHead } from "../../../util/truncate.js";
 
 const BlameSchema = Type.Object({
   path: Type.String({ description: "File path relative to repo root" }),
@@ -17,11 +18,17 @@ export const createGitBlameTool = (workspacePath: string): AgentTool => ({
       throw new ToolInputError("path must be a non-empty string");
     }
     const git = simpleGit(workspacePath);
-    const text = await git.raw(["blame", "--", params.path]);
+    const raw = await git.raw(["blame", "--", params.path]);
+    const truncation = truncateHead(raw);
+
+    let text = truncation.content;
+    if (truncation.truncated) {
+      text += `\n\n[truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)})]`;
+    }
 
     return {
       content: [{ type: "text", text }],
-      details: { path: params.path },
+      details: { path: params.path, ...(truncation.truncated ? { truncation } : {}) },
     };
   },
 });

src/agent/tools/git/diff.ts 🔗

@@ -2,6 +2,7 @@ import { Type } from "@sinclair/typebox";
 import type { AgentTool } from "@mariozechner/pi-ai";
 import simpleGit from "simple-git";
 import { ToolInputError } from "../../../util/errors.js";
+import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, truncateHead } from "../../../util/truncate.js";
 
 const DiffSchema = Type.Object({
   ref: Type.Optional(Type.String({ description: "Base ref (optional)" })),
@@ -31,10 +32,17 @@ export const createGitDiffTool = (workspacePath: string): AgentTool => ({
     if (params.ref2) args.push(params.ref2);
     if (params.path) args.push("--", params.path);
 
-    const text = await git.diff(args);
+    const raw = await git.diff(args);
+    const truncation = truncateHead(raw);
+
+    let text = truncation.content;
+    if (truncation.truncated) {
+      text += `\n\n[truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)})]`;
+    }
+
     return {
       content: [{ type: "text", text }],
-      details: { path: params.path ?? null },
+      details: { path: params.path ?? null, ...(truncation.truncated ? { truncation } : {}) },
     };
   },
 });

src/agent/tools/git/log.ts 🔗

@@ -3,6 +3,8 @@ import type { AgentTool } from "@mariozechner/pi-ai";
 import simpleGit from "simple-git";
 import { ToolInputError } from "../../../util/errors.js";
 
+const DEFAULT_LOG_LIMIT = 20;
+
 const LogSchema = Type.Object({
   path: Type.Optional(Type.String({ description: "Filter to commits touching this path" })),
   author: Type.Optional(Type.String({ description: "Filter by author name/email" })),
@@ -21,12 +23,11 @@ export const createGitLogTool = (workspacePath: string): AgentTool => ({
     const git = simpleGit(workspacePath);
     const options: string[] = [];
 
-    if (params.n !== undefined) {
-      if (typeof params.n !== "number" || Number.isNaN(params.n) || params.n <= 0) {
-        throw new ToolInputError("n must be a positive number");
-      }
-      options.push("-n", String(Math.floor(params.n)));
+    const limit = params.n !== undefined ? params.n : DEFAULT_LOG_LIMIT;
+    if (typeof limit !== "number" || Number.isNaN(limit) || limit <= 0) {
+      throw new ToolInputError("n must be a positive number");
     }
+    options.push("-n", String(Math.floor(limit)));
     if (params.oneline) options.push("--oneline");
     if (params.author && !String(params.author).trim()) {
       throw new ToolInputError("author must be a non-empty string");

src/agent/tools/git/show.ts 🔗

@@ -2,6 +2,7 @@ import { Type } from "@sinclair/typebox";
 import type { AgentTool } from "@mariozechner/pi-ai";
 import simpleGit from "simple-git";
 import { ToolInputError } from "../../../util/errors.js";
+import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, truncateHead } from "../../../util/truncate.js";
 
 const ShowSchema = Type.Object({
   ref: Type.String({ description: "Commit hash or ref" }),
@@ -17,11 +18,17 @@ export const createGitShowTool = (workspacePath: string): AgentTool => ({
       throw new ToolInputError("ref must be a non-empty string");
     }
     const git = simpleGit(workspacePath);
-    const text = await git.show([params.ref]);
+    const raw = await git.show([params.ref]);
+    const truncation = truncateHead(raw);
+
+    let text = truncation.content;
+    if (truncation.truncated) {
+      text += `\n\n[truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)})]`;
+    }
 
     return {
       content: [{ type: "text", text }],
-      details: { ref: params.ref },
+      details: { ref: params.ref, ...(truncation.truncated ? { truncation } : {}) },
     };
   },
 });

test/git-tools.test.ts 🔗

@@ -0,0 +1,131 @@
+import { describe, test, expect, beforeAll, afterAll } from "bun:test";
+import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import simpleGit from "simple-git";
+import { createGitShowTool } from "../src/agent/tools/git/show.js";
+import { createGitDiffTool } from "../src/agent/tools/git/diff.js";
+import { createGitBlameTool } from "../src/agent/tools/git/blame.js";
+import { createGitLogTool } from "../src/agent/tools/git/log.js";
+import { DEFAULT_MAX_LINES, DEFAULT_MAX_BYTES } from "../src/util/truncate.js";
+
+function textOf(result: any): string {
+  return result.content[0].text;
+}
+
+let workDir: string;
+let git: ReturnType<typeof simpleGit>;
+
+beforeAll(async () => {
+  workDir = mkdtempSync(join(tmpdir(), "rumilo-git-test-"));
+  git = simpleGit(workDir);
+  await git.init();
+  await git.addConfig("user.name", "Test");
+  await git.addConfig("user.email", "test@test.com");
+
+  // Create a large file for truncation tests
+  const largeLine = "x".repeat(100);
+  const largeContent = Array.from({ length: 3000 }, (_, i) => `${i}: ${largeLine}`).join("\n");
+  writeFileSync(join(workDir, "large.txt"), largeContent);
+  await git.add("large.txt");
+  await git.commit("add large file");
+
+  // Create many commits for log default-limit test
+  for (let i = 0; i < 30; i++) {
+    writeFileSync(join(workDir, "counter.txt"), String(i));
+    await git.add("counter.txt");
+    await git.commit(`commit number ${i}`);
+  }
+});
+
+afterAll(() => {
+  try {
+    rmSync(workDir, { recursive: true, force: true });
+  } catch {}
+});
+
+describe("git_show truncation (issue #8)", () => {
+  test("truncates large output and appends notice", async () => {
+    const tool = createGitShowTool(workDir);
+    // The first commit has the large file diff, which should exceed truncation limits
+    const logs = await git.log();
+    const firstCommitHash = logs.all[logs.all.length - 1]!.hash;
+    const result = await tool.execute("call-1", { ref: firstCommitHash });
+    const text = textOf(result);
+
+    // Output should be bounded - not return all 3000+ lines raw
+    const lines = text.split("\n");
+    expect(lines.length).toBeLessThanOrEqual(DEFAULT_MAX_LINES + 5); // small margin for notice
+    expect(Buffer.byteLength(text, "utf-8")).toBeLessThanOrEqual(DEFAULT_MAX_BYTES + 500); // margin for notice
+    expect(text).toContain("[truncated");
+  });
+
+  test("small output is not truncated", async () => {
+    const tool = createGitShowTool(workDir);
+    const result = await tool.execute("call-2", { ref: "HEAD" });
+    const text = textOf(result);
+    // HEAD commit is small (counter.txt change), should NOT be truncated
+    expect(text).not.toContain("[truncated");
+  });
+});
+
+describe("git_diff truncation (issue #8)", () => {
+  test("truncates large diff output", async () => {
+    const tool = createGitDiffTool(workDir);
+    const logs = await git.log();
+    const firstCommitHash = logs.all[logs.all.length - 1]!.hash;
+    const secondCommitHash = logs.all[logs.all.length - 2]!.hash;
+    // Diff between first commit (large file add) and second commit
+    const result = await tool.execute("call-3", { ref: firstCommitHash, ref2: secondCommitHash });
+    const text = textOf(result);
+    // The diff won't be huge (only counter.txt changes), so let's create a proper large diff scenario
+    // Instead, diff from the first commit to HEAD which has many changes but also large.txt unchanged
+    // Better: modify large.txt to create a big diff
+    // Actually, let's just verify the mechanism works by checking the first commit via show already.
+    // For diff specifically, create a modified version of large.txt
+    const largeLine2 = "y".repeat(100);
+    const largeContent2 = Array.from({ length: 3000 }, (_, i) => `${i}: ${largeLine2}`).join("\n");
+    writeFileSync(join(workDir, "large.txt"), largeContent2);
+    const result2 = await tool.execute("call-3b", { ref: "HEAD" });
+    const text2 = textOf(result2);
+    const lines2 = text2.split("\n");
+    expect(lines2.length).toBeLessThanOrEqual(DEFAULT_MAX_LINES + 5);
+    expect(text2).toContain("[truncated");
+    // Restore the file
+    await git.checkout(["--", "large.txt"]);
+  });
+});
+
+describe("git_blame truncation (issue #8)", () => {
+  test("truncates large blame output", async () => {
+    const tool = createGitBlameTool(workDir);
+    const result = await tool.execute("call-4", { path: "large.txt" });
+    const text = textOf(result);
+    const lines = text.split("\n");
+    expect(lines.length).toBeLessThanOrEqual(DEFAULT_MAX_LINES + 5);
+    expect(Buffer.byteLength(text, "utf-8")).toBeLessThanOrEqual(DEFAULT_MAX_BYTES + 500);
+    expect(text).toContain("[truncated");
+  });
+});
+
+describe("git_log default limit (issue #9)", () => {
+  test("returns at most 20 commits when n is not specified", async () => {
+    const tool = createGitLogTool(workDir);
+    const result: any = await tool.execute("call-5", {});
+    // We have 31 commits total (1 large file + 30 counter), default should limit to 20
+    expect(result.details.count).toBeLessThanOrEqual(20);
+    expect(result.details.count).toBe(20);
+  });
+
+  test("explicit n overrides default limit", async () => {
+    const tool = createGitLogTool(workDir);
+    const result: any = await tool.execute("call-6", { n: 5 });
+    expect(result.details.count).toBe(5);
+  });
+
+  test("explicit n larger than 20 works", async () => {
+    const tool = createGitLogTool(workDir);
+    const result: any = await tool.execute("call-7", { n: 25 });
+    expect(result.details.count).toBe(25);
+  });
+});