diff --git a/src/index.js b/src/index.js index 3d6c575..bcb0f46 100755 --- a/src/index.js +++ b/src/index.js @@ -26,6 +26,21 @@ const GROUP_RULES = [ const VERSION = '0.1.0'; export function parseNameStatus(output, source = 'unstaged') { + if (output.includes('\0')) { + const fields = output.split('\0'); + const changes = []; + + for (let index = 0; index < fields.length && fields[index];) { + const rawStatus = fields[index++]; + const status = rawStatus[0]; + const previousPath = status === 'R' || status === 'C' ? fields[index++] : null; + const path = fields[index++]; + changes.push(nameStatusChange(rawStatus, path, previousPath, source)); + } + + return changes; + } + return output .trim() .split('\n') @@ -34,36 +49,65 @@ export function parseNameStatus(output, source = 'unstaged') { const parts = line.split('\t'); const rawStatus = parts[0]; const status = rawStatus[0]; - const score = rawStatus.length > 1 ? rawStatus.slice(1) : null; const path = status === 'R' || status === 'C' ? parts[2] : parts[1]; const previousPath = status === 'R' || status === 'C' ? parts[1] : null; - return { - path, - previousPath, - status, - statusDetail: rawStatus, - statusLabel: STATUS_LABELS[status] ?? 'changed', - score, - source, - }; + return nameStatusChange(rawStatus, path, previousPath, source); }); } export function parseNumstat(output) { const byPath = new Map(); + if (output.includes('\0')) { + const fields = output.split('\0'); + + for (let index = 0; index < fields.length && fields[index];) { + const record = fields[index++]; + const firstTab = record.indexOf('\t'); + const secondTab = record.indexOf('\t', firstTab + 1); + const addedText = record.slice(0, firstTab); + const deletedText = record.slice(firstTab + 1, secondTab); + const path = record.slice(secondTab + 1); + let destinationPath = path; + if (!destinationPath) { + index += 1; // The first path is the rename/copy source. + destinationPath = fields[index++]; + } + byPath.set(destinationPath, numstatEntry(addedText, deletedText)); + } + + return byPath; + } + for (const line of output.trim().split('\n').filter(Boolean)) { const parts = line.split('\t'); - const added = parts[0] === '-' ? null : Number(parts[0]); - const deleted = parts[1] === '-' ? null : Number(parts[1]); const path = parts.at(-1); - byPath.set(path, { added, deleted, binary: added === null || deleted === null }); + byPath.set(path, numstatEntry(parts[0], parts[1])); } return byPath; } +function nameStatusChange(rawStatus, path, previousPath, source) { + const status = rawStatus[0]; + return { + path, + previousPath, + status, + statusDetail: rawStatus, + statusLabel: STATUS_LABELS[status] ?? 'changed', + score: rawStatus.length > 1 ? rawStatus.slice(1) : null, + source, + }; +} + +function numstatEntry(addedText, deletedText) { + const added = addedText === '-' ? null : Number(addedText); + const deleted = deletedText === '-' ? null : Number(deletedText); + return { added, deleted, binary: added === null || deleted === null }; +} + export function parseUntrackedPaths(output) { return output .split('\0') @@ -165,8 +209,8 @@ export function renderMarkdown(plan) { } export function collectGitDiff(cwd = process.cwd()) { - const unstaged = parseNameStatus(runGit(['diff', '--name-status'], cwd), 'unstaged'); - const staged = parseNameStatus(runGit(['diff', '--cached', '--name-status'], cwd), 'staged'); + const unstaged = parseNameStatus(runGit(['diff', '--name-status', '-z'], cwd), 'unstaged'); + const staged = parseNameStatus(runGit(['diff', '--cached', '--name-status', '-z'], cwd), 'staged'); const untrackedPaths = parseUntrackedPaths(runGit(['ls-files', '--others', '--exclude-standard', '-z'], cwd)); const untracked = untrackedPaths.map((path) => ({ path, @@ -180,8 +224,8 @@ export function collectGitDiff(cwd = process.cwd()) { const untrackedStats = new Map(untrackedPaths.map((path) => [path, untrackedStat(path, cwd)])); const changes = mergeChanges([...staged, ...unstaged, ...untracked]); const stats = mergeStats([ - parseNumstat(runGit(['diff', '--cached', '--numstat'], cwd)), - parseNumstat(runGit(['diff', '--numstat'], cwd)), + parseNumstat(runGit(['diff', '--cached', '--numstat', '-z'], cwd)), + parseNumstat(runGit(['diff', '--numstat', '-z'], cwd)), untrackedStats, ]); const diffStat = mergeDiffStats([ diff --git a/test/plan.test.js b/test/plan.test.js index 502c97c..838a062 100644 --- a/test/plan.test.js +++ b/test/plan.test.js @@ -54,6 +54,31 @@ test('parses git name-status metadata including renames', () => { ]); }); +test('parses NUL-delimited name-status and numstat without splitting paths', () => { + const tabPath = 'src/tab\tname.js'; + const newlinePath = 'docs/new\nname.md'; + const copyPath = 'docs/copy\tname.md'; + const changes = parseNameStatus([ + 'M', tabPath, + 'R087', 'docs/old\nname.md', newlinePath, + 'C100', 'docs/source.md', copyPath, + '', + ].join('\0')); + const stats = parseNumstat([ + `2\t1\t${tabPath}`, + '1\t3\t', 'docs/old\nname.md', newlinePath, + '', + ].join('\0')); + + assert.deepEqual(changes.map(({ path, previousPath, statusDetail }) => ({ path, previousPath, statusDetail })), [ + { path: tabPath, previousPath: null, statusDetail: 'M' }, + { path: newlinePath, previousPath: 'docs/old\nname.md', statusDetail: 'R087' }, + { path: copyPath, previousPath: 'docs/source.md', statusDetail: 'C100' }, + ]); + assert.deepEqual(stats.get(tabPath), { added: 2, deleted: 1, binary: false }); + assert.deepEqual(stats.get(newlinePath), { added: 1, deleted: 3, binary: false }); +}); + test('parses git stat summaries for plan metadata', () => { const stat = parseDiffStat(` src/index.js | 2 +- @@ -208,6 +233,36 @@ test('cli preserves special characters in untracked paths', () => { assert.deepEqual(first.json, second.json); }); +test('cli preserves staged and unstaged tracked paths containing tabs and newlines', () => { + const stagedPath = 'src/staged\tname.js'; + const unstagedPath = 'src/unstaged\nname.js'; + const repo = createRepo({ + initialFiles: { + [stagedPath]: 'before\n', + [unstagedPath]: 'one\ntwo\n', + }, + }); + writeFileSync(join(repo, stagedPath), 'before\nafter\n'); + execFileSync('git', ['add', '--', stagedPath], { cwd: repo }); + writeFileSync(join(repo, unstagedPath), 'changed\n'); + + const { markdown, json } = runCli(repo); + const files = json.commits.flatMap((commit) => commit.files); + const staged = files.find((file) => file.path === stagedPath); + const unstaged = files.find((file) => file.path === unstagedPath); + + assert.deepEqual({ source: staged.source, stats: staged.stats }, { + source: 'staged', + stats: { added: 1, deleted: 0, binary: false }, + }); + assert.deepEqual({ source: unstaged.source, stats: unstaged.stats }, { + source: 'unstaged', + stats: { added: 1, deleted: 2, binary: false }, + }); + assert.ok(markdown.includes(stagedPath)); + assert.ok(markdown.includes(unstagedPath)); +}); + test('cli prints version without requiring a git repo', () => { const cliPath = join(process.cwd(), 'src/index.js'); const output = execFileSync(process.execPath, [cliPath, '--version'], { cwd: tmpdir(), encoding: 'utf8' });