Skip to content

Atomic Commit Gate — Design & Rule Rationale

Implements: ADR-0014 — One-Feature-Per-Commit Policy Source: tools/check_atomic_commit.py Hook: .git/hooks/commit-msg (installed by tools/install_hooks.sh)

Why Enforce Atomic Commits?

"Atomic" does not mean small — it means coherent. Every file changed in the commit should serve one explicitly stated purpose. The discipline pays dividends in three concrete ways:

  1. git bisect reliability — bisect can pin a regression to one commit only when each commit has a single identifiable purpose.
  2. Focused code review — reviewers can evaluate a change without mentally separating two independent concerns.
  3. Safe revert surgery — reverting a bug fix does not accidentally revert an unrelated refactor that was bundled into the same commit.

Rule Set (Applied in Order)

Rule 0 — Bypass ([skip-atomic])

If the subject line (first line only) contains [skip-atomic], all checks are skipped and the bypass is logged to .atomic-commit-bypasses.log.

Why subject-line only? The body may legitimately reference [skip-atomic] when explaining why a bypass was used. Checking only the subject prevents false-positive bypasses triggered by explanatory text.

Why log bypasses? The escape hatch is intentionally low-friction but must remain auditable. Teams can review .atomic-commit-bypasses.log in retrospectives and tighten the policy when patterns emerge.


Rule 1 — Conventional Commits Format

The subject line must match:

<type>[(<scope>)][!]: <description>

Valid types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert, sec.

Why Conventional Commits? A structured subject makes the commit machine-readable. CI tools, changelog generators, and the bundled-concern detector (Rule 2) all depend on this structure. Without it, mechanical reasoning about commit intent is impossible.

Implementation — _CC_RE:

_CC_RE = re.compile(
    r"^(?P<type>[a-z]+)(?:\((?P<scope>[^)]+)\))?(?P<breaking>!)?\s*:\s*(?P<desc>.+)$"
)

Named groups (type, scope, breaking, desc) are extracted for reuse in later rules without re-parsing the subject.


Rule 2 — No Bundled Concerns in Body

The commit body must not contain lines that begin with a conventional-commit prefix (e.g., fix: resolve import error or feat(auth): add token refresh).

Why this is the strongest signal: When a developer mentally writes two commit messages and concatenates them, the result almost always has two conventional-commit-style headers — one in the subject, one somewhere in the body. This pattern is unambiguous and mechanically detectable.

Implementation — _BUNDLED_RE:

_BUNDLED_RE = re.compile(
    r"^(?:feat|fix|docs|...|sec)(?:\([^)]+\))?!?\s*:",
    re.MULTILINE,
)

re.MULTILINE makes ^ match at the start of every line, not just the start of the string, so the regex correctly detects the pattern anywhere in the body.


Rule 3 — "and" Heuristic (Warning Only, Non-Blocking)

If the description contains action verbs connected by "and" (e.g., "add validation and fix serialisation bug"), a warning is printed but the commit is not blocked.

Why non-blocking? Many valid descriptions use "and" to describe a single cohesive change (e.g., "add endpoint and update OpenAPI spec"). Making this rule blocking would generate false positives at an unacceptable rate. A warning prompts developers to reflect without imposing a hard stop.


Input Channels

The CLI accepts the commit message via three channels (in priority order):

Priority Channel Use case
1 --message / -m flag Manual testing
2 commit_msg_file positional arg Git hook ("$1")
3 Standard input (non-TTY) Pipe-based CI testing

This design allows the same binary to serve as both a git hook and a standalone CLI tool without code duplication.


Audit Log Format

Each bypass appends one line to .atomic-commit-bypasses.log:

2026-03-26T14:32:01+00:00 | reason=[skip-atomic] | subject='feat: scaffold [skip-atomic]'

Fields: ISO-8601 UTC timestamp, reason label, subject (truncated to 120 chars). The log is version-controlled so bypasses are visible in git log.


Extending the Rule Set

To add a new rule:

  1. Implement a check inside run_gate after the existing rules.
  2. Print a [FAIL] message with a clear fix instruction and return 1.
  3. Update the module docstring rule list.
  4. Add a test case to tests/test_check_atomic_commit.py.
  5. Update ADR-0014 if the new rule changes the policy semantics.