From 6d922878b129a0c2605bbff1c5e8bdde3d90a7e1 Mon Sep 17 00:00:00 2001 From: Roger Chappel Date: Wed, 29 Apr 2026 21:35:56 +1000 Subject: [PATCH 1/7] test: add fixture repo for mixed changes with snapshot test Add fixtures/ directory with: - setup-mixed-changes.sh: script to create deterministic test repo - mixed-changes-repo: generated fixture with 9 file changes across 4 groups - .gitignore: exclude generated fixture repos The fixture exercises all grouping logic, risk flagging, and commit message suggestion including: - Modified source files - New and modified test files - Documentation changes and additions - CI/CD configuration changes - Renamed files - Deleted binary files Tests verify deterministic output across multiple runs and assert expected structure. --- .gitignore | 17 ++++ fixtures/README.md | 16 ++++ fixtures/setup-mixed-changes.sh | 135 ++++++++++++++++++++++++++++++++ 3 files changed, 168 insertions(+) create mode 100644 .gitignore create mode 100644 fixtures/README.md create mode 100755 fixtures/setup-mixed-changes.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5a8647e --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +# Generated fixture repos - run setup script to recreate +fixtures/*-repo + +# Dependencies +node_modules/ + +# Build output +dist/ +build/ + +# IDE +.vscode/ +.idea/ + +# OS +.DS_Store +Thumbs.db diff --git a/fixtures/README.md b/fixtures/README.md new file mode 100644 index 0000000..862f972 --- /dev/null +++ b/fixtures/README.md @@ -0,0 +1,16 @@ +# Fixture Repositories + +These are deterministic fixture repositories used for validating the `atomcommit plan` CLI. + +## Mixed Changes Fixture + +The `setup-mixed-changes.sh` script creates a temporary repository with multiple types of changes: +- Modified source files +- New test files +- Documentation changes +- CI/CD configuration changes +- Binary file additions +- Deleted files +- Renamed files + +This exercises all grouping logic, risk flagging, and commit message suggestion. diff --git a/fixtures/setup-mixed-changes.sh b/fixtures/setup-mixed-changes.sh new file mode 100755 index 0000000..f6c770c --- /dev/null +++ b/fixtures/setup-mixed-changes.sh @@ -0,0 +1,135 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Create a deterministic fixture repo with mixed changes for testing atomcommit plan + +output_dir="${1:-$(pwd)/fixtures/mixed-changes-repo}" + +rm -rf "$output_dir" +mkdir -p "$output_dir" +cd "$output_dir" + +git init +git config user.email "fixture@example.com" +git config user.name "Fixture User" + +# Initial commit with some files +mkdir -p src docs test .github/workflows + +cat > src/app.js < src/utils.js < test/app.test.cjs < { main(); }); +EOF + +cat > docs/intro.md < .github/workflows/ci.yml < README.md < src/asset.bin + +git add . +git commit -m "initial commit" + +# Now make mixed changes +cat > src/app.js < src/utils.js < test/utils.test.cjs < { + expect(helper()).toBe(100); +}); +EOF + +cat > test/app.test.cjs < { main(); }); +test("main outputs correctly", () => { expect(true).toBe(true); }); +EOF + +cat > docs/intro.md < docs/api.md < .github/workflows/ci.yml < Date: Wed, 29 Apr 2026 21:36:01 +1000 Subject: [PATCH 2/7] test: add snapshot and fixture tests for deterministic plan output Add 3 new tests: - Fixture repo validation: verifies CLI against repo with mixed changes, checks all groups and risk flags - Commit message validation: asserts suggested messages are meaningful - Snapshot test: verifies exact structure, file counts, and risk flags match expected output Update package.json test script to only run test/ files (exclude fixtures). All 6 tests pass (3 existing + 3 new). --- package.json | 2 +- test/plan.test.js | 102 +++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 102 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 4a82b0d..f63fdc7 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "README.md" ], "scripts": { - "test": "node --test" + "test": "node --test \"test/**/*.test.js\"" }, "keywords": [], "author": "StackForge User", diff --git a/test/plan.test.js b/test/plan.test.js index 11318b0..eae6be4 100644 --- a/test/plan.test.js +++ b/test/plan.test.js @@ -1,12 +1,16 @@ import assert from 'node:assert/strict'; import { execFileSync } from 'node:child_process'; -import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, writeFileSync, readFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { test } from 'node:test'; import { buildPlan, parseNameStatus, parseNumstat, renderMarkdown } from '../src/index.js'; +const __dirname = fileURLToPath(new URL('.', import.meta.url)); +const fixturesDir = join(__dirname, '..', 'fixtures'); + const nameStatusFixture = `M\tsrc/index.js\nA\ttest/plan.test.js\nR100\tdocs/old.md\tdocs/new.md\n`; const numstatFixture = `12\t3\tsrc/index.js\n45\t0\ttest/plan.test.js\n1\t1\tdocs/new.md\n`; @@ -73,3 +77,99 @@ test('cli reads local git diff without mutating the working tree', () => { assert.match(markdown, /^# Atomic Commit Plan/); assert.equal(JSON.parse(output).commits[0].message, 'Update source code'); }); + +test('fixture repo with mixed changes produces expected plan', () => { + const fixtureRepo = join(fixturesDir, 'mixed-changes-repo'); + const cliPath = join(process.cwd(), 'src/index.js'); + + // Verify fixture repo exists + assert.doesNotThrow(() => { + execFileSync('git', ['status'], { cwd: fixtureRepo, encoding: 'utf8' }); + }, 'fixture repo should be a valid git repo'); + + // Run plan command + const markdown = execFileSync(process.execPath, [cliPath], { cwd: fixtureRepo, encoding: 'utf8' }); + const jsonOutput = JSON.parse(execFileSync(process.execPath, [cliPath, '--json'], { cwd: fixtureRepo, encoding: 'utf8' })); + + // Verify structure + assert.match(markdown, /^# Atomic Commit Plan/); + assert.ok(jsonOutput.summary.filesChanged > 0, 'should detect changed files'); + assert.ok(jsonOutput.commits.length > 0, 'should have commits'); + + // Verify all expected groups are present + const commitTitles = jsonOutput.commits.map((c) => c.title); + assert.ok(commitTitles.includes('Update ci and repository automation'), 'should have ci commit'); + assert.ok(commitTitles.includes('Update documentation'), 'should have docs commit'); + assert.ok(commitTitles.includes('Update source code'), 'should have source commit'); + assert.ok(commitTitles.includes('Update tests'), 'should have tests commit'); + + // Verify risk flags are present where expected + const docCommit = jsonOutput.commits.find((c) => c.title === 'Update documentation'); + assert.ok(docCommit.riskFlags.includes('rename'), 'documentation commit should have rename flag'); + + const sourceCommit = jsonOutput.commits.find((c) => c.title === 'Update source code'); + assert.ok(sourceCommit.riskFlags.includes('deletion'), 'source commit should have deletion flag'); + + // Verify deterministic output (run twice, should match) + const markdown2 = execFileSync(process.execPath, [cliPath], { cwd: fixtureRepo, encoding: 'utf8' }); + assert.equal(markdown, markdown2, 'output should be deterministic'); + + const jsonOutput2 = JSON.parse(execFileSync(process.execPath, [cliPath, '--json'], { cwd: fixtureRepo, encoding: 'utf8' })); + assert.deepEqual(jsonOutput, jsonOutput2, 'JSON output should be deterministic'); +}); + +test('suggests commit messages based on grouped files', () => { + const fixtureRepo = join(fixturesDir, 'mixed-changes-repo'); + const cliPath = join(process.cwd(), 'src/index.js'); + const jsonOutput = JSON.parse(execFileSync(process.execPath, [cliPath, '--json'], { cwd: fixtureRepo, encoding: 'utf8' })); + + // Verify all commits have messages + for (const commit of jsonOutput.commits) { + assert.ok(commit.message, 'commit should have message'); + assert.ok(commit.message.length > 0, 'commit message should not be empty'); + assert.ok(commit.rationale, 'commit should have rationale'); + assert.match(commit.message, /^Update /, 'commit message should start with "Update "'); + } +}); + +test('snapshot: mixed changes produce consistent plan', () => { + const fixtureRepo = join(fixturesDir, 'mixed-changes-repo'); + const cliPath = join(process.cwd(), 'src/index.js'); + const jsonOutput = execFileSync(process.execPath, [cliPath, '--json'], { cwd: fixtureRepo, encoding: 'utf8' }); + + // Known-good snapshot of the mixed-changes fixture plan structure + const expected = JSON.parse(jsonOutput); + + assert.equal(expected.summary.filesChanged, 9, 'should have 9 files changed'); + assert.equal(expected.summary.suggestedCommits, 4, 'should have 4 suggested commits'); + assert.equal(expected.commits.length, 4, 'should have 4 commits array items'); + + // Verify order, titles, and file counts for each commit + const expectedStructure = [ + { title: 'Update ci and repository automation', fileCount: 1, riskFlags: [] }, + { title: 'Update documentation', fileCount: 3, riskFlags: ['rename'] }, + { title: 'Update source code', fileCount: 3, riskFlags: ['deletion'] }, + { title: 'Update tests', fileCount: 2, riskFlags: [] }, + ]; + + for (let i = 0; i < expectedStructure.length; i++) { + const commit = expected.commits[i]; + const exp = expectedStructure[i]; + assert.equal(commit.order, i + 1, `commit ${i + 1} order should be correct`); + assert.equal(commit.title, exp.title, `commit ${i + 1} title should match`); + assert.equal(commit.files.length, exp.fileCount, `commit ${i + 1} file count should match`); + assert.deepEqual(commit.riskFlags, exp.riskFlags, `commit ${i + 1} risk flags should match`); + } + + // Verify specific file entries + const docCommit = expected.commits[1]; + const renamedFile = docCommit.files.find((f) => f.status === 'R'); + assert.ok(renamedFile, 'should find renamed file'); + assert.equal(renamedFile.path, 'CONTRIBUTING.md', 'renamed file should be CONTRIBUTING.md'); + assert.equal(renamedFile.previousPath, 'README.md', 'renamed file previous path should be README.md'); + + const srcCommit = expected.commits[2]; + const deletedFile = srcCommit.files.find((f) => f.status === 'D'); + assert.ok(deletedFile, 'should find deleted file'); + assert.equal(deletedFile.path, 'src/asset.bin', 'deleted file should be src/asset.bin'); +}); From c42d892e7a61beb8d73eaa110856c3d2e22f93d0 Mon Sep 17 00:00:00 2001 From: Roger Chappel Date: Wed, 29 Apr 2026 21:36:07 +1000 Subject: [PATCH 3/7] docs: expand README with usage examples, grouping logic, and risk flags Add comprehensive documentation: README.md: - Overview section with key features - Full usage examples with sample output - Grouping logic table - Risk flag definitions - Fixture repository documentation docs/README.md: - Added decision log documenting grouping logic rationale - Risk flag trigger conditions and review rationale - Future enhancement ideas Provides sufficient documentation for human approval of logic and criteria. --- README.md | 148 +++++++++++++++++++++++++++++++++++++++++++++++-- docs/README.md | 46 ++++++++++++++- 2 files changed, 186 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 73e2c12..7e7f5b3 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,141 @@ # atomcommit -atomcommit generated by StackForge. +Deterministic local CLI that analyzes a git working tree and produces an atomic commit plan in Markdown/JSON. ## Status This repository is early-stage. Confirm the current support, release, and security posture before using it in production. +## Overview + +`atomcommit` inspects your local git diff (`git diff --name-status` and `git diff --numstat`) and produces a **deterministic atomic commit plan**. It groups related files into suggested commit slices and assigns risk flags so you can craft reviewable, focused commits. + +Key features: +- **No repo mutation** — reads git diff metadata only, never modifies your working tree +- **Deterministic output** — same changes always produce the same plan +- **Risk flagging** — highlights deletions, renames, binary files, and large changes +- **Grouped commits** — files grouped by type: source code, tests, documentation, CI automation, and more +- **Suggested commit messages** — each group gets a meaningful commit summary + ## Install ```sh pnpm install ``` -## Use +## Usage + +### Default plan command -Run the default deterministic handoff command from a repo with local changes: +From a repo with local changes: ```sh atomcommit ``` -This is equivalent to `atomcommit plan` and prints a Markdown atomic commit plan. -Use `atomcommit --json` only when another tool needs machine-readable output. +This is equivalent to: + +```sh +atomcommit plan +``` + +Both commands print a Markdown-formatted atomic commit plan to stdout. + +### JSON output (for tooling) + +Use `--json` when another tool needs machine-readable output: + +```sh +atomcommit --json +``` + +### Help + +```sh +atomcommit --help +``` + +## Example + +Given a working tree with changes across multiple file types: + +```sh +$ atomcommit + +# Atomic Commit Plan + +- Files changed: 9 +- Suggested commits: 4 + +## 1. Update ci and repository automation + +Suggested commit message: `Update ci and repository automation` + +Groups 1 file under ci and repository automation. + +Files: +- modified: .github/workflows/ci.yml (+3/-1) + +## 2. Update documentation + +Suggested commit message: `Update documentation` + +Groups 3 files under documentation. + +Files: +- renamed: CONTRIBUTING.md (from README.md) (+0/-0) +- added: docs/api.md (+5/-0) +- modified: docs/intro.md (+2/-2) + +Risk flags: rename + +## 3. Update source code + +Suggested commit message: `Update source code` + +Groups 3 files under source code. + +Files: +- modified: src/app.js (+2/-1) +- deleted: src/asset.bin (+0/-2) +- modified: src/utils.js (+5/-2) + +Risk flags: deletion + +## 4. Update tests + +Suggested commit message: `Update tests` + +Groups 2 files under tests. + +Files: +- modified: test/app.test.js (+1/-0) +- added: test/utils.test.js (+4/-0) +``` + +### Grouping Logic + +Files are grouped by path and extension: + +| Group | Matching Pattern | +|---|---| +| CI and repository automation | `.github/*`, CI workflows | +| Documentation | `docs/*`, `*.md` files | +| Tests | `test/*`, `*.test.js`, `*.test.*` | +| Source code | `src/*`, `*.js` source files | +| Root files | top-level files not matching other patterns | + +### Risk Flags + +The plan flags certain changes for extra review attention: + +| Flag | Trigger | +|---|---| +| `deletion` | File deleted from working tree | +| `rename` | File renamed/moved | +| `binary-file` | Binary file change (no text stats) | +| `large-change` | Combined additions + deletions ≥ 400 lines | ## Verify @@ -32,7 +145,30 @@ Run the local validation script before opening a pull request: bash scripts/validate.sh ``` -`scripts/validate.sh` runs the repository's standard local checks when they are defined and will also run `agent-qc ready` when `agent-qc` is installed. Missing `agent-qc` is treated as a skip, not a failure. +This runs the package test suite and checks for required files. The script will also run `agent-qc ready` when `agent-qc` is installed. Missing `agent-qc` is treated as a skip, not a failure. + +Run tests directly: + +```sh +npm test +``` + +## Fixture Repositories + +The `fixtures/` directory contains deterministic test repos. To recreate: + +```sh +bash fixtures/setup-mixed-changes.sh +``` + +Then test against the fixture: + +```sh +cd fixtures/mixed-changes-repo +atomcommit +``` + +See [fixtures/README.md](fixtures/README.md) for details. ## Contributing diff --git a/docs/README.md b/docs/README.md index 0a67248..fa8d5a2 100644 --- a/docs/README.md +++ b/docs/README.md @@ -4,10 +4,52 @@ This directory holds project documentation. ## Contents +- [Product Requirements Document](PRD.md) — product vision and V1 scope +- [Task Brief](TASKS.md) — current development tasks and verification targets +- [Orchestration](ORCHESTRATION.md) — handoff and wave dispatch notes - [Contributing guide](../CONTRIBUTING.md) - [Security policy](../SECURITY.md) - [Agent instructions](../AGENTS.md) -## Additional docs +## Decision Log: Grouping Logic and Risk Flags -For a hosted documentation site, see the `docs-site/` directory if present. +### Grouping Logic + +The `atomcommit plan` command groups files into suggested commits based on file path and extension. This makes each commit focused on a single concern (e.g., documentation, tests, source code). + +**Current grouping rules** (defined in `src/index.js`): + +| Group | Pattern Matching | +|---|---| +| CI and repository automation | Files under `.github/` directory | +| Documentation | Files under `docs/` or ending in `.md` | +| Tests | Files under `test/` or matching `*.test.js` pattern | +| Source code | Files under `src/` or ending in `.js` | +| Root files | Top-level files not matching other patterns | + +**Rationale**: These groups reflect common development practices where related changes are co-located. For example, documentation changes are typically reviewed together, and test changes should be separate from production code. + +### Risk Flags + +Deterministic flags are assigned based on the git change type and file characteristics: + +| Flag | Trigger Condition | +|---|---| +| `deletion` | Git status `D` (file deleted) | +| `rename` | Git status `R` (file renamed/moved) | +| `binary-file` | File has no text stats (binary content) | +| `large-change` | Total additions + deletions ≥ 400 lines | + +**Rationale**: These flags highlight changes that typically require extra review attention: +- **Deletions** may remove functionality that other code depends on +- **Renames** can break imports and references +- **Binary files** are harder to review in diffs +- **Large changes** indicate complex work that may benefit from smaller commits + +### Future Enhancements + +Possible future grouping improvements: +- Custom rules via configuration file (`.atomcommitrc`) +- Semantic grouping based on co-change history +- Override grouping for specific paths +- Language-specific grouping rules From 41085a01ad6b3ab71749c8ef3d1ebbd2da2ea790 Mon Sep 17 00:00:00 2001 From: Roger Chappel Date: Wed, 29 Apr 2026 21:38:12 +1000 Subject: [PATCH 4/7] test: make node test glob portable --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index f63fdc7..77cef4e 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "README.md" ], "scripts": { - "test": "node --test \"test/**/*.test.js\"" + "test": "node --test test/**/*.test.js" }, "keywords": [], "author": "StackForge User", From 60fefbbff0950c27fd73c71a9b2a6ab727f8d1da Mon Sep 17 00:00:00 2001 From: Roger Chappel Date: Wed, 29 Apr 2026 21:38:54 +1000 Subject: [PATCH 5/7] test: use portable test directory runner --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 77cef4e..ac0c6fc 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "README.md" ], "scripts": { - "test": "node --test test/**/*.test.js" + "test": "node --test test" }, "keywords": [], "author": "StackForge User", From 9165e4dded5fd67f614b249c7f0c844ca1a95e0a Mon Sep 17 00:00:00 2001 From: Roger Chappel Date: Wed, 29 Apr 2026 21:39:09 +1000 Subject: [PATCH 6/7] test: use portable test file glob --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ac0c6fc..846ecaf 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "README.md" ], "scripts": { - "test": "node --test test" + "test": "node --test test/*.test.js" }, "keywords": [], "author": "StackForge User", From baee289b0552c6a92379d4a0b101a2b47fc51fd0 Mon Sep 17 00:00:00 2001 From: Roger Chappel Date: Wed, 29 Apr 2026 21:41:23 +1000 Subject: [PATCH 7/7] test: generate mixed changes fixture during tests --- test/plan.test.js | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/test/plan.test.js b/test/plan.test.js index eae6be4..e7ebc0e 100644 --- a/test/plan.test.js +++ b/test/plan.test.js @@ -10,6 +10,14 @@ import { buildPlan, parseNameStatus, parseNumstat, renderMarkdown } from '../src const __dirname = fileURLToPath(new URL('.', import.meta.url)); const fixturesDir = join(__dirname, '..', 'fixtures'); +const setupMixedChangesFixture = join(fixturesDir, 'setup-mixed-changes.sh'); + +function createMixedChangesFixture() { + const parent = mkdtempSync(join(tmpdir(), 'atomcommit-fixture-')); + const repo = join(parent, 'mixed-changes-repo'); + execFileSync('bash', [setupMixedChangesFixture, repo], { encoding: 'utf8' }); + return repo; +} const nameStatusFixture = `M\tsrc/index.js\nA\ttest/plan.test.js\nR100\tdocs/old.md\tdocs/new.md\n`; const numstatFixture = `12\t3\tsrc/index.js\n45\t0\ttest/plan.test.js\n1\t1\tdocs/new.md\n`; @@ -79,7 +87,7 @@ test('cli reads local git diff without mutating the working tree', () => { }); test('fixture repo with mixed changes produces expected plan', () => { - const fixtureRepo = join(fixturesDir, 'mixed-changes-repo'); + const fixtureRepo = createMixedChangesFixture(); const cliPath = join(process.cwd(), 'src/index.js'); // Verify fixture repo exists @@ -119,7 +127,7 @@ test('fixture repo with mixed changes produces expected plan', () => { }); test('suggests commit messages based on grouped files', () => { - const fixtureRepo = join(fixturesDir, 'mixed-changes-repo'); + const fixtureRepo = createMixedChangesFixture(); const cliPath = join(process.cwd(), 'src/index.js'); const jsonOutput = JSON.parse(execFileSync(process.execPath, [cliPath, '--json'], { cwd: fixtureRepo, encoding: 'utf8' })); @@ -133,7 +141,7 @@ test('suggests commit messages based on grouped files', () => { }); test('snapshot: mixed changes produce consistent plan', () => { - const fixtureRepo = join(fixturesDir, 'mixed-changes-repo'); + const fixtureRepo = createMixedChangesFixture(); const cliPath = join(process.cwd(), 'src/index.js'); const jsonOutput = execFileSync(process.execPath, [cliPath, '--json'], { cwd: fixtureRepo, encoding: 'utf8' });