Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
54 changes: 53 additions & 1 deletion src/commands.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 });
}
});
51 changes: 51 additions & 0 deletions src/diffParser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
]);
});
77 changes: 73 additions & 4 deletions src/diffParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,83 @@ function stripPrefix(path: string): string {
return path.replace(/^a\//, "").replace(/^b\//, "");
}

const escapeBytes: Record<string, number> = {
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<string, FileChange>();
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;
Expand All @@ -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";
Expand Down