From 338d0e3e4b4f72f209feae364f3ba3804ce72209 Mon Sep 17 00:00:00 2001 From: Roger Chappel Date: Mon, 27 Jul 2026 15:11:40 +1000 Subject: [PATCH 1/3] test: cover staged rename with unstaged edits --- test/plan.test.js | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/test/plan.test.js b/test/plan.test.js index 2d1be2f..502c97c 100644 --- a/test/plan.test.js +++ b/test/plan.test.js @@ -147,6 +147,46 @@ test('cli combines tracked and untracked files in deterministic path order', () ); }); +test('cli preserves a staged rename that is edited again unstaged', () => { + const repo = createRepo({ initialFiles: { 'src/old.js': 'export const value = 1;\n' } }); + execFileSync('git', ['mv', 'src/old.js', 'src/new.js'], { cwd: repo }); + execFileSync('git', ['add', '-A'], { cwd: repo }); + writeFileSync(join(repo, 'src/new.js'), 'export const value = 1;\nexport const next = 2;\n'); + + const { markdown, json } = runCli(repo); + const file = json.commits[0].files[0]; + + assert.deepEqual(json.summary, { + filesChanged: 1, + suggestedCommits: 1, + diffStat: '1 file changed, 1 insertion(+)', + }); + assert.deepEqual( + { + path: file.path, + previousPath: file.previousPath, + status: file.status, + statusDetail: file.statusDetail, + statusLabel: file.statusLabel, + source: file.source, + stats: file.stats, + riskFlags: file.riskFlags, + }, + { + path: 'src/new.js', + previousPath: 'src/old.js', + status: 'R', + statusDetail: 'R100', + statusLabel: 'renamed', + source: 'staged+unstaged', + stats: { added: 1, deleted: 0, binary: false }, + riskFlags: ['rename'], + }, + ); + assert.match(markdown, /renamed: src\/new\.js \(from src\/old\.js\) \(\+1\/-0, staged \+ unstaged\)/); + assert.match(markdown, /Risk flags: rename/); +}); + test('cli excludes ignored untracked files', () => { const repo = createRepo({ initialFiles: { '.gitignore': '*.log\n' } }); writeFileSync(join(repo, 'debug.log'), 'ignored\n'); From 3a8510ee72898216e6c7d4628525f48a0d4f302d Mon Sep 17 00:00:00 2001 From: Roger Chappel Date: Mon, 27 Jul 2026 15:12:06 +1000 Subject: [PATCH 2/3] fix: retain rename identity across diff sources --- src/index.js | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/index.js b/src/index.js index 8e25cbb..3d6c575 100755 --- a/src/index.js +++ b/src/index.js @@ -188,7 +188,7 @@ export function collectGitDiff(cwd = process.cwd()) { parseDiffStat(runGit(['diff', '--cached', '--stat'], cwd)), parseDiffStat(runGit(['diff', '--stat'], cwd)), untrackedDiffStat(untrackedStats), - ]); + ], changes.length); return buildPlan(changes, stats, diffStat); } @@ -224,10 +224,14 @@ function mergeChanges(changes) { byPath.set(change.path, change); continue; } + const identity = existing.status === 'R' || existing.status === 'C' ? existing : change; byPath.set(change.path, { - ...existing, ...change, - previousPath: existing.previousPath ?? change.previousPath, + status: identity.status, + statusDetail: identity.statusDetail, + statusLabel: identity.statusLabel, + score: identity.score, + previousPath: identity.previousPath ?? existing.previousPath ?? change.previousPath, source: existing.source === change.source ? change.source : 'staged+unstaged', }); } @@ -249,17 +253,17 @@ function mergeStats(statMaps) { return merged; } -function mergeDiffStats(stats) { +function mergeDiffStats(stats, uniqueFiles) { const nonEmpty = stats.filter((stat) => stat.raw.length > 0); if (nonEmpty.length === 0) return { raw: '', summary: '0 files changed', files: [] }; return { raw: nonEmpty.map((stat) => stat.raw).join('\n'), - summary: summarizeStats(nonEmpty), + summary: summarizeStats(nonEmpty, uniqueFiles), files: nonEmpty.flatMap((stat) => stat.files), }; } -function summarizeStats(stats) { +function summarizeStats(stats, uniqueFiles) { let files = 0; let insertions = 0; let deletions = 0; @@ -271,6 +275,7 @@ function summarizeStats(stats) { deletions += Number(summary.match(/(\d+) deletions?/)?.[1] ?? 0); } + files = uniqueFiles ?? files; const parts = [`${files} ${files === 1 ? 'file' : 'files'} changed`]; if (insertions > 0) parts.push(`${insertions} ${insertions === 1 ? 'insertion' : 'insertions'}(+)`); if (deletions > 0) parts.push(`${deletions} ${deletions === 1 ? 'deletion' : 'deletions'}(-)`); From b56d259756de809b92b11f279a60c0a6e7110750 Mon Sep 17 00:00:00 2001 From: Roger Chappel Date: Mon, 27 Jul 2026 15:12:23 +1000 Subject: [PATCH 3/3] docs: explain staged and unstaged rename plans --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index f8c5456..ba931de 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. +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. ## Fixture smoke