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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
12 changes: 10 additions & 2 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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) {
Expand Down
31 changes: 27 additions & 4 deletions test/plan.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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/);
});

Expand Down Expand Up @@ -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', () => {
Expand Down