From 9490ae2df0515c62eb283440ce349de619289151 Mon Sep 17 00:00:00 2001 From: cookerpapa Date: Mon, 14 Sep 2026 17:39:44 +0800 Subject: [PATCH] fix(blast): normalize Git diff prefixes --- src/blast/diff.ts | 3 ++ test/blast-diff-prefix.test.ts | 86 ++++++++++++++++++++++++++++++++++ test/blast.test.ts | 15 ++++++ 3 files changed, 104 insertions(+) create mode 100644 test/blast-diff-prefix.test.ts diff --git a/src/blast/diff.ts b/src/blast/diff.ts index 49e10c75..caa053d8 100644 --- a/src/blast/diff.ts +++ b/src/blast/diff.ts @@ -131,6 +131,9 @@ function diffFiles(dir: string, range: string[]): ChangedFile[] | null { "--unified=0", "--no-color", "--no-ext-diff", + // applyHunks expects a/ and b/, regardless of the user's diff.* preferences. + "--src-prefix=a/", + "--dst-prefix=b/", "--find-renames", ...range, "--", diff --git a/test/blast-diff-prefix.test.ts b/test/blast-diff-prefix.test.ts new file mode 100644 index 00000000..7753bb20 --- /dev/null +++ b/test/blast-diff-prefix.test.ts @@ -0,0 +1,86 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { join } from "node:path"; +import { changedFiles } from "../src/blast/diff.js"; +import { tmpRepo } from "./helpers.js"; + +function git(dir: string, ...args: string[]): string { + const result = spawnSync("git", ["-c", "commit.gpgsign=false", ...args], { + cwd: dir, + encoding: "utf8", + }); + assert.equal(result.status, 0, `git ${args.join(" ")} failed: ${result.stderr}`); + return result.stdout.trim(); +} + +const configurations: [string, [string, string][]][] = [ + ["default", []], + ["mnemonic", [["diff.mnemonicPrefix", "true"]]], + ["no prefix", [["diff.noprefix", "true"]]], + ["custom", [["diff.srcPrefix", "before/"], ["diff.dstPrefix", "after/"]]], +]; + +for (const [name, settings] of configurations) { + for (const comparison of ["working tree", "base branch", "last commit"] as const) { + test(`diff: ${name} configuration preserves paths and hunks for ${comparison}`, (t) => { + const dir = tmpRepo("diff-prefix"); + t.after(() => rmSync(dir, { recursive: true, force: true })); + git(dir, "init", "-b", "main"); + git(dir, "config", "user.name", "Test"); + git(dir, "config", "user.email", "test@example.com"); + // Set defaults locally so the fixture is independent of the contributor's + // global preferences, then enable the configuration under test. + for (const [key, value] of [ + ["diff.mnemonicPrefix", "false"], ["diff.noprefix", "false"], + ["diff.srcPrefix", "a/"], ["diff.dstPrefix", "b/"], ...settings, + ]) git(dir, "config", key, value); + + // A real b/ directory catches the ambiguity in stripping a presumed + // prefix when the user's diff.noprefix setting removed it already. + mkdirSync(join(dir, "b")); + writeFileSync(join(dir, "b/changed file.ts"), "export function value() {\n return 1;\n}\n"); + const renameBody = "// stable first line\n// stable second line\n// stable third line\n// stable fourth line\n"; + writeFileSync(join(dir, "b/old.ts"), `${renameBody}export const old = 1;\n`); + writeFileSync(join(dir, "b/deleted.ts"), "export const deleted = true;\n"); + git(dir, "add", "-A"); + git(dir, "commit", "-m", "initial"); + git(dir, "checkout", "-b", "feature"); + + writeFileSync(join(dir, "b/changed file.ts"), "export function value() {\n return 2;\n}\n"); + renameSync(join(dir, "b/old.ts"), join(dir, "b/renamed.ts")); + writeFileSync(join(dir, "b/renamed.ts"), `${renameBody}export const old = 2;\n`); + writeFileSync(join(dir, "b/added.ts"), "export const added = true;\n"); + rmSync(join(dir, "b/deleted.ts")); + git(dir, "add", "-A"); + // Keep both staged and unstaged edits in the working-tree comparison. + writeFileSync(join(dir, "b/changed file.ts"), "export function value() {\n return 3;\n}\n"); + if (comparison !== "working tree") { + git(dir, "add", "-A"); + git(dir, "commit", "-m", "edit files"); + } + + const configBefore = git(dir, "config", "--local", "--list"); + const result = changedFiles(dir, comparison === "base branch" ? "main" : undefined); + assert.ok(result); + assert.equal(result.basis, comparison === "working tree" ? "working tree vs HEAD" : comparison === "base branch" ? "main...HEAD" : "HEAD~1...HEAD"); + const files = new Map(result.files.map((file) => [file.path, file])); + assert.equal(files.size, 4); + assert.deepEqual(files.get("b/changed file.ts")?.ranges, [{ start: 2, end: 2 }]); + assert.deepEqual(files.get("b/changed file.ts")?.hunks[0].lines, [ + { n: null, sign: "-", text: " return 1;" }, + { n: 2, sign: "+", text: " return 3;" }, + ]); + assert.equal(files.get("b/added.ts")?.status, "added"); + assert.deepEqual(files.get("b/added.ts")?.ranges, [{ start: 1, end: 1 }]); + assert.equal(files.get("b/renamed.ts")?.status, "renamed"); + assert.equal(files.get("b/renamed.ts")?.oldPath, "b/old.ts"); + assert.deepEqual(files.get("b/renamed.ts")?.ranges, [{ start: 5, end: 5 }]); + assert.equal(files.get("b/deleted.ts")?.status, "deleted"); + assert.deepEqual(files.get("b/deleted.ts")?.ranges, []); + assert.deepEqual(files.get("b/deleted.ts")?.hunks, []); + assert.equal(git(dir, "config", "--local", "--list"), configBefore); + }); + } +} diff --git a/test/blast.test.ts b/test/blast.test.ts index 80190672..28231b51 100644 --- a/test/blast.test.ts +++ b/test/blast.test.ts @@ -114,6 +114,21 @@ test("blast: an edit inside one function reports that function's dependents, not assert.ok(!names.includes("area"), `area must not be reported: ${JSON.stringify(names)}`); }); +for (const [key, value] of [["diff.mnemonicPrefix", "true"], ["diff.dstPrefix", "after/"]]) { + test(`blast: ${key} preserves symbol-level seeds and dependents`, (t) => { + const d = builtRepo(); + t.after(() => rmSync(d, { recursive: true, force: true })); + git(d, "config", key, value); + writeFileSync(join(d, "src", "math.ts"), MATH_EDITED); + + const report = blastJson([d]); + assert.deepEqual(report.seeds.map((seed) => seed.name), ["add"]); + assert.ok(report.seeds.every((seed) => !seed.wholeFile)); + assert.deepEqual(report.changed[0].ranges, [{ start: 2, end: 2 }]); + assert.deepEqual(new Set(report.impacted.map((item) => item.name)), new Set(["total", "report"])); + }); +} + test("blast --depth: one hop stops at the direct caller", () => { const d = builtRepo(); writeFileSync(join(d, "src", "math.ts"), MATH_EDITED);