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
88 changes: 57 additions & 31 deletions packages/gittensory-mcp/lib/local-branch.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
// "<added>\t<deleted>\t<path?>" -- 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) {
Expand Down
82 changes: 82 additions & 0 deletions test/unit/local-branch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1589,6 +1589,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");
Expand Down