From b6e9b68bbcdfe255682b99c6218c7d4c769205f5 Mon Sep 17 00:00:00 2001 From: Dexterity104 Date: Thu, 25 Jun 2026 16:05:56 +0000 Subject: [PATCH] fix(mcp): report real changed line counts for renamed files --- packages/gittensory-mcp/lib/local-branch.js | 88 +++++++++++++-------- test/unit/local-branch.test.ts | 82 +++++++++++++++++++ 2 files changed, 139 insertions(+), 31 deletions(-) diff --git a/packages/gittensory-mcp/lib/local-branch.js b/packages/gittensory-mcp/lib/local-branch.js index ec149347ec..2ea85367ec 100644 --- a/packages/gittensory-mcp/lib/local-branch.js +++ b/packages/gittensory-mcp/lib/local-branch.js @@ -341,55 +341,81 @@ export function probeLocalScorer(scorerCommand = resolveScorePreviewCommand()) { ); } -export function gitLines(cwd, args) { +function gitOutput(cwd, args) { try { - return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000 }) - .split("\n") - .map((line) => line.trim()) - .filter(Boolean); + return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000 }); } catch { - return []; + return ""; } } +export function gitLines(cwd, args) { + return gitOutput(cwd, args) + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); +} + function collectChangedFiles(cwd, baseRef) { - const statusRows = gitLines(cwd, ["diff", "--name-status", "-M", baseRef, "--"]); + // Read both halves with `-z`: the human format quotes non-ASCII/control-char paths, so a quoted + // name-status key would never match the verbatim numstat key and the file's stats would be lost. const numstat = new Map(parseNumstat(cwd, baseRef).map((entry) => [entry.path, entry])); - return statusRows.map((row) => { - const fields = row.split(/\t/); - const code = fields[0] ?? ""; - const isRename = code.startsWith("R"); - const path = isRename ? fields[2] ?? fields[1] ?? "" : fields[1] ?? ""; - const previousPath = isRename ? fields[1] : undefined; - const stats = numstat.get(path) ?? { additions: 0, deletions: 0, binary: false }; + return parseNameStatus(cwd, baseRef).map((entry) => { + const stats = numstat.get(entry.path) ?? { additions: 0, deletions: 0, binary: false }; return stripUndefined({ - path, - previousPath, + path: entry.path, + previousPath: entry.previousPath, additions: stats.additions, deletions: stats.deletions, - status: statusFromCode(code), + status: statusFromCode(entry.code), binary: stats.binary, }); }); } +function parseNameStatus(cwd, baseRef) { + // `-z`: the status code is its own field and paths are verbatim; a rename is followed by the old + // then the new path, any other status by a single path. + const records = gitOutput(cwd, ["diff", "--name-status", "-M", "-z", baseRef, "--"]).split("\0"); + const entries = []; + for (let index = 0; index < records.length; index += 1) { + const code = records[index]; + if (!code) continue; + const isRename = code.startsWith("R"); + const previousPath = isRename ? records[index + 1] : undefined; + const path = records[index + (isRename ? 2 : 1)]; + index += isRename ? 2 : 1; + entries.push({ code, path, previousPath }); + } + return entries; +} + function parseNumstat(cwd, baseRef) { - return gitLines(cwd, ["diff", "--numstat", "-M", baseRef, "--"]).map((row) => { - const fields = row.split(/\t/); - const additions = fields[0] === "-" ? 0 : Number(fields[0] ?? 0); - const deletions = fields[1] === "-" ? 0 : Number(fields[1] ?? 0); - return { - path: normalizeNumstatPath(fields.slice(2).join("\t")), - additions: Number.isFinite(additions) ? additions : 0, - deletions: Number.isFinite(deletions) ? deletions : 0, - binary: fields[0] === "-" || fields[1] === "-", - }; - }); + // `-z`: paths are verbatim and a rename emits old/new as separate fields, not the lossy + // "{a => b}" / "a => b" human form that left cross-directory renames keyed by an unmatchable string. + const records = gitOutput(cwd, ["diff", "--numstat", "-M", "-z", baseRef, "--"]).split("\0"); + const entries = []; + for (let index = 0; index < records.length; index += 1) { + const stat = records[index]; + if (!stat) continue; + const [added, deleted, inlinePath] = splitNumstatStat(stat); + // An empty inline path marks a rename: the new path is the second of the two following fields. + let path = inlinePath; + if (inlinePath === "") { + path = records[index + 2]; + index += 2; + } + const binary = added === "-"; + entries.push({ path, additions: binary ? 0 : Number(added), deletions: binary ? 0 : Number(deleted), binary }); + } + return entries; } -function normalizeNumstatPath(path) { - const renamed = path.match(/\{.* => (.*)\}/); - return renamed?.[1] ? path.replace(/\{.* => (.*)\}/, renamed[1]) : path; +function splitNumstatStat(stat) { + // "\t\t" -- keep the path slice intact even if it contains tabs. + const firstTab = stat.indexOf("\t"); + const secondTab = stat.indexOf("\t", firstTab + 1); + return [stat.slice(0, firstTab), stat.slice(firstTab + 1, secondTab), stat.slice(secondTab + 1)]; } function collectCommitMessages(cwd, baseRef) { diff --git a/test/unit/local-branch.test.ts b/test/unit/local-branch.test.ts index afdd92b0fb..bbf755039f 100644 --- a/test/unit/local-branch.test.ts +++ b/test/unit/local-branch.test.ts @@ -1500,6 +1500,88 @@ describe("local MCP git metadata collection", () => { expect(JSON.stringify(renameMetadata)).not.toMatch(/export const old/); }); + it("counts additions and deletions for cross-directory renames that share no prefix or suffix", async () => { + // @ts-expect-error package helper is plain JS because the local wrapper ships as a Node bin package. + const { collectLocalBranchMetadata } = await import("../../packages/gittensory-mcp/lib/local-branch.js"); + tempDir = mkdtempSync(join(tmpdir(), "gittensory-local-")); + git(tempDir, "init"); + git(tempDir, "config", "user.email", "test@example.com"); + git(tempDir, "config", "user.name", "Gittensory Test"); + git(tempDir, "config", "commit.gpgsign", "false"); + git(tempDir, "remote", "add", "origin", "git@github.com:entrius/allways-ui.git"); + writeFileSync(join(tempDir, "README.md"), "fixture\n"); + git(tempDir, "add", "README.md"); + git(tempDir, "commit", "-m", "initial commit"); + git(tempDir, "checkout", "-b", "cross-dir-rename"); + mkdirSync(join(tempDir, "src/alpha"), { recursive: true }); + // A large body keeps rename similarity high so git reports a rename, not add + delete. + const body = Array.from({ length: 20 }, (_, line) => `line ${line}`).join("\n"); + writeFileSync(join(tempDir, "src/alpha/foo.js"), `${body}\n`); + // A binary blob exercises numstat's "-\t-" path (additions/deletions 0, binary true). + writeFileSync(join(tempDir, "logo.bin"), Buffer.from([0, 1, 2, 0, 255, 254])); + git(tempDir, "add", "-A"); + git(tempDir, "commit", "-m", "add foo"); + // With no shared prefix or suffix git renders this rename as a bare "src/alpha/foo.js => + // docs/beta/bar.js" in --numstat, which the previous brace-only parser never matched -> the + // renamed file fell back to +0/-0 and undercounted changedLineCount. + mkdirSync(join(tempDir, "docs/beta"), { recursive: true }); + git(tempDir, "mv", "src/alpha/foo.js", "docs/beta/bar.js"); + writeFileSync(join(tempDir, "docs/beta/bar.js"), `${body}\nadded one\nadded two\n`); + writeFileSync(join(tempDir, "logo.bin"), Buffer.from([3, 0, 4, 0, 5, 0, 6])); + git(tempDir, "add", "-A"); + git(tempDir, "commit", "-m", "rename foo across directories"); + + const renameMetadata = collectLocalBranchMetadata({ cwd: tempDir, baseRef: "HEAD~1", login: "oktofeesh1" }); + expect(renameMetadata.changedFiles).toEqual( + expect.arrayContaining([ + expect.objectContaining({ path: "docs/beta/bar.js", previousPath: "src/alpha/foo.js", status: "renamed", additions: 2, deletions: 0 }), + expect.objectContaining({ path: "logo.bin", status: "modified", additions: 0, deletions: 0, binary: true }), + ]), + ); + }); + + it("returns no lines when the git command fails", async () => { + // @ts-expect-error package helper is plain JS because the local wrapper ships as a Node bin package. + const { gitLines } = await import("../../packages/gittensory-mcp/lib/local-branch.js"); + expect(gitLines(join(tmpdir(), "gittensory-no-such-repo-d8f3"), ["rev-parse", "HEAD"])).toEqual([]); + }); + + it("counts stats and keeps verbatim paths for non-ASCII filenames at the default core.quotePath", async () => { + // @ts-expect-error package helper is plain JS because the local wrapper ships as a Node bin package. + const { collectLocalBranchMetadata } = await import("../../packages/gittensory-mcp/lib/local-branch.js"); + tempDir = mkdtempSync(join(tmpdir(), "gittensory-local-")); + git(tempDir, "init"); + git(tempDir, "config", "user.email", "test@example.com"); + git(tempDir, "config", "user.name", "Gittensory Test"); + git(tempDir, "config", "commit.gpgsign", "false"); + // Deliberately leave core.quotePath at its default (on): git's human --name-status then quotes + // accented paths, which would diverge from the verbatim --numstat -z key and zero out the stats. + git(tempDir, "remote", "add", "origin", "git@github.com:entrius/allways-ui.git"); + writeFileSync(join(tempDir, "über.txt"), "a\nb\nc\n"); + const renameBody = Array.from({ length: 20 }, (_, line) => `line ${line}`).join("\n"); + mkdirSync(join(tempDir, "café")); + writeFileSync(join(tempDir, "café/old.txt"), `${renameBody}\n`); + git(tempDir, "add", "-A"); + git(tempDir, "commit", "-m", "seed non-ascii files"); + git(tempDir, "checkout", "-b", "non-ascii"); + writeFileSync(join(tempDir, "über.txt"), "a\nb\nc\nd\ne\n"); + writeFileSync(join(tempDir, "naïve.txt"), "x\ny\nz\n"); + mkdirSync(join(tempDir, "docs")); + git(tempDir, "mv", "café/old.txt", "docs/résumé.txt"); + writeFileSync(join(tempDir, "docs/résumé.txt"), `${renameBody}\nextra\n`); + git(tempDir, "add", "-A"); + git(tempDir, "commit", "-m", "edit non-ascii files"); + + const metadata = collectLocalBranchMetadata({ cwd: tempDir, baseRef: "HEAD~1", login: "oktofeesh1" }); + expect(metadata.changedFiles).toEqual( + expect.arrayContaining([ + expect.objectContaining({ path: "über.txt", status: "modified", additions: 2, deletions: 0 }), + expect.objectContaining({ path: "naïve.txt", status: "added", additions: 3, deletions: 0 }), + expect.objectContaining({ path: "docs/résumé.txt", previousPath: "café/old.txt", status: "renamed", additions: 1, deletions: 0 }), + ]), + ); + }); + it("parses remotes, changed-file stats, linked issues, and refuses source upload mode", async () => { // @ts-expect-error package helper is plain JS because the local wrapper ships as a Node bin package. const { collectLocalBranchMetadata, parseGitRemote } = await import("../../packages/gittensory-mcp/lib/local-branch.js");