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.
- name
commit-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
- You need to rewrite pushed history, recover lost work, or untangle a bad rebase — use git-history-surgery
- You are writing the pull request description — use pull-request-writer
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.
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 bisectand reverts work. - Never commit secrets, generated artifacts, or unrelated formatting churn. Check
git diff --cachedbefore 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:
- Pure renames/moves (no content change, so
git log --followkeeps history). - Refactors that make the change easy (no behavior change).
- The behavior change itself, with its tests in the same commit.
- 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: ...>
| Type | Use for | Release effect (semantic-release / release-please defaults) |
|---|---|---|
feat | New user-visible capability | minor |
fix | Bug fix for users | patch |
perf | Performance improvement, no API change | patch |
refactor | Code change with no behavior change | none |
test | Adding or fixing tests only | none |
docs | Documentation only | none |
build | Build system, dependencies | none |
ci | CI configuration | none |
chore | Maintenance that fits nothing above | none |
revert | Reverting a previous commit | depends |
! 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
| Situation | Do |
|---|---|
| Working tree mixes feature + unrelated fix | Two commits via git add -p; fix first if the feature does not depend on it |
| Feature needs a refactor | Refactor 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 PR | Fixup commits; autosquash right before merge if the repo squash-merges or the team agrees |
| Repo squash-merges every PR | Commit 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 -astages 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.gitignoreandgit statusfirst.- Pre-commit hooks that reformat files leave the commit missing those edits; re-stage and amend, or the next commit will contain unrelated formatting.
--fixuptargets 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..HEADreads as a coherent story; each commit has one purpose.git rebase -i --exec "<test cmd>" origin/mainpasses at every commit.python3 scripts/check_commit_msg.py --range origin/main..HEADexits 0.git diff origin/main...HEADcontains 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
#!/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.