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.

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
Expand Down
17 changes: 11 additions & 6 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -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',
});
}
Expand All @@ -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;
Expand All @@ -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'}(-)`);
Expand Down
40 changes: 40 additions & 0 deletions test/plan.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down