diff --git a/packages/cli/test/snapshot.test.ts b/packages/cli/test/snapshot.test.ts index 9f274d8..625457f 100644 --- a/packages/cli/test/snapshot.test.ts +++ b/packages/cli/test/snapshot.test.ts @@ -77,13 +77,17 @@ describe("codedecay snapshot CLI contract", () => { expect(comparison.stdout).toBe(""); }); - it("returns a clean error for git repositories with no commits", async () => { + it("emits a clean snapshot for git repositories with no commits", async () => { const repo = createTempDir(); git(repo, ["init", "-b", "main"]); + writeFile(repo, "src/app.ts", "export const value = 1;\n"); const result = await run(["snapshot", "--format", "json"], repo); - expect(result.exitCode).toBe(2); - expect(result.stderr).toContain("Git command failed"); - expect(result.stdout).toBe(""); + const snapshot = JSON.parse(result.stdout); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + expect(snapshot.tool).toBe("CodeDecay"); + expect(snapshot.summary.changedFiles).toBe(1); }); }); diff --git a/packages/git/src/changed-files.ts b/packages/git/src/changed-files.ts index b6bcac4..4931355 100644 --- a/packages/git/src/changed-files.ts +++ b/packages/git/src/changed-files.ts @@ -1,10 +1,17 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; import type { FileChange } from "@submuxhq/codedecay-core"; import { runGit } from "./command"; import { parseAddedLines, parseNameStatus, parseNumStat } from "./parsers"; +import { getRepoRoot } from "./repository"; import type { GitDiffOptions } from "./types"; import { getUntrackedFiles } from "./untracked"; export function getGitChangedFiles(options: GitDiffOptions): FileChange[] { + if (!options.base && !options.head && !hasHeadCommit(options.cwd)) { + return getNoCommitChanges(options.cwd); + } + const rangeArgs = getDiffRangeArgs(options); const nameStatusOutput = runGit(options.cwd, [ "diff", @@ -64,3 +71,56 @@ function getDiffRangeArgs(options: GitDiffOptions): string[] { return ["HEAD"]; } + +function hasHeadCommit(cwd: string): boolean { + try { + runGit(cwd, ["rev-parse", "--verify", "--quiet", "HEAD"]); + return true; + } catch { + return false; + } +} + +function getNoCommitChanges(cwd: string): FileChange[] { + const output = runGit(cwd, ["ls-files", "--cached", "--others", "--exclude-standard", "--full-name"]); + if (!output.trim()) { + return []; + } + + const repoRoot = getRepoRoot(cwd); + const seen = new Set(); + return output + .split(/\r?\n/) + .filter(Boolean) + .filter((path) => { + if (seen.has(path)) { + return false; + } + seen.add(path); + return true; + }) + .map((path) => createAddedChange(repoRoot, path)); +} + +function createAddedChange(repoRoot: string, path: string): FileChange { + const lines = readTextFile(join(repoRoot, path)) + .split(/\r?\n/) + .map((line, index) => ({ line: index + 1, content: line })) + .filter((line) => line.content.length > 0); + + return { + path, + status: "added", + additions: lines.length, + deletions: 0, + addedLines: lines + }; +} + +function readTextFile(path: string): string { + try { + return readFileSync(path, "utf8"); + } catch { + return ""; + } +} diff --git a/packages/git/test/git.test.ts b/packages/git/test/git.test.ts index ea20d78..01bc27e 100644 --- a/packages/git/test/git.test.ts +++ b/packages/git/test/git.test.ts @@ -121,6 +121,22 @@ describe("live git integration", () => { ]); }); + it("handles repositories with no commits without leaking raw HEAD errors", () => { + const repo = createEmptyRepo(); + writeFile(repo, "src/app.ts", "export const value = 1;\n"); + + expect(() => getGitChangedFiles({ cwd: repo })).not.toThrow(/ambiguous argument 'HEAD'/); + expect(getGitChangedFiles({ cwd: repo })).toEqual([ + { + path: "src/app.ts", + status: "added", + additions: 1, + deletions: 0, + addedLines: [{ line: 1, content: "export const value = 1;" }] + } + ]); + }); + it("throws a clear error for invalid refs", () => { const repo = createRepo({ "src/app.ts": "export const value = 1;\n" @@ -215,6 +231,16 @@ function createRepo(files: Record): string { return repo; } +function createEmptyRepo(): string { + const repo = mkdtempSync(join(tmpdir(), "codedecay-git-empty-")); + tempRoots.push(repo); + + git(repo, ["init", "-b", "main"]); + git(repo, ["config", "user.email", "codedecay@example.com"]); + git(repo, ["config", "user.name", "CodeDecay Test"]); + return repo; +} + function writeFile(root: string, path: string, contents: string): void { const fullPath = join(root, path); mkdirSync(dirname(fullPath), { recursive: true });