Commit Craft skill for Claude Code

Commits that bisect cleanly and explain why. Turns a messy working tree into a short series of atomic commits: refactors before features, tests alongside the behavior they cover, and Conventional Commits headers whose bodies explain the reason, not the diff. Claude stages hunk by hunk, tests the staged snapshot, folds review corrections in with fixup commits, and can lint every message in a range with a bundled script.

  • namecommit-craft
  • size218 lines · ≈2,100 tokens
  • files2
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Produces atomic, reviewable git commits with Conventional Commits headers and bodies that explain why, by staging hunks selectively, splitting mixed changes, and folding corrections in with fixup commits and autosquash. Includes a stdlib message linter usable as a commit-msg hook. Use when the user asks to commit changes, write a commit message, split a messy working tree into commits, clean up WIP commits before a PR, or set up conventional commit checks.

Try asking

  • Commit these changes as sensible separate commits
  • Write a proper commit message for what I just staged
  • Clean up my WIP commits on this branch before I open the PR

Use it when

  • The working tree mixes a refactor, a fix, and a feature
  • You need Conventional Commits for semantic-release or release-please
  • Review fixes should be folded into the commits they belong to
  • You want a commit-msg hook without adding Node tooling

Skip it when

How to check the result

Running the test command at each commit with git rebase --exec passes, and the bundled linter exits 0 for the branch range.

The common mistake it prevents

Using git commit -a or git add . on a dirty tree, which bundles unrelated edits and stray files into a single commit that cannot be reverted cleanly.

~/.claude/skills/commit-craft/SKILL.md

--- name: commit-craft description: Produces atomic, reviewable git commits with Conventional Commits headers and bodies that explain why, by staging hunks selectively,… license: MIT ---

A good commit does one thing, builds and passes tests on its own, and has a message that tells a future reader why the change was made. git log is documentation that survives every wiki migration; write it that way.

Ground rules

  • One logical change per commit. A refactor and the feature that needed it are two commits, refactor first.
  • Every commit should build and pass the relevant tests, so git bisect and reverts work.
  • Never commit secrets, generated artifacts, or unrelated formatting churn. Check git diff --cached before every commit.
  • Do not rewrite commits that are already pushed to a shared branch without the user’s explicit consent (see git-history-surgery).
  • Follow the repo’s existing convention over this skill’s defaults. Check first: git log --oneline -20, commitlint.config.*, .czrc, CONTRIBUTING.md.

Workflow

- [ ] 1. Inspect the working tree and recent history
- [ ] 2. Group changes into logical units
- [ ] 3. Stage one unit precisely (hunks, not files)
- [ ] 4. Verify the staged snapshot
- [ ] 5. Write the message
- [ ] 6. Repeat; then tidy with fixup/autosquash before pushing

1. Inspect

git status --short
git diff --stat                 # unstaged scope
git diff --cached --stat        # already staged
git log --oneline -15           # convention: types, scopes, casing, issue refs

2. Group into logical units

Sort every changed hunk into a bucket. Typical buckets, committed in this order:

  1. Pure renames/moves (no content change, so git log --follow keeps history).
  2. Refactors that make the change easy (no behavior change).
  3. The behavior change itself, with its tests in the same commit.
  4. Docs, config, or follow-up cleanup.

Formatting-only changes across untouched code go in their own style: commit, or better, are left out. Add the SHA of large formatting commits to .git-blame-ignore-revs and set git config blame.ignoreRevsFile .git-blame-ignore-revs.

3. Stage precisely

git add -p path/to/file.ts       # y/n per hunk, s = split hunk, e = edit hunk by hand
git add -N new_file.ts           # intent-to-add so new files show up in `add -p`
git restore --staged -p file.ts  # unstage selected hunks
git stash push --keep-index -u   # park everything not staged to test the snapshot

When s cannot split a hunk (adjacent lines), use e and delete the + lines you do not want staged (or turn - lines into context by replacing - with a space).

4. Verify the staged snapshot

git diff --cached                # read every line you are about to commit
git stash push --keep-index -u   # optional: test exactly what is staged
npm test                         # or the project's fast check
git stash pop

Check for debug prints, commented-out code, TODOs without an issue, .only/fit in tests, and credentials.

5. Write the message

Conventional Commits format:

<type>(<optional scope>)<!>: <subject in imperative mood, lower case, no period>

<body: why the change was needed, what alternatives were rejected, any
non-obvious consequences. Wrap at 72 columns.>

<footers: Refs: #123, BREAKING CHANGE: ..., Co-authored-by: ...>
TypeUse forRelease effect (semantic-release / release-please defaults)
featNew user-visible capabilityminor
fixBug fix for userspatch
perfPerformance improvement, no API changepatch
refactorCode change with no behavior changenone
testAdding or fixing tests onlynone
docsDocumentation onlynone
buildBuild system, dependenciesnone
ciCI configurationnone
choreMaintenance that fits nothing abovenone
revertReverting a previous commitdepends

! after the type/scope or a BREAKING CHANGE: footer signals a major bump.

Subject rules: imperative (“add”, not “added”/“adds”), at most ~50 chars ideal and 72 hard, describes the effect not the file (“fix(auth): reject expired refresh tokens”, not “update auth.ts”). Scope is the module or package name the repo already uses.

Body rules: explain why and the trade-off; the diff already shows what. Mention the symptom for fixes, the constraint for workarounds, and link the issue. Skip the body only for truly self-evident changes.

Good:

fix(billing): round invoice totals half-even before tax

Totals were rounded half-up after tax, which drifted from the
accounting system by one cent on ~0.3% of invoices and caused
reconciliation failures. Rounding half-even on the pre-tax subtotal
matches how the ledger computes it.

Refs: BILL-482

Bad: fix stuff, WIP, address review comments, update files, a subject that lists three unrelated changes joined by “and”.

Use a file for multi-line messages so shells do not mangle them:

git commit -F /tmp/msg.txt

6. Tidy before pushing: fixups and autosquash

While iterating, commit corrections as fixups aimed at the commit they belong to:

git commit --fixup=<sha>             # will be squashed into <sha>
git commit --fixup=amend:<sha>       # squash and replace <sha>'s message
git commit --fixup=reword:<sha>      # only reword <sha>'s message
git rebase -i --autosquash origin/main
git config --global rebase.autoSquash true   # make autosquash the default

Find the target quickly: git log --oneline origin/main..HEAD or git log -S'functionName' --oneline.

For the last commit only: git commit --amend --no-edit (staged changes) or git commit --amend (message). Only when it is unpushed or on your own branch.

Run the tests at each commit of a cleaned series:

git rebase -i --exec "npm test" origin/main

Decisions

SituationDo
Working tree mixes feature + unrelated fixTwo commits via git add -p; fix first if the feature does not depend on it
Feature needs a refactorRefactor commit (tests green, no behavior change), then feature commit
Large mechanical change (rename across 80 files)Its own commit, produced by a tool, message says how to reproduce it
Review feedback on an open PRFixup commits; autosquash right before merge if the repo squash-merges or the team agrees
Repo squash-merges every PRCommit granularity matters less; PR title becomes the commit, so write it as a commit subject
Generated files (lockfiles, snapshots)Commit with the change that caused them, never alone without context

Enforcing the format

Lint a message or a range with the bundled stdlib script:

python3 scripts/check_commit_msg.py .git/COMMIT_EDITMSG     # one message file
python3 scripts/check_commit_msg.py --range origin/main..HEAD
echo "feat: add thing" | python3 scripts/check_commit_msg.py -

It exits 1 on errors (bad header, unknown type, period at end, missing blank line, header over 72 chars) and prints warnings for long body lines or headers over 50. Install as a hook:

printf '#!/bin/sh\nexec python3 "%s" "$1"\n' "$(pwd)/scripts/check_commit_msg.py" > .git/hooks/commit-msg
chmod +x .git/hooks/commit-msg

Teams on Node usually prefer @commitlint/cli with @commitlint/config-conventional run from a husky or lefthook commit-msg hook; respect it if present.

Gotchas

  • git commit -a stages every tracked change: the main source of accidental mixed commits. Avoid it when the tree is not clean.
  • git add . picks up new untracked junk (.env, editor files). Check .gitignore and git status first.
  • Pre-commit hooks that reformat files leave the commit missing those edits; re-stage and amend, or the next commit will contain unrelated formatting.
  • --fixup targets by SHA; after any rebase the SHA changes. Re-find it.
  • Squash-merge repos ignore your carefully split commits on main; the PR title and description are what survive. Put the why there too.
  • Co-authored-by: trailers must be in the final paragraph with no blank lines between trailers, or GitHub will not parse them.

Verification

  • git log --stat origin/main..HEAD reads as a coherent story; each commit has one purpose.
  • git rebase -i --exec "<test cmd>" origin/main passes at every commit.
  • python3 scripts/check_commit_msg.py --range origin/main..HEAD exits 0.
  • git diff origin/main...HEAD contains nothing unintended (debug code, secrets).

Bundled files

Loaded only when a step in the skill needs them. Review them here before installing.

scripts/check_commit_msg.py 148 lines · 5.3 KB
#!/usr/bin/env python3
"""Lint commit messages against Conventional Commits.

Usage:
  python3 check_commit_msg.py <message-file>        # e.g. .git/COMMIT_EDITMSG (commit-msg hook)
  python3 check_commit_msg.py -                     # read one message from stdin
  python3 check_commit_msg.py --range A..B          # every non-merge commit in a git range

Exit codes: 0 = no errors, 1 = errors found, 2 = usage / git failure.
"""
from __future__ import annotations

import re
import subprocess
import sys

TYPES = {
    "feat", "fix", "perf", "refactor", "test", "docs", "build",
    "ci", "chore", "style", "revert",
}
HEADER_RE = re.compile(
    r"^(?P<type>[a-z]+)(?:\((?P<scope>[^()\s]+)\))?(?P<bang>!)?: (?P<subject>.+)$"
)
SKIP_PREFIXES = ("Merge ", "Revert \"", "fixup! ", "squash! ", "amend! ")
HARD_HEADER = 72
SOFT_HEADER = 50
BODY_WIDTH = 72
PAST_TENSE = re.compile(r"^(added|fixed|updated|removed|changed|adds|fixes|updates|removes|changes)\b", re.I)


def strip_comments(text: str) -> str:
    lines = [ln for ln in text.splitlines() if not ln.startswith("#")]
    # git appends the verbose diff after a scissors line; drop it
    out = []
    for ln in lines:
        if ln.startswith("------------------------ >8"):
            break
        out.append(ln)
    return "\n".join(out).strip("\n")


def lint(message: str) -> tuple[list[str], list[str]]:
    errors: list[str] = []
    warnings: list[str] = []
    message = strip_comments(message)
    if not message.strip():
        return ["empty commit message"], warnings
    lines = message.splitlines()
    header = lines[0]
    if header.startswith(SKIP_PREFIXES):
        return errors, warnings

    m = HEADER_RE.match(header)
    if not m:
        errors.append(f"header does not match '<type>(<scope>)!: <subject>': {header!r}")
    else:
        if m["type"] not in TYPES:
            errors.append(f"unknown type {m['type']!r}; expected one of {', '.join(sorted(TYPES))}")
        subject = m["subject"]
        if subject.endswith("."):
            errors.append("subject must not end with a period")
        if subject[:1].isupper() and not subject[:2].isupper():
            warnings.append("subject usually starts lower case (unless a proper noun/acronym)")
        if PAST_TENSE.match(subject):
            warnings.append("use imperative mood in the subject ('add', not 'added'/'adds')")
    if len(header) > HARD_HEADER:
        errors.append(f"header is {len(header)} chars; max {HARD_HEADER}")
    elif len(header) > SOFT_HEADER:
        warnings.append(f"header is {len(header)} chars; aim for <= {SOFT_HEADER}")

    if len(lines) > 1 and lines[1].strip():
        errors.append("second line must be blank (separate header from body)")

    in_fence = False
    for i, ln in enumerate(lines[2:], start=3):
        if ln.startswith("```"):
            in_fence = not in_fence
            continue
        if in_fence or "://" in ln or ln.startswith("    "):
            continue
        if len(ln) > BODY_WIDTH:
            warnings.append(f"line {i} is {len(ln)} chars; wrap body at {BODY_WIDTH}")

    for ln in lines[1:]:
        if re.match(r"^breaking[ -]change:", ln, re.I) and not re.match(r"^BREAKING[ -]CHANGE: ", ln):
            errors.append("breaking change footer must be exactly 'BREAKING CHANGE: <description>'")
    return errors, warnings


def messages_from_range(rng: str) -> list[tuple[str, str]]:
    try:
        out = subprocess.run(
            ["git", "log", "--no-merges", "--format=%H%x00%B%x1e", rng],
            check=True, capture_output=True, text=True,
        ).stdout
    except (subprocess.CalledProcessError, FileNotFoundError) as exc:
        detail = getattr(exc, "stderr", "") or str(exc)
        print(f"git log failed: {detail.strip()}", file=sys.stderr)
        sys.exit(2)
    result = []
    for rec in out.split("\x1e"):
        rec = rec.strip("\n")
        if not rec:
            continue
        sha, _, body = rec.partition("\x00")
        result.append((sha[:10], body))
    return result


def main(argv: list[str]) -> int:
    if not argv or argv[0] in {"-h", "--help"}:
        print(__doc__)
        return 2
    if argv[0] == "--range":
        if len(argv) < 2:
            print("--range needs a revision range like origin/main..HEAD", file=sys.stderr)
            return 2
        items = messages_from_range(argv[1])
        if not items:
            print(f"no commits in range {argv[1]}")
            return 0
    elif argv[0] == "-":
        items = [("stdin", sys.stdin.read())]
    else:
        try:
            with open(argv[0], encoding="utf-8") as fh:
                items = [(argv[0], fh.read())]
        except OSError as exc:
            print(f"cannot read {argv[0]}: {exc}", file=sys.stderr)
            return 2

    total_errors = 0
    for label, msg in items:
        errors, warnings = lint(msg)
        header = strip_comments(msg).splitlines()[0] if strip_comments(msg) else ""
        status = "FAIL" if errors else ("WARN" if warnings else "ok")
        print(f"[{status}] {label}: {header}")
        for e in errors:
            print(f"    error: {e}")
        for w in warnings:
            print(f"    warn:  {w}")
        total_errors += len(errors)
    print(f"\n{len(items)} message(s) checked, {total_errors} error(s)")
    return 1 if total_errors else 0


if __name__ == "__main__":
    sys.exit(main(sys.argv[1:]))

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc