Detailed changes
@@ -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 } : {}) },
};
},
});
@@ -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 } : {}) },
};
},
});
@@ -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");
@@ -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 } : {}) },
};
},
});
@@ -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);
+ });
+});