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  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});