Make Atomic Git Commits
Core Principle
Each commit = one logical change, one intention. If you cannot describe the commit in one sentence without "and", split it.
- "Fix null pointer dereference in checkout flow" — passes
- "Fix cart quantity bug and update user model and add tests" — fails ("and") → three commits
Each commit must be independent (understandable alone), buildable (compiles, tests pass), and complete (no partial work).
Atomic ≠ tiny. A refactor touching 20 files can be one atomic commit if it does one thing. The test: can it be reverted independently?
Red Flags — Split Now
- Subject contains "and" or two commit types apply (e.g. feat + fix)
- You're tempted to write "misc", "various", "cleanup", or "updates"
- The body describes unrelated effects
- Change A can be reverted independently from change B
Revert-Boundary Gate
Before committing a large staged diff, list the independent revert boundaries in one-line phrases. If more than one phrase is required, split the commit — even if the changes are coupled or all tests only pass after the full refactor. "They are coupled" is not an atomicity argument; use transitional buildable commits or explain why no independent revert boundary exists.
Common Rationalizations
| Rationalization |
Reality |
| "Only a few lines" |
Size is irrelevant. Different intentions = different commits. |
| "Fixed it while working on X" |
Work context ≠ commit boundary. Different reason-to-exist = different commit. |
"Both are chore: type" |
Same type ≠ same intention. chore: update prettier ≠ chore: add dependency. |
| "Splitting is pedantic" |
Non-atomic commits break bisect, complicate reverts, confuse reviewers. |
| "Test file is too small" |
No minimum line count. Tests are test: type — always separate. |
| "They are deeply coupled" |
Coupling explains staging order, not commit boundaries. Separate intentions with separate revert boundaries = separate commits. Dependency between commits is normal. |
| "I'll just count the characters" |
Manual counting fails. Always. Use the length gate script — never eyeball it. |
Message Rules
Discover Convention First
Run git log --oneline -20 and match the repo's existing format exactly:
- Prefix format: Conventional types (
feat:, fix:)? Module names (auth:, ui:)? None?
- Capitalization/punctuation: Sentence case or lowercase? Trailing period?
- Scopes: If parenthesized scopes exist (
feat(auth):), use existing ones. Never invent scopes. Never use commit types as scopes (fix(perf): is wrong when perf is intended as a type).
No commit history? Pick one convention and apply consistently. Conventional Commits (type(scope): summary) or plain prefix (area: summary).
Subject Line
Max 72 characters. Always commit through the length gate script — never call git commit directly. Write the matching script below to an executable file and run it; it enforces the limit and is the only sanctioned commit command. Never count characters yourself — it does not work.
#!/usr/bin/env bash
# commit-length-gate.sh "subject" ["body paragraph" ...] — each arg after the subject becomes a -m body paragraph.
set -euo pipefail
[[ $# -ge 1 ]] || { echo "usage: $0 \"subject\" [\"body\"...]" >&2; exit 1; }
s=$1; shift
((${#s} <= 72)) || { echo "Subject too long: ${#s} chars (max 72): $s" >&2; exit 1; }
a=(-m "$s"); for b in "$@"; do a+=(-m "$b"); done
exec git commit "${a[@]}"
# commit-length-gate.ps1 "subject" ["body paragraph" ...] — each arg after the subject becomes a -m body paragraph.
param([Parameter(Mandatory, Position = 0)][string]$Subject, [Parameter(ValueFromRemainingArguments)][string[]]$Body)
if ($Subject.Length -gt 72) { throw "Subject too long: $($Subject.Length) chars (max 72)`n$Subject" }
$c = @('-m', $Subject) + @(foreach ($p in $Body) { '-m', $p })
git commit @c
Start with an imperative verb after any prefix: add, fix, extract, remove. Not feature, make, let's.
Formal technical language. No conversational constructions ("make X Y, not Z", "do X instead of Y").
Body
Prefer no body. Make the subject self-explanatory. Only add a body when the subject genuinely cannot carry the explanation.
Body explains WHY, not WHAT — the diff shows what changed. Write prose, never bullet points or checklists. A one-line why-statement is almost always sufficient; if you need more than 3 -m paragraphs, your commit isn't atomic.
| Body needed |
No body needed |
| Bug fix with non-obvious cause |
Typo, formatting, dead code removal |
| Refactor that changes structure (why?) |
Self-explanatory rename |
| Performance improvement (bottleneck? gain?) |
Single-line config or dependency bump |
| Breaking change or migration step |
Subject fully describes the change |
Forbidden in Messages
- File lists: Git tracks files. "3 files changed, 5 tests added" is noise.
- Plan-internal terminology: No task numbers ("Task 3"), wave/phase names, step labels. Commits must stand alone in
git log. Reference ticket IDs from issue trackers, not planning artifacts.
Commit Types
| Type |
Description |
| feat |
New user-facing feature |
| fix |
User-facing bug fix |
| docs |
Documentation or comments only |
| style |
Formatting, whitespace, linting — no logic change (never CSS/design) |
| refactor |
Code restructuring, no behavior change |
| perf |
Performance improvement |
| test |
Test-related changes (fixing tests = test:, not fix:) |
| chore |
Build process, dependencies, tooling |
| ci |
CI configuration and scripts |
When a change is both restructuring and performance, choose the emphasis.
Common Mistakes
| Mistake |
Fix |
| "Misc cleanup" commits |
Each cleanup item = its own commit |
| Generated files mixed with hand-written code |
Generated files go in a separate commit |
| Rename combined with semantic edits |
Rename first (using git mv), then edit in next commit |
| Rename split across commits (delete old + add new) |
Stage both in one commit — git needs old and new together for rename detection |
| One commit per file |
Commit per logical change, not per file |
| Sub-agent output left uncommitted |
Incorporate sub-agent changes and commit appropriately |
Calling git commit directly |
Always use the length gate script. Raw git commit bypasses the 72-char enforcement. |
Emergency Exception
During active P1 outages, land the minimal fix first. Clean up history in a follow-up commit.
1---2name: commit3description: Use when about to make git commits, when tempted to bundle unrelated changes behind a single message, or when commit history needs to support git bisect, revert, and cherry-pick workflows. Also use when reviewing a branch before creating a PR. If you are writing a plan, specify that the executor must load this skill before start working.4license: CC-BY-SA-4.05---67# Make Atomic Git Commits89## Core Principle1011Each commit = one logical change, one intention. If you cannot describe the commit in one sentence without "and", split it.1213- "Fix null pointer dereference in checkout flow" — passes14- "Fix cart quantity bug and update user model and add tests" — fails ("and") → three commits1516Each commit must be **independent** (understandable alone), **buildable** (compiles, tests pass), and **complete** (no partial work).1718Atomic ≠ tiny. A refactor touching 20 files can be one atomic commit if it does one thing. The test: can it be reverted independently?1920### Red Flags — Split Now2122- Subject contains "and" or two commit types apply (e.g. feat + fix)23- You're tempted to write "misc", "various", "cleanup", or "updates"24- The body describes unrelated effects25- Change A can be reverted independently from change B2627### Revert-Boundary Gate2829Before committing a large staged diff, list the independent revert boundaries in one-line phrases. If more than one phrase is required, split the commit — even if the changes are coupled or all tests only pass after the full refactor. "They are coupled" is not an atomicity argument; use transitional buildable commits or explain why no independent revert boundary exists.3031## Common Rationalizations3233| Rationalization | Reality |34|---|---|35| "Only a few lines" | Size is irrelevant. Different intentions = different commits. |36| "Fixed it while working on X" | Work context ≠ commit boundary. Different reason-to-exist = different commit. |37| "Both are `chore:` type" | Same type ≠ same intention. `chore: update prettier` ≠ `chore: add dependency`. |38| "Splitting is pedantic" | Non-atomic commits break bisect, complicate reverts, confuse reviewers. |39| "Test file is too small" | No minimum line count. Tests are `test:` type — always separate. |40| "They are deeply coupled" | Coupling explains staging order, not commit boundaries. Separate intentions with separate revert boundaries = separate commits. Dependency between commits is normal. |41| "I'll just count the characters" | Manual counting fails. Always. Use the length gate script — never eyeball it. |4243## Message Rules4445### Discover Convention First4647Run `git log --oneline -20` and match the repo's existing format exactly:48- **Prefix format**: Conventional types (`feat:`, `fix:`)? Module names (`auth:`, `ui:`)? None?49- **Capitalization/punctuation**: Sentence case or lowercase? Trailing period?50- **Scopes**: If parenthesized scopes exist (`feat(auth):`), use existing ones. Never invent scopes. Never use commit types as scopes (`fix(perf):` is wrong when `perf` is intended as a type).5152No commit history? Pick one convention and apply consistently. Conventional Commits (`type(scope): summary`) or plain prefix (`area: summary`).5354### Subject Line5556- Max 72 characters. **Always commit through the length gate script** — never call `git commit` directly. Write the matching script below to an executable file and run it; it enforces the limit and is the only sanctioned commit command. Never count characters yourself — it does not work.5758 ```bash59 #!/usr/bin/env bash60 # commit-length-gate.sh "subject" ["body paragraph" ...] — each arg after the subject becomes a -m body paragraph.61 set -euo pipefail62 [[ $# -ge 1 ]] || { echo "usage: $0 \"subject\" [\"body\"...]" >&2; exit 1; }63 s=$1; shift64 ((${#s} <= 72)) || { echo "Subject too long: ${#s} chars (max 72): $s" >&2; exit 1; }65 a=(-m "$s"); for b in "$@"; do a+=(-m "$b"); done66 exec git commit "${a[@]}"67 ```6869 ```powershell70 # commit-length-gate.ps1 "subject" ["body paragraph" ...] — each arg after the subject becomes a -m body paragraph.71 param([Parameter(Mandatory, Position = 0)][string]$Subject, [Parameter(ValueFromRemainingArguments)][string[]]$Body)72 if ($Subject.Length -gt 72) { throw "Subject too long: $($Subject.Length) chars (max 72)`n$Subject" }73 $c = @('-m', $Subject) + @(foreach ($p in $Body) { '-m', $p })74 git commit @c75 ```76- Start with an imperative verb after any prefix: `add`, `fix`, `extract`, `remove`. Not `feature`, `make`, `let's`.77- Formal technical language. No conversational constructions ("make X Y, not Z", "do X instead of Y").7879### Body8081**Prefer no body.** Make the subject self-explanatory. Only add a body when the subject genuinely cannot carry the explanation.8283Body explains **WHY**, not WHAT — the diff shows what changed. Write prose, never bullet points or checklists. A one-line why-statement is almost always sufficient; if you need more than 3 `-m` paragraphs, your commit isn't atomic.8485| Body needed | No body needed |86|---|---|87| Bug fix with non-obvious cause | Typo, formatting, dead code removal |88| Refactor that changes structure (why?) | Self-explanatory rename |89| Performance improvement (bottleneck? gain?) | Single-line config or dependency bump |90| Breaking change or migration step | Subject fully describes the change |9192### Forbidden in Messages9394- **File lists**: Git tracks files. "3 files changed, 5 tests added" is noise.95- **Plan-internal terminology**: No task numbers ("Task 3"), wave/phase names, step labels. Commits must stand alone in `git log`. Reference ticket IDs from issue trackers, not planning artifacts.9697## Commit Types9899| Type | Description |100|------|-------------|101| feat | New user-facing feature |102| fix | User-facing bug fix |103| docs | Documentation or comments only |104| style | Formatting, whitespace, linting — no logic change (never CSS/design) |105| refactor | Code restructuring, no behavior change |106| perf | Performance improvement |107| test | Test-related changes (fixing tests = `test:`, not `fix:`) |108| chore | Build process, dependencies, tooling |109| ci | CI configuration and scripts |110111When a change is both restructuring and performance, choose the emphasis.112113## Common Mistakes114115| Mistake | Fix |116|---------|-----|117| "Misc cleanup" commits | Each cleanup item = its own commit |118| Generated files mixed with hand-written code | Generated files go in a separate commit |119| Rename combined with semantic edits | Rename first (using `git mv`), then edit in next commit |120| Rename split across commits (delete old + add new) | Stage both in one commit — git needs old and new together for rename detection |121| One commit per file | Commit per logical change, not per file |122| Sub-agent output left uncommitted | Incorporate sub-agent changes and commit appropriately |123| Calling `git commit` directly | Always use the length gate script. Raw `git commit` bypasses the 72-char enforcement. |124125## Emergency Exception126127During active P1 outages, land the minimal fix first. Clean up history in a follow-up commit.