git-tools.test.ts

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