From 05ec4e1b54fdf23b47159a43e5208e1015a3175b Mon Sep 17 00:00:00 2001 From: Roger Chappel Date: Thu, 13 Aug 2026 09:09:17 +1000 Subject: [PATCH 1/3] test: cover Markdown-safe unusual paths --- test/plan.test.js | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/test/plan.test.js b/test/plan.test.js index 838a062..7c10747 100644 --- a/test/plan.test.js +++ b/test/plan.test.js @@ -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', () => { From a8ad8234766bdf306e3b317277a0e0a80dfb0c1f Mon Sep 17 00:00:00 2001 From: Roger Chappel Date: Thu, 13 Aug 2026 09:09:49 +1000 Subject: [PATCH 2/3] fix: escape paths in Markdown plans --- src/index.js | 12 ++++++++++-- test/plan.test.js | 4 ++-- 2 files changed, 12 insertions(+), 4 deletions(-) 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 7c10747..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/); }); From 119f294a8ea2069dc062e391dd80f5733b841cdf Mon Sep 17 00:00:00 2001 From: Roger Chappel Date: Thu, 13 Aug 2026 09:10:03 +1000 Subject: [PATCH 3/3] docs: explain Markdown path escaping --- README.md | 2 ++ 1 file changed, 2 insertions(+) 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.