import { describe, expect, test } from "bun:test" import { $ } from "bun" import * as fs from "fs/promises" import path from "path" import { tmpdir } from "../../fixture/fixture" import { getGitContext, isLockFile, parseNameStatus, parsePorcelain, mapStatus, isUntracked, MAX_DIFF_LENGTH, } from "../../../src/kilocode/commit-message/git-context" // ── Helper: stage files in a temp git repo ────────────────────────── async function stage(dir: string, files: Record) { for (const [file, text] of Object.entries(files)) { const target = path.join(dir, file) await fs.mkdir(path.dirname(target), { recursive: true }) await Bun.write(target, text) await $`git add ${file}`.cwd(dir).quiet() } } // ── Pure-function unit tests (no git needed) ──────────────────────── describe("commit-message.git-context", () => { describe("parseNameStatus", () => { test("parses added file", () => { const result = parseNameStatus("A\tsrc/new-file.ts") expect(result).toEqual([{ status: "A", path: "src/new-file.ts" }]) }) test("parses modified file", () => { const result = parseNameStatus("M\tsrc/existing.ts") expect(result).toEqual([{ status: "M", path: "src/existing.ts" }]) }) test("parses deleted file", () => { const result = parseNameStatus("D\tsrc/removed.ts") expect(result).toEqual([{ status: "D", path: "src/removed.ts" }]) }) test("parses renamed file using new path", () => { const result = parseNameStatus("R100\told-name.ts\tnew-name.ts") expect(result).toEqual([{ status: "R100", path: "new-name.ts" }]) }) test("parses multiple entries", () => { const result = parseNameStatus("M\tsrc/a.ts\nA\tsrc/b.ts") expect(result).toHaveLength(2) expect(result[0]!.path).toBe("src/a.ts") expect(result[1]!.path).toBe("src/b.ts") }) test("returns empty array for empty input", () => { expect(parseNameStatus("")).toEqual([]) }) }) describe("parsePorcelain", () => { test("parses untracked file", () => { const result = parsePorcelain("?? src/brand-new.ts") expect(result).toEqual([{ status: "??", path: "src/brand-new.ts" }]) }) test("parses modified file", () => { const result = parsePorcelain(" M src/changed.ts") expect(result).toEqual([{ status: "M", path: "src/changed.ts" }]) }) test("returns empty array for empty input", () => { expect(parsePorcelain("")).toEqual([]) }) test("filters blank lines", () => { const result = parsePorcelain("?? a.ts\n\n?? b.ts") expect(result).toHaveLength(2) }) }) describe("mapStatus", () => { test("maps R-prefix to renamed", () => { expect(mapStatus("R100")).toBe("renamed") expect(mapStatus("R050")).toBe("renamed") }) test("maps A to added", () => { expect(mapStatus("A")).toBe("added") }) test("maps ?? to added", () => { expect(mapStatus("??")).toBe("added") }) test("maps ? to added", () => { expect(mapStatus("?")).toBe("added") }) test("maps D to deleted", () => { expect(mapStatus("D")).toBe("deleted") }) test("maps M to modified", () => { expect(mapStatus("M")).toBe("modified") }) test("maps unknown codes to modified", () => { expect(mapStatus("X")).toBe("modified") }) }) describe("isUntracked", () => { test("returns true for ??", () => { expect(isUntracked("??")).toBe(true) }) test("returns true for ?", () => { expect(isUntracked("?")).toBe(true) }) test("returns false for other codes", () => { expect(isUntracked("M")).toBe(false) expect(isUntracked("A")).toBe(false) }) }) describe("isLockFile", () => { test("detects package-lock.json", () => { expect(isLockFile("package-lock.json")).toBe(true) }) test("detects yarn.lock", () => { expect(isLockFile("yarn.lock")).toBe(true) }) test("detects lock files in subdirectories", () => { expect(isLockFile("packages/api/package-lock.json")).toBe(true) }) test("detects various lock files", () => { expect(isLockFile("bun.lockb")).toBe(true) expect(isLockFile("go.sum")).toBe(true) expect(isLockFile("Cargo.lock")).toBe(true) expect(isLockFile("poetry.lock")).toBe(true) expect(isLockFile("pnpm-lock.yaml")).toBe(true) }) test("does not flag normal files", () => { expect(isLockFile("src/index.ts")).toBe(false) expect(isLockFile("README.md")).toBe(false) }) }) // ── Integration tests using real git repos ──────────────────────── describe("lock file filtering", () => { test("filters out lock files from staged changes", async () => { await using tmp = await tmpdir({ git: true }) await stage(tmp.path, { "src/index.ts": "console.log('hello')\n", "package-lock.json": '{"lockfileVersion": 3}\n', }) const ctx = await getGitContext(tmp.path) expect(ctx.files).toHaveLength(1) expect(ctx.files[0]!.path).toBe("src/index.ts") }) test("filters lock files in subdirectories", async () => { await using tmp = await tmpdir({ git: true }) await stage(tmp.path, { "packages/api/package-lock.json": "lock\n", "packages/api/src/index.ts": "export {}\n", }) const ctx = await getGitContext(tmp.path) expect(ctx.files).toHaveLength(1) expect(ctx.files[0]!.path).toBe("packages/api/src/index.ts") }) }) describe("status parsing", () => { test("parses staged added files", async () => { await using tmp = await tmpdir({ git: true }) await stage(tmp.path, { "src/new-file.ts": "new content\n" }) const ctx = await getGitContext(tmp.path) expect(ctx.files).toHaveLength(1) expect(ctx.files[0]!.status).toBe("added") expect(ctx.files[0]!.path).toBe("src/new-file.ts") }) test("parses staged modified files", async () => { await using tmp = await tmpdir({ git: true }) // Create, commit, then modify await stage(tmp.path, { "src/existing.ts": "original\n" }) await $`git commit -m "add file"`.cwd(tmp.path).quiet() await Bun.write(path.join(tmp.path, "src/existing.ts"), "changed\n") await $`git add src/existing.ts`.cwd(tmp.path).quiet() const ctx = await getGitContext(tmp.path) expect(ctx.files).toHaveLength(1) expect(ctx.files[0]!.status).toBe("modified") }) test("parses staged deleted files", async () => { await using tmp = await tmpdir({ git: true }) await stage(tmp.path, { "src/removed.ts": "to delete\n" }) await $`git commit -m "add file"`.cwd(tmp.path).quiet() await $`git rm src/removed.ts`.cwd(tmp.path).quiet() const ctx = await getGitContext(tmp.path) expect(ctx.files).toHaveLength(1) expect(ctx.files[0]!.status).toBe("deleted") }) }) describe("diff truncation", () => { test("truncates diffs exceeding max length", async () => { await using tmp = await tmpdir({ git: true }) const long = "x".repeat(MAX_DIFF_LENGTH + 2000) await stage(tmp.path, { "src/big.ts": "original\n" }) await $`git commit -m "add"`.cwd(tmp.path).quiet() await Bun.write(path.join(tmp.path, "src/big.ts"), long + "\n") await $`git add src/big.ts`.cwd(tmp.path).quiet() const ctx = await getGitContext(tmp.path) expect(ctx.files).toHaveLength(1) expect(ctx.files[0]!.diff).toContain("... [truncated]") }) }) describe("selected files filtering", () => { test("only includes files in selectedFiles set", async () => { await using tmp = await tmpdir({ git: true }) await stage(tmp.path, { "src/a.ts": "a\n", "src/b.ts": "b\n", "src/c.ts": "c\n", }) const ctx = await getGitContext(tmp.path, ["src/a.ts", "src/c.ts"]) expect(ctx.files).toHaveLength(2) const paths = ctx.files.map((f) => f.path) expect(paths).toContain("src/a.ts") expect(paths).toContain("src/c.ts") expect(paths).not.toContain("src/b.ts") }) test("includes all files when selectedFiles is undefined", async () => { await using tmp = await tmpdir({ git: true }) await stage(tmp.path, { "src/a.ts": "a\n", "src/b.ts": "b\n", }) const ctx = await getGitContext(tmp.path) expect(ctx.files).toHaveLength(2) }) test("returns empty files when selectedFiles has no matches", async () => { await using tmp = await tmpdir({ git: true }) await stage(tmp.path, { "src/a.ts": "a\n" }) const ctx = await getGitContext(tmp.path, ["src/nonexistent.ts"]) expect(ctx.files).toHaveLength(0) }) }) describe("branch and recent commits", () => { test("returns current branch name", async () => { await using tmp = await tmpdir({ git: true }) await $`git checkout -b feature/my-branch`.cwd(tmp.path).quiet() const ctx = await getGitContext(tmp.path) expect(ctx.branch).toBe("feature/my-branch") }) test("returns recent commits as array", async () => { await using tmp = await tmpdir({ git: true }) // tmpdir already creates a root commit await stage(tmp.path, { "a.ts": "a\n" }) await $`git commit -m "second commit"`.cwd(tmp.path).quiet() const ctx = await getGitContext(tmp.path) expect(ctx.recentCommits.length).toBeGreaterThanOrEqual(1) expect(ctx.recentCommits.some((c) => c.includes("second commit"))).toBe(true) }) }) })