diff --git a/README.md b/README.md index ba931de..780b59c 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,8 @@ atomcommit --help The `git ls-files` query includes ordinary untracked files while respecting Git ignore rules. NUL-delimited paths preserve spaces and other special characters. The CLI remains read-only: it does not stage files, alter the index, or modify the working tree. +JSON output preserves path values exactly. In Markdown output, paths are displayed as JSON string literals inside inline code spans. Control characters therefore appear as escapes such as `\\n` and `\\t`, and the code-span fence automatically expands when a filename contains backticks, keeping every path on one unambiguous list item. + Staged and unstaged diffs are combined by logical path. If a staged rename is edited again before commit, the plan keeps the rename's original path and rename risk flag, labels it `staged + unstaged`, aggregates both sets of line changes, and counts it as one changed file. It groups changes by repository area such as documentation, tests, source code, package metadata, and CI automation. It also flags review risks such as deletions, renames, lockfiles, large changes, binary files, and sensitive-looking paths. diff --git a/src/index.js b/src/index.js index bcb0f46..88c234e 100755 --- a/src/index.js +++ b/src/index.js @@ -169,6 +169,14 @@ export function buildPlan(changes, numstat = new Map(), diffStat = { raw: '', su }; } +function renderPath(path) { + const value = JSON.stringify(path); + const longestRun = Math.max(0, ...[...value.matchAll(/`+/g)].map(([run]) => run.length)); + const fence = '`'.repeat(longestRun + 1); + const padding = value.startsWith('`') || value.endsWith('`') ? ' ' : ''; + return `${fence}${padding}${value}${padding}${fence}`; +} + export function renderMarkdown(plan) { const lines = [ '# Atomic Commit Plan', @@ -193,9 +201,9 @@ export function renderMarkdown(plan) { for (const file of commit.files) { const stats = file.stats.binary ? 'binary' : `+${file.stats.added}/-${file.stats.deleted}`; - const previous = file.previousPath ? ` (from ${file.previousPath})` : ''; + const previous = file.previousPath ? ` (from ${renderPath(file.previousPath)})` : ''; const source = file.source === 'staged+unstaged' ? ', staged + unstaged' : `, ${file.source}`; - lines.push(`- ${file.statusLabel}: ${file.path}${previous} (${stats}${source})`); + lines.push(`- ${file.statusLabel}: ${renderPath(file.path)}${previous} (${stats}${source})`); } if (commit.riskFlags.length > 0) { diff --git a/test/plan.test.js b/test/plan.test.js index 838a062..fc984fe 100644 --- a/test/plan.test.js +++ b/test/plan.test.js @@ -156,7 +156,7 @@ test('cli plans an untracked-only file without mutating it', () => { group: 'source code', riskFlags: [], }); - assert.match(markdown, /added: src\/new-feature\.js \(\+1\/-0, untracked\)/); + assert.match(markdown, /added: `"src\/new-feature\.js"` \(\+1\/-0, untracked\)/); }); test('cli combines tracked and untracked files in deterministic path order', () => { @@ -208,7 +208,7 @@ test('cli preserves a staged rename that is edited again unstaged', () => { riskFlags: ['rename'], }, ); - assert.match(markdown, /renamed: src\/new\.js \(from src\/old\.js\) \(\+1\/-0, staged \+ unstaged\)/); + assert.match(markdown, /renamed: `"src\/new\.js"` \(from `"src\/old\.js"`\) \(\+1\/-0, staged \+ unstaged\)/); assert.match(markdown, /Risk flags: rename/); }); @@ -259,8 +259,31 @@ test('cli preserves staged and unstaged tracked paths containing tabs and newlin source: 'unstaged', stats: { added: 1, deleted: 2, binary: false }, }); - assert.ok(markdown.includes(stagedPath)); - assert.ok(markdown.includes(unstagedPath)); + assert.ok(markdown.includes('`"src/staged\\tname.js"`')); + assert.ok(markdown.includes('`"src/unstaged\\nname.js"`')); + assert.equal(markdown.split('\n').filter((line) => line.startsWith('- modified:')).length, 2); +}); + +test('markdown renders unusual tracked and untracked paths as single deterministic list items', () => { + const trackedPath = 'src/tracked\n- injected.md'; + const untrackedPath = 'docs/`guide`\t# heading [draft].md'; + const repo = createRepo({ initialFiles: { [trackedPath]: 'before\n' } }); + writeFileSync(join(repo, trackedPath), 'after\n'); + mkdirSync(join(repo, 'docs')); + writeFileSync(join(repo, untrackedPath), 'new\n'); + + const first = runCli(repo); + const second = runCli(repo); + + assert.deepEqual( + first.json.commits.flatMap((commit) => commit.files.map((file) => file.path)).sort(), + [trackedPath, untrackedPath].sort(), + ); + assert.equal(first.markdown, second.markdown); + assert.ok(first.markdown.includes('``"docs/`guide`\\t# heading [draft].md"``')); + assert.ok(first.markdown.includes('`"src/tracked\\n- injected.md"`')); + assert.equal(first.markdown.split('\n').filter((line) => /^- (added|modified):/.test(line)).length, 2); + assert.equal(first.markdown.split('\n').filter((line) => /^# heading|^- injected/.test(line)).length, 0); }); test('cli prints version without requiring a git repo', () => {