---
name: forge-commit-messages
description: Commit messages that survive `git blame` in five years. Subject under 72 chars imperative mood, body for the why not the what, no AI attribution, atomic commits, revert + migration + refactor patterns. Contains paste-ready templates. Use when authoring commits.
license: MIT
---

# forge-commit-messages

You are writing a commit message that will be read by `git blame` in 2031 by someone debugging a regression. Default agent-written commits are "update", "fix bug", or "wip" - three of the four worst-named commits in any repository. This skill exists to make commits archaeology-friendly.

The mental model: **the commit subject is a tweet about the change. The body is the email you send the future maintainer who has to undo or extend it.** Both have one job: convey the *why*.

## Quick reference (the things you must never ship)

1. Subject `update`, `fix`, `wip`, `more changes`, `misc`, `stuff`, `whatever`.
2. Subject in past tense ("Updated the handler").
3. Subject ending in a period.
4. Subject over 72 characters.
5. Subject starting with lowercase (except `feat:`, `fix:` etc prefixes).
6. Body that is just the file list.
7. Body that includes the full diff as quoted text.
8. `Co-Authored-By: Claude` (or any AI attribution).
9. Skipping pre-commit hooks via `--no-verify`.
10. Editing a commit that has been pushed to a shared branch.

## Hard rules

### Subject line

**1. One line, under 72 characters, imperative mood, no trailing period.** "Add cursor pagination to orders" - not "Added pagination" and not "pagination updates."

**2. Capitalize the first word.** "Add", not "add". Tiny detail; consistent across the log.

**3. Describe at the level of behavior, not implementation.** "Prevent double-charge on payment retry" beats "Refactor PaymentService.charge."

**4. No issue number alone as the subject.** "Fix #1234" tells you nothing without GitHub open. "Fix double-charge on retry (#1234)" gives you context plus reference.

**5. Conventional Commits prefixes (`feat:`, `fix:`, `chore:`) only if your team uses them for changelog automation.** Otherwise tax with no benefit.

### Subject vs body

**6. Blank line between subject and body. Mandatory.** Many tools render only the subject; without the blank line, body becomes part of the subject.

### Body (when needed)

**7. Body is for the why.** What problem did this solve. What constraint forced this approach. What did we try that did not work. Future-you needs the reasoning, not the file list.

**8. Body wraps at ~72 chars per line.** `git log` and many email tools wrap awkwardly otherwise.

**9. Body is optional for trivial changes.** Subject alone is fine for typo fixes, dependency bumps, simple renames.

**10. For non-trivial commits, write the body even if you think nobody will read it.** They will. They will be confused. You will not be there to explain.

### What to include in the body

**11. The motivation.** "We saw 0.3% of card-charge requests get retried by Stripe webhooks before our deduplication kicked in, causing double-charges."

**12. The approach summary.** "Use the idempotency_key on the Charge object; store the key + result in Redis for 24h."

**13. Alternatives considered, if non-obvious.** "We considered a DB-backed dedup table; Redis was chosen because the lookup is hot path and the dedup window is short."

**14. Caveats and known limitations.** "Does not deduplicate across regions; each region has its own Redis."

**15. References.** Issue numbers, ADRs, Slack threads, incident report IDs.

### What NOT to include

**16. No file list.** `git diff --stat` shows it.

**17. No step-by-step narrative.** "First I changed X, then Y broke, so I added Z." Belongs in a PR description, not a commit.

**18. No code snippets.** Code is in the diff.

**19. No marketing language.** "Blazingly fast", "revolutionary."

**20. No AI attribution.** Do not credit the assistant. Commits are about the work, not the workflow. No `Generated by...`, no `Co-Authored-By: Claude / GPT / Copilot`.

**21. No "as discussed" without a link.** Memory fades.

### Commit hygiene

**22. One logical change per commit. Atomic.** A commit that "adds the API endpoint and fixes an unrelated bug and bumps a dependency" cannot be reverted cleanly.

**23. `git commit -p` (or `git add -p`) for separating mixed changes.** Take the extra two minutes.

**24. Squash a series of "wip" commits before merging.** A clean history of 3 meaningful commits beats 17 wip commits.

**25. Do not rebase commits pushed to a shared branch unless coordinated.** Force-pushing onto main is destructive.

### When to use what

**26. `feat:` for new user-visible behavior. `fix:` for bug fixes. `chore:` for non-user-visible (deps, tooling). `refactor:` for internal restructure with no behavior change.** Only when changelog generator depends on these.

**27. Footer for breaking changes:** `BREAKING CHANGE: <description>` (Conventional Commits). Or in plain prose: "Breaking change: X is renamed to Y."

**28. Footer for issue closure on supported hosts:** `Closes #123`, `Fixes #456`. GitHub auto-closes when merged.

## Common AI-output patterns to reject

| Pattern | Why bad | Fix |
| --- | --- | --- |
| Subject `update` / `fix` / `wip` | Tells nothing | Imperative behavior description |
| Subject past tense ("Updated the handler") | Wrong convention | Present imperative |
| Subject ends with period | Style inconsistency | Drop the period |
| Subject 100 chars | Truncated in tooling | ≤72 chars |
| Body is the file list | `git diff --stat` shows it | Body explains *why* |
| Body includes diff snippets | Diff is in the diff | Reference function/file names instead |
| `Co-Authored-By: Claude` | Inappropriate | Author is the committing human |
| `🤖 Generated with [Claude Code]` | Inappropriate | Remove |
| Commit message "as discussed" | Lost context | Link the discussion (Slack permalink, ADR) |
| Mixed changes in one commit | Cannot revert cleanly | Split: `git add -p` |

## Templates

### Standard commit (with body)

```
Add cursor pagination to orders

The orders endpoint timed out at OFFSET >5000 (~250ms p99). This
switches to keyset pagination using (created_at DESC, id DESC).

Response shape: { data, next_cursor, has_more }.
v1 clients passing ?page= now get 400 unsupported_pagination.

The keyset index already existed (orders_created_at_id_idx);
no migration needed.

Closes #1234
```

### Revert commit

```
Revert "Add cursor pagination to orders"

This reverts commit 8f2a3c1.

The new pagination broke the legacy /api/v1/orders client, which
relies on offset-based queries. We are reverting to unblock v1
clients while we coordinate the migration.

Refs: incident-2026-05-22-orders-api
```

### Migration / data commit

```
Backfill user.timezone from existing IP geo cache

We are about to enforce timezone-aware scheduling and need every
user to have a non-null timezone. The IP geo cache covers ~97% of
active users; remaining users will default to UTC and be prompted
on next login.

Run order:
1. This commit (idempotent backfill script)
2. PR #1234 (NOT NULL constraint migration)

Closes #1199
```

### Refactor with no behavior change

```
Extract OrderRepository from OrderService

OrderService had grown to handle both business logic and persistence,
making the business logic untestable without a DB. This commit moves
all DB code to OrderRepository; OrderService now takes the repository
as a constructor argument.

No behavior change. Tests still pass.

Refs: ADR-0014 (Repository pattern adoption)
```

### Tiny commits (no body)

These do not need a body:

```
Fix typo in PaymentService docstring
Bump axios to 1.7.5
Rename internal helper: idGen -> generateId
Remove unused import in src/billing/charger.ts
```

## Worked example: bad → good

```
BAD

  Subject: update
  Body:    (none)
  Files:   src/handlers/orders.ts, src/cursor.ts, test/orders.test.ts

GOOD

  Subject: Add cursor pagination to /orders

  The orders endpoint timed out at OFFSET >5000 (~250ms p99). This
  commit replaces offset with keyset pagination using
  (created_at DESC, id DESC).

  Response shape: { data, next_cursor, has_more }. v1 clients
  passing ?page= now get 400 unsupported_pagination.

  Closes #1234
```

## Workflow

When writing a commit message:

1. **Stage only the change you want to commit.** Use `-p` if needed.
2. **Write the subject.** Imperative, ≤72 chars, no period.
3. **If non-trivial, write the body.** Why, approach, caveats, references.
4. **Re-read. Cut anything that just restates the diff.**
5. **`git log --oneline | head` and check the subject reads consistently with recent commits.**

## Verification

```bash
bash skills/dx/forge-commit-messages/verify/check_commit_messages.sh
```

Run from inside a git repo. Examines the most recent N commits and flags: stub subjects, subject starting lowercase, ending with period, longer than 72 chars, AI co-author attribution.

## When to skip this skill

- Personal scratch repos where nobody else reads the log.
- Auto-generated commits (Dependabot, Renovate) - they have their own format.
- Squash merges where the PR title becomes the commit subject (then the discipline lives in [`forge-pr-description`](../forge-pr-description/SKILL.md)).

## Related skills

- [`forge-pr-description`](../forge-pr-description/SKILL.md) - the description that wraps these commits.
- [`forge-code-review`](../forge-code-review/SKILL.md) - reviewing the commits.
- [`forge-migrations`](../../data/forge-migrations/SKILL.md) - the migration-commit template comes from here.
