diff --git a/README.md b/README.md index 7a93586..f8c5456 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # atomcommit -`atomcommit` is a local-first CLI that turns staged and unstaged git diffs into deterministic atomic commit plans. +`atomcommit` is a local-first CLI that turns staged, unstaged, and ordinary untracked changes into deterministic atomic commit plans. ## Status @@ -50,12 +50,16 @@ atomcommit --help ## What it reads -`atomcommit` shells out to read-only git diff commands: +`atomcommit` shells out only to these read-only Git commands: - `git diff --name-status` - `git diff --cached --name-status` - `git diff --numstat` - `git diff --stat` +- `git ls-files --others --exclude-standard -z` +- `git diff --no-index --numstat -- /dev/null ` + +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. 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. diff --git a/src/index.js b/src/index.js index 23c9bed..8e25cbb 100755 --- a/src/index.js +++ b/src/index.js @@ -64,6 +64,13 @@ export function parseNumstat(output) { return byPath; } +export function parseUntrackedPaths(output) { + return output + .split('\0') + .filter(Boolean) + .sort((a, b) => a.localeCompare(b)); +} + export function parseDiffStat(output) { const lines = output.trim().split('\n').filter(Boolean); const summary = (lines.at(-1) ?? '0 files changed').trim(); @@ -160,19 +167,55 @@ export function renderMarkdown(plan) { export function collectGitDiff(cwd = process.cwd()) { const unstaged = parseNameStatus(runGit(['diff', '--name-status'], cwd), 'unstaged'); const staged = parseNameStatus(runGit(['diff', '--cached', '--name-status'], cwd), 'staged'); - const changes = mergeChanges([...staged, ...unstaged]); + const untrackedPaths = parseUntrackedPaths(runGit(['ls-files', '--others', '--exclude-standard', '-z'], cwd)); + const untracked = untrackedPaths.map((path) => ({ + path, + previousPath: null, + status: 'A', + statusDetail: 'A', + statusLabel: 'added', + score: null, + source: 'untracked', + })); + const untrackedStats = new Map(untrackedPaths.map((path) => [path, untrackedStat(path, cwd)])); + const changes = mergeChanges([...staged, ...unstaged, ...untracked]); const stats = mergeStats([ parseNumstat(runGit(['diff', '--cached', '--numstat'], cwd)), parseNumstat(runGit(['diff', '--numstat'], cwd)), + untrackedStats, ]); const diffStat = mergeDiffStats([ parseDiffStat(runGit(['diff', '--cached', '--stat'], cwd)), parseDiffStat(runGit(['diff', '--stat'], cwd)), + untrackedDiffStat(untrackedStats), ]); return buildPlan(changes, stats, diffStat); } +function untrackedStat(path, cwd) { + const output = runGit(['diff', '--no-index', '--numstat', '--', '/dev/null', path], cwd, [0, 1]); + const [added, deleted] = output.split('\t'); + const binary = added === '-' || deleted === '-'; + return { + added: binary ? null : Number(added), + deleted: binary ? null : Number(deleted), + binary, + }; +} + +function untrackedDiffStat(stats) { + if (stats.size === 0) return { raw: '', summary: '0 files changed', files: [] }; + let insertions = 0; + for (const stat of stats.values()) { + insertions += stat.added ?? 0; + } + const parts = [`${stats.size} ${stats.size === 1 ? 'file' : 'files'} changed`]; + if (insertions > 0) parts.push(`${insertions} ${insertions === 1 ? 'insertion' : 'insertions'}(+)`); + const summary = parts.join(', '); + return { raw: summary, summary, files: [] }; +} + function mergeChanges(changes) { const byPath = new Map(); for (const change of changes) { @@ -261,16 +304,16 @@ function commitMessageFor(group, files) { return `Update ${group}`; } -function runGit(args, cwd) { +function runGit(args, cwd, allowedStatuses = [0]) { const result = spawnSync('git', args, { cwd, encoding: 'utf8' }); - if (result.status !== 0) { + if (!allowedStatuses.includes(result.status)) { throw new Error(`git ${args.join(' ')} failed: ${result.stderr.trim()}`); } return result.stdout; } function printHelp() { - console.log(`Usage: atomcommit [plan] [--json]\n\nCommands:\n plan Analyze the local git diff and print an atomic commit plan.\n\nDefault:\n atomcommit is equivalent to atomcommit plan.\n\nOptions:\n --json Print machine-readable JSON instead of Markdown.\n -h, --help Show this help.\n -v, --version Print the CLI version.\n\nSafety:\n atomcommit only runs read-only git diff commands and never stages, commits, or modifies files.`); + console.log(`Usage: atomcommit [plan] [--json]\n\nCommands:\n plan Analyze local tracked and untracked changes and print an atomic commit plan.\n\nDefault:\n atomcommit is equivalent to atomcommit plan.\n\nOptions:\n --json Print machine-readable JSON instead of Markdown.\n -h, --help Show this help.\n -v, --version Print the CLI version.\n\nSafety:\n atomcommit only runs read-only git diff and git ls-files commands and never stages, commits, or modifies files.`); } export function main(argv = process.argv.slice(2), cwd = process.cwd()) { diff --git a/test/plan.test.js b/test/plan.test.js index 1598393..2d1be2f 100644 --- a/test/plan.test.js +++ b/test/plan.test.js @@ -6,7 +6,7 @@ import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { test } from 'node:test'; -import { buildPlan, parseDiffStat, parseNameStatus, parseNumstat, renderMarkdown } from '../src/index.js'; +import { buildPlan, parseDiffStat, parseNameStatus, parseNumstat, parseUntrackedPaths, renderMarkdown } from '../src/index.js'; const __dirname = fileURLToPath(new URL('.', import.meta.url)); const fixturesDir = join(__dirname, '..', 'fixtures'); @@ -65,6 +65,10 @@ test('parses git stat summaries for plan metadata', () => { assert.deepEqual(stat.files, ['src/index.js | 2 +-', 'test/plan.test.js | 4 ++++']); }); +test('parses NUL-delimited untracked paths deterministically', () => { + assert.deepEqual(parseUntrackedPaths('z.js\0docs/a [draft].md\0'), ['docs/a [draft].md', 'z.js']); +}); + test('builds deterministic grouped plan and markdown', () => { const plan = buildPlan(parseNameStatus(nameStatusFixture), parseNumstat(numstatFixture)); @@ -100,6 +104,70 @@ test('cli reads local git diff without mutating the working tree', () => { assert.equal(JSON.parse(output).commits[0].message, 'Update source code'); }); +test('cli plans an untracked-only file without mutating it', () => { + const repo = createRepo(); + mkdirSync(join(repo, 'src')); + writeFileSync(join(repo, 'src/new-feature.js'), 'export const feature = true;\n'); + + const beforeStatus = gitStatus(repo); + const { markdown, json } = runCli(repo); + + assert.equal(gitStatus(repo), beforeStatus); + assert.deepEqual(json.summary, { + filesChanged: 1, + suggestedCommits: 1, + diffStat: '1 file changed, 1 insertion(+)', + }); + assert.equal(json.commits[0].message, 'Add source code'); + assert.deepEqual(json.commits[0].files[0], { + path: 'src/new-feature.js', + previousPath: null, + status: 'A', + statusDetail: 'A', + statusLabel: 'added', + score: null, + source: 'untracked', + stats: { added: 1, deleted: 0, binary: false }, + group: 'source code', + riskFlags: [], + }); + assert.match(markdown, /added: src\/new-feature\.js \(\+1\/-0, untracked\)/); +}); + +test('cli combines tracked and untracked files in deterministic path order', () => { + const repo = createRepo({ initialFiles: { 'src/z.js': 'before\n' } }); + writeFileSync(join(repo, 'src/z.js'), 'after\n'); + writeFileSync(join(repo, 'src/a.js'), 'new\n'); + + const { json } = runCli(repo); + assert.equal(json.summary.filesChanged, 2); + assert.deepEqual( + json.commits[0].files.map(({ path, source }) => [path, source]), + [['src/a.js', 'untracked'], ['src/z.js', 'unstaged']], + ); +}); + +test('cli excludes ignored untracked files', () => { + const repo = createRepo({ initialFiles: { '.gitignore': '*.log\n' } }); + writeFileSync(join(repo, 'debug.log'), 'ignored\n'); + writeFileSync(join(repo, 'visible.txt'), 'included\n'); + + const { json } = runCli(repo); + assert.deepEqual(json.commits.flatMap((commit) => commit.files.map((file) => file.path)), ['visible.txt']); +}); + +test('cli preserves special characters in untracked paths', () => { + const repo = createRepo(); + const specialPath = 'src/new [draft] #1.js'; + mkdirSync(join(repo, 'src')); + writeFileSync(join(repo, specialPath), 'new\n'); + + const first = runCli(repo); + const second = runCli(repo); + assert.equal(first.json.commits[0].files[0].path, specialPath); + assert.deepEqual(first.json, second.json); +}); + test('cli prints version without requiring a git repo', () => { const cliPath = join(process.cwd(), 'src/index.js'); const output = execFileSync(process.execPath, [cliPath, '--version'], { cwd: tmpdir(), encoding: 'utf8' }); @@ -202,3 +270,30 @@ test('snapshot: mixed changes produce consistent plan', () => { assert.ok(deletedFile, 'should find deleted file'); assert.equal(deletedFile.path, 'src/asset.bin', 'deleted file should be src/asset.bin'); }); + +function createRepo({ initialFiles = {} } = {}) { + const repo = mkdtempSync(join(tmpdir(), 'atomcommit-untracked-')); + execFileSync('git', ['init'], { cwd: repo }); + execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: repo }); + execFileSync('git', ['config', 'user.name', 'Test User'], { cwd: repo }); + for (const [path, contents] of Object.entries(initialFiles)) { + mkdirSync(join(repo, path, '..'), { recursive: true }); + writeFileSync(join(repo, path), contents); + } + writeFileSync(join(repo, 'tracked.txt'), 'initial\n'); + execFileSync('git', ['add', '.'], { cwd: repo }); + execFileSync('git', ['commit', '-m', 'initial'], { cwd: repo }); + return repo; +} + +function runCli(repo) { + const cliPath = join(process.cwd(), 'src/index.js'); + return { + markdown: execFileSync(process.execPath, [cliPath], { cwd: repo, encoding: 'utf8' }), + json: JSON.parse(execFileSync(process.execPath, [cliPath, '--json'], { cwd: repo, encoding: 'utf8' })), + }; +} + +function gitStatus(repo) { + return execFileSync('git', ['status', '--short'], { cwd: repo, encoding: 'utf8' }); +}