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