git-tools.test.ts

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