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 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'}(-)`); 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');