diff --git a/CHANGELOG.md b/CHANGELOG.md index 92e8296..751777f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,11 @@ format and uses semantic versioning when versioned releases are published. - Initial project setup. +### Fixed + +- Decode Git-quoted modification and rename paths so scans, path patterns, and + reports use the actual filename. + ## Release Links - Unreleased: diff --git a/src/commands.test.ts b/src/commands.test.ts index 2b045c7..9c55b88 100644 --- a/src/commands.test.ts +++ b/src/commands.test.ts @@ -1,4 +1,5 @@ -import { mkdtemp, rm } from "node:fs/promises"; +import { execFileSync } from "node:child_process"; +import { mkdir, mkdtemp, readFile, rename, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { test } from "node:test"; @@ -17,3 +18,54 @@ test("runCommand scans a fixture diff", async () => { await rm(dir, { recursive: true, force: true }); } }); + +test("runCommand reports logical paths from a real Git diff", async () => { + const dir = await mkdtemp(join(tmpdir(), "diffbudget-git-paths-")); + const tabPath = "src/tab\tname.ts"; + const renamedPath = "test/renamed é file.test.ts"; + const git = (args: string[]) => execFileSync("git", args, { cwd: dir, encoding: "utf8" }); + + try { + git(["init", "--quiet"]); + git(["config", "user.name", "DiffBudget Test"]); + git(["config", "user.email", "test@example.com"]); + await mkdir(join(dir, "src")); + await mkdir(join(dir, "test")); + await writeFile(join(dir, tabPath), "before\n"); + await writeFile(join(dir, "old name.txt"), "rename me\n"); + await writeFile(join(dir, "deleted.txt"), "remove me\n"); + git(["add", "."]); + git(["commit", "--quiet", "-m", "base"]); + + await writeFile(join(dir, tabPath), "after\n"); + await writeFile(join(dir, "added.txt"), "new\n"); + await writeFile(join(dir, "binary.dat"), Uint8Array.from([0, 1, 2, 3])); + await rm(join(dir, "deleted.txt")); + await rename(join(dir, "old name.txt"), join(dir, renamedPath)); + git(["add", "-A"]); + git(["commit", "--quiet", "-m", "change"]); + + const result = await runCommand(parseArgs([ + "scan", + "--base", "HEAD~1", + "--target", "HEAD", + "--output", join(dir, "report"), + "--format", "json" + ]), dir); + const report = JSON.parse(await readFile(join(dir, "report", "diffbudget-report.json"), "utf8")) as { + files: Array<{ file: { path: string; oldPath?: string; status: string; additions: number; deletions: number } }>; + }; + + assert.equal(result.code, 0); + const files = new Map(report.files.map(({ file }) => [file.path, file])); + assert.deepEqual([...files.keys()].sort(), ["added.txt", "binary.dat", "deleted.txt", tabPath, renamedPath].sort()); + assert.deepEqual(files.get("added.txt"), { path: "added.txt", status: "added", additions: 1, deletions: 0 }); + assert.deepEqual(files.get("binary.dat"), { path: "binary.dat", status: "binary", additions: 0, deletions: 0, binary: true }); + assert.deepEqual(files.get("deleted.txt"), { path: "deleted.txt", status: "deleted", additions: 0, deletions: 1 }); + assert.deepEqual(files.get(tabPath), { path: tabPath, status: "modified", additions: 1, deletions: 1 }); + assert.equal(files.get(renamedPath)?.status, "renamed"); + assert.equal(files.get(renamedPath)?.oldPath, "old name.txt"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); diff --git a/src/diffParser.test.ts b/src/diffParser.test.ts index 9e7a3f2..9669ce9 100644 --- a/src/diffParser.test.ts +++ b/src/diffParser.test.ts @@ -12,3 +12,54 @@ test("parseUnifiedDiff extracts paths and line counts", () => { assert.equal(changes[0]?.deletions, 1); assert.equal(changes[2]?.status, "added"); }); + +test("parseUnifiedDiff decodes Git-quoted modification paths", () => { + const diff = [ + String.raw`diff --git "a/tab\tquote\"slash\\-\303\251.txt" "b/tab\tquote\"slash\\-\303\251.txt"`, + "index 1234567..89abcde 100644", + String.raw`--- "a/tab\tquote\"slash\\-\303\251.txt"`, + String.raw`+++ "b/tab\tquote\"slash\\-\303\251.txt"`, + "@@ -1 +1 @@", + "-before", + "+after" + ].join("\n"); + + assert.deepEqual(parseUnifiedDiff(diff), [{ + path: "tab\tquote\"slash\\-é.txt", + status: "modified", + additions: 1, + deletions: 1 + }]); +}); + +test("parseUnifiedDiff preserves ordinary spaces and decodes quoted renames", () => { + const diff = [ + "diff --git a/ordinary space.txt b/ordinary space.txt", + "index 1234567..89abcde 100644", + "--- a/ordinary space.txt", + "+++ b/ordinary space.txt", + "@@ -1 +1 @@", + "-before", + "+after", + String.raw`diff --git "a/old\tname.txt" "b/new\nname.txt"`, + "similarity index 100%", + String.raw`rename from "old\tname.txt"`, + String.raw`rename to "new\nname.txt"` + ].join("\n"); + + assert.deepEqual(parseUnifiedDiff(diff), [ + { + path: "ordinary space.txt", + status: "modified", + additions: 1, + deletions: 1 + }, + { + path: "new\nname.txt", + oldPath: "old\tname.txt", + status: "renamed", + additions: 0, + deletions: 0 + } + ]); +}); diff --git a/src/diffParser.ts b/src/diffParser.ts index 6a9a001..73fb953 100644 --- a/src/diffParser.ts +++ b/src/diffParser.ts @@ -14,14 +14,83 @@ function stripPrefix(path: string): string { return path.replace(/^a\//, "").replace(/^b\//, ""); } +const escapeBytes: Record = { + a: 0x07, + b: 0x08, + t: 0x09, + n: 0x0a, + v: 0x0b, + f: 0x0c, + r: 0x0d, + "\"": 0x22, + "\\": 0x5c +}; + +function readQuotedPath(input: string, start = 0): { path: string; end: number } | undefined { + if (input[start] !== "\"") return undefined; + + const bytes: number[] = []; + const encoder = new TextEncoder(); + let index = start + 1; + + while (index < input.length) { + const character = input[index]; + if (character === "\"") { + return { path: new TextDecoder().decode(Uint8Array.from(bytes)), end: index + 1 }; + } + if (character !== "\\") { + const codePoint = input.codePointAt(index); + if (codePoint === undefined) return undefined; + const literal = String.fromCodePoint(codePoint); + bytes.push(...encoder.encode(literal)); + index += literal.length; + continue; + } + + index += 1; + const escaped = input[index]; + if (escaped === undefined) return undefined; + if (/[0-7]/.test(escaped)) { + const octal = input.slice(index).match(/^[0-7]{1,3}/)?.[0]; + if (!octal) return undefined; + bytes.push(Number.parseInt(octal, 8)); + index += octal.length; + continue; + } + bytes.push(escapeBytes[escaped] ?? escaped.charCodeAt(0)); + index += 1; + } + + return undefined; +} + +function decodeGitPath(path: string): string { + const quoted = readQuotedPath(path); + return quoted && quoted.end === path.length ? quoted.path : path; +} + +function pathFromDiffHeader(line: string): string { + const header = line.slice("diff --git ".length); + if (header.startsWith("\"")) { + const oldPath = readQuotedPath(header); + if (oldPath) { + const nextStart = oldPath.end + (header[oldPath.end] === " " ? 1 : 0); + const newPath = readQuotedPath(header, nextStart); + if (newPath && newPath.end === header.length) return newPath.path; + } + } + + const match = /^a\/(.*) b\/(.*)$/.exec(header); + return match ? match[2] : header.trim(); +} + export function parseUnifiedDiff(text: string): FileChange[] { const byPath = new Map(); let current: FileChange | undefined; for (const line of text.split(/\r?\n/)) { if (line.startsWith("diff --git ")) { - const match = /^diff --git a\/(.*) b\/(.*)$/.exec(line); - const path = match ? match[2] : line.slice("diff --git ".length).trim(); + const path = pathFromDiffHeader(line); current = emptyChange(stripPrefix(path)); byPath.set(current.path, current); continue; @@ -32,12 +101,12 @@ export function parseUnifiedDiff(text: string): FileChange[] { continue; } if (line.startsWith("rename from ")) { - current.oldPath = line.slice("rename from ".length).trim(); + current.oldPath = decodeGitPath(line.slice("rename from ".length).trim()); current.status = "renamed"; continue; } if (line.startsWith("rename to ")) { - const nextPath = line.slice("rename to ".length).trim(); + const nextPath = decodeGitPath(line.slice("rename to ".length).trim()); byPath.delete(current.path); current.path = nextPath; current.status = "renamed";