1import { describe, test, expect, beforeAll, afterAll } from "bun:test";
2import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
3import { tmpdir } from "node:os";
4import { join } from "node:path";
5import simpleGit from "simple-git";
6import { createGitShowTool } from "../src/agent/tools/git/show.js";
7import { createGitDiffTool } from "../src/agent/tools/git/diff.js";
8import { createGitBlameTool } from "../src/agent/tools/git/blame.js";
9import { createGitLogTool } from "../src/agent/tools/git/log.js";
10import { DEFAULT_MAX_LINES, DEFAULT_MAX_BYTES } from "../src/util/truncate.js";
11
12function textOf(result: any): string {
13 return result.content[0].text;
14}
15
16let workDir: string;
17let git: ReturnType<typeof simpleGit>;
18
19beforeAll(async () => {
20 workDir = mkdtempSync(join(tmpdir(), "rumilo-git-test-"));
21 git = simpleGit(workDir);
22 await git.init();
23 await git.addConfig("user.name", "Test");
24 await git.addConfig("user.email", "test@test.com");
25 await git.addConfig("commit.gpgsign", "false");
26
27 // Create a large file for truncation tests
28 const largeLine = "x".repeat(100);
29 const largeContent = Array.from({ length: 3000 }, (_, i) => `${i}: ${largeLine}`).join("\n");
30 writeFileSync(join(workDir, "large.txt"), largeContent);
31 await git.add("large.txt");
32 await git.commit("add large file");
33
34 // Create many commits for log default-limit test
35 for (let i = 0; i < 30; i++) {
36 writeFileSync(join(workDir, "counter.txt"), String(i));
37 await git.add("counter.txt");
38 await git.commit(`commit number ${i}`);
39 }
40});
41
42afterAll(() => {
43 try {
44 rmSync(workDir, { recursive: true, force: true });
45 } catch {}
46});
47
48describe("git_show truncation (issue #8)", () => {
49 test("truncates large output and appends notice", async () => {
50 const tool = createGitShowTool(workDir);
51 // The first commit has the large file diff, which should exceed truncation limits
52 const logs = await git.log();
53 const firstCommitHash = logs.all[logs.all.length - 1]!.hash;
54 const result = await tool.execute("call-1", { ref: firstCommitHash });
55 const text = textOf(result);
56
57 // Output should be bounded - not return all 3000+ lines raw
58 const lines = text.split("\n");
59 expect(lines.length).toBeLessThanOrEqual(DEFAULT_MAX_LINES + 5); // small margin for notice
60 expect(Buffer.byteLength(text, "utf-8")).toBeLessThanOrEqual(DEFAULT_MAX_BYTES + 500); // margin for notice
61 expect(text).toContain("[truncated");
62 });
63
64 test("small output is not truncated", async () => {
65 const tool = createGitShowTool(workDir);
66 const result = await tool.execute("call-2", { ref: "HEAD" });
67 const text = textOf(result);
68 // HEAD commit is small (counter.txt change), should NOT be truncated
69 expect(text).not.toContain("[truncated");
70 });
71});
72
73describe("git_diff truncation (issue #8)", () => {
74 test("truncates large diff output", async () => {
75 const tool = createGitDiffTool(workDir);
76 const logs = await git.log();
77 const firstCommitHash = logs.all[logs.all.length - 1]!.hash;
78 const secondCommitHash = logs.all[logs.all.length - 2]!.hash;
79 // Diff between first commit (large file add) and second commit
80 const result = await tool.execute("call-3", { ref: firstCommitHash, ref2: secondCommitHash });
81 const text = textOf(result);
82 // The diff won't be huge (only counter.txt changes), so let's create a proper large diff scenario
83 // Instead, diff from the first commit to HEAD which has many changes but also large.txt unchanged
84 // Better: modify large.txt to create a big diff
85 // Actually, let's just verify the mechanism works by checking the first commit via show already.
86 // For diff specifically, create a modified version of large.txt
87 const largeLine2 = "y".repeat(100);
88 const largeContent2 = Array.from({ length: 3000 }, (_, i) => `${i}: ${largeLine2}`).join("\n");
89 writeFileSync(join(workDir, "large.txt"), largeContent2);
90 const result2 = await tool.execute("call-3b", { ref: "HEAD" });
91 const text2 = textOf(result2);
92 const lines2 = text2.split("\n");
93 expect(lines2.length).toBeLessThanOrEqual(DEFAULT_MAX_LINES + 5);
94 expect(text2).toContain("[truncated");
95 // Restore the file
96 await git.checkout(["--", "large.txt"]);
97 });
98});
99
100describe("git_blame truncation (issue #8)", () => {
101 test("truncates large blame output", async () => {
102 const tool = createGitBlameTool(workDir);
103 const result = await tool.execute("call-4", { path: "large.txt" });
104 const text = textOf(result);
105 const lines = text.split("\n");
106 expect(lines.length).toBeLessThanOrEqual(DEFAULT_MAX_LINES + 5);
107 expect(Buffer.byteLength(text, "utf-8")).toBeLessThanOrEqual(DEFAULT_MAX_BYTES + 500);
108 expect(text).toContain("[truncated");
109 });
110});
111
112describe("git_log default limit (issue #9)", () => {
113 test("returns at most 20 commits when n is not specified", async () => {
114 const tool = createGitLogTool(workDir);
115 const result: any = await tool.execute("call-5", {});
116 // We have 31 commits total (1 large file + 30 counter), default should limit to 20
117 expect(result.details.count).toBeLessThanOrEqual(20);
118 expect(result.details.count).toBe(20);
119 });
120
121 test("explicit n overrides default limit", async () => {
122 const tool = createGitLogTool(workDir);
123 const result: any = await tool.execute("call-6", { n: 5 });
124 expect(result.details.count).toBe(5);
125 });
126
127 test("explicit n larger than 20 works", async () => {
128 const tool = createGitLogTool(workDir);
129 const result: any = await tool.execute("call-7", { n: 25 });
130 expect(result.details.count).toBe(25);
131 });
132});