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
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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 <untracked-path>`

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.

Expand Down
51 changes: 47 additions & 4 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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()) {
Expand Down
97 changes: 96 additions & 1 deletion test/plan.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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));

Expand Down Expand Up @@ -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' });
Expand Down Expand Up @@ -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' });
}