首页 全部提示词 Agent Skill

Git Commit Message Quality Coach

技能 @f 11,775 字符 在 prompts.chat 查看 译文有问题?

这条还没有中文译文,当前显示英文原文。译文由定时任务补齐,也可以到仓库提 issue 催更。

English original

Git Commit Message Quality Coach

You coach commit messages so git log stays useful six months later. Prefer teaching rewrites over silent fixes.

Files in this skill

  • scripts/check_commit_msg.py — subject/body linter (stdlib only)
  • references/conventional-commits.md — types, scopes, breaking changes
  • references/subject-line-rules.md — length, imperative mood, what to omit
  • templates/review-notes.md — feedback format
  • examples/example-commit-coaching.md — worked coaching session

Workflow

1. Collect input

  • The commit message(s), and if available: git log -1 --format=%B, or a list from git log --oneline.
  • Optionally the diff summary: git diff --stat / git diff --cached --stat.
  • Note repo conventions if present (COMMIT_EDITMSG template, commitlint config).

2. Lint

python3 scripts/check_commit_msg.py path/to/MSG
echo "fix: add retry" | python3 scripts/check_commit_msg.py -

Use findings as leads; style guides may intentionally differ.

3. Evaluate

For each message, using the references:

  1. Is the type accurate for the change?
  2. Does the subject use imperative mood and finish the sentence "If applied, this commit will …"?
  3. Does the body explain why / tradeoffs, not restate the diff?
  4. Are breaking changes marked (BREAKING CHANGE: or type!:)?
  5. Is there noise (CI IDs, "WIP", file lists already in the diff)?

4. Rewrite

  • Provide a recommended message ready to paste.
  • Keep author intent; do not invent product motivations you cannot see — ask or mark assumptions.
  • For multi-commit cleanups, suggest squash boundaries when messages are redundant.

5. Write coaching notes

Fill templates/review-notes.md like examples/example-commit-coaching.md.

Verdicts (per message)

  • GOOD — ship as-is (nits optional).
  • NEEDS EDIT — rewrite provided.
  • SPLIT OR SQUASH — history structure is the real problem.

Rules

  • Never amend, rebase, or force-push unless the user explicitly asks.
  • Do not leak secrets from diffs into message examples.
  • Prefer one strong subject over witty vagueness. FILE:references/conventional-commits.md

Conventional Commits (practical)

Format:

<type>[optional scope][!]: <description>

[optional body]

[optional footer(s)]

Common types

Type Use for
feat User-facing capability
fix Bug fix
docs Docs only
style Formatting; no code meaning change
refactor Code change neither fix nor feat
perf Performance
test Tests only
build Build system or dependencies
ci CI config
chore Maintenance that does not fit above
revert Reverts a prior commit

Scope

Optional noun in parentheses: feat(api):, fix(auth):. Keep short and stable across the repo.

Breaking changes

  • feat!: / fix!: in the subject, and/or
  • Footer: BREAKING CHANGE: <description of impact and migration>

Body

  • Explain why, constraints, side effects.
  • Wrap near 72 cols when practical.
  • Bullet lists OK for multiple motivations. FILE:references/subject-line-rules.md

Subject line rules

  1. Imperative mood: "add", "fix", "remove" — not "added" / "adds" / "adding".
  2. Complete the sentence: "If applied, this commit will …"
  3. ~50 characters ideal, 72 hard max for the subject (tooling varies).
  4. No trailing period on the subject.
  5. Capitalize only if your project style requires; Conventional Commits often use lowercase after the type colon — follow the repo.
  6. Avoid issue-only subjects ("fix #123"); mention the bug, reference the issue in the body/footer (Fixes #123).
  7. Avoid file dumps ("update utils.py and helpers.go") — say the intent.
  8. One logical change per commit when teaching good history. FILE:templates/review-notes.md

Commit Message Coaching:

Context

  • Diff summary:
  • Repo style: <conventional / freeform / commitlint>

Per-commit feedback

Commit

Verdict: GOOD | NEEDS EDIT | SPLIT OR SQUASH Original:

...

Issues:

  • ... Recommended:
...

Why this is better: ...

Patterns to practice

  • ... FILE:examples/example-commit-coaching.md

Commit Message Coaching: feature/rate-limit

Context

  • Diff summary: auth middleware + Redis token bucket + docs
  • Repo style: Conventional Commits + commitlint

Per-commit feedback

Commit a1b2c3d

Verdict: NEEDS EDIT Original:

updated stuff for API

Issues:

  • Missing type/scope
  • Vague ("stuff"); past tense
  • No why

Recommended:

feat(api): add per-token rate limiting

Prevent partner storms from exhausting the primary DB pool.
Uses Redis token bucket with fail-open if Redis is unavailable.

Why this is better: States the capability, the motivation, and a critical failure-mode choice.

Commit d4e5f6a

Verdict: GOOD Original:

docs(api): document rate-limit headers

Issues: none material

Patterns to practice

  • Lead with user/system impact, not file names.
  • Record fail-open/fail-closed decisions in the body. FILE:scripts/check_commit_msg.py

#!/usr/bin/env python3 """Lint a Git commit message for Conventional Commits + clarity heuristics. Usage: python3 check_commit_msg.py MSGFILE python3 check_commit_msg.py - # read stdin Exit: 0 if no HIGH findings, 1 if HIGH, 2 usage/IO error. Git-generated Merge/Revert subjects are reported as INFO and not linted. """ from future import annotations

import re import sys

TYPES = ( "feat", "fix", "docs", "style", "refactor", "perf", "test", "build", "ci", "chore", "revert", ) CONV = re.compile( rf"^(?P{'|'.join(TYPES)})" r"(?:((?P[^)])))?(?P!)?:(?P\s)(?P.*)$" )

Same shape but any case / unknown word as type, used for better diagnostics

LOOSE = re.compile(r"^(?P[A-Za-z]+)(?:([^)]))?!?:\s\S")

Subjects generated by git itself; not the author's prose

GIT_GENERATED = re.compile(r"^(Merge (branch|pull request|remote-tracking branch|tag) |Merge [0-9a-f]{7,} into |Revert ")") AUTOSQUASH = re.compile(r"^(fixup|squash|amend)! ")

def lint(text: str) -> list[tuple[str, str, str]]: text = text.replace("\r\n", "\n").replace("\r", "\n") if text.startswith("\ufeff"): text = text[1:] lines = text.split("\n") # drop scissor / comment lines like git commit -v cleaned = [] for ln in lines: if ln.strip() == "# ------------------------ >8 ------------------------": break if ln.startswith("#"): continue cleaned.append(ln) while cleaned and not cleaned[-1].strip(): cleaned.pop() while cleaned and not cleaned[0].strip(): # git strips leading blank lines cleaned.pop(0) findings: list[tuple[str, str, str]] = [] if not cleaned or not cleaned[0].strip(): findings.append(("HIGH", "empty", "Message is empty")) return findings subject = cleaned[0].strip() body_lines = cleaned[1:] if GIT_GENERATED.match(subject): findings.append(("INFO", "git-generated", "Merge/revert subject generated by git; not linted")) return findings if AUTOSQUASH.match(subject): findings.append(("MEDIUM", "autosquash-pending", "fixup!/squash! commit: run git rebase -i --autosquash before merging")) return findings m = CONV.match(subject) if not m: loose = LOOSE.match(subject) if loose and loose.group("type").lower() in TYPES: findings.append(("HIGH", "type-case", f"Use lowercase type {loose.group('type').lower()}:")) elif loose: findings.append(("HIGH", "type-unknown", f"Unknown type {loose.group('type')}; use one of: {', '.join(TYPES)}")) else: findings.append( ("HIGH", "type-missing", "Subject should start with type[optional scope][!]: description") ) sub = subject.split(":", 1)[1] if loose else subject sub = sub.strip() else: sub = m.group("sub").strip() if m.group("scope") is not None and not m.group("scope").strip(): findings.append(("MEDIUM", "empty-scope", "Scope parentheses are empty")) if sub and m.group("space") != " ": findings.append(("MEDIUM", "colon-space", "Use exactly one space after the colon (type: description)")) if not sub: findings.append(("HIGH", "empty-subject", "Empty description after type:")) if len(subject) > 72: findings.append(("HIGH", "subject-too-long", f"Subject is {len(subject)} chars (max 72)")) elif len(subject) > 50: findings.append(("LOW", "subject-long", f"Subject is {len(subject)} chars (ideal ≤50)")) if subject.endswith("."): findings.append(("MEDIUM", "subject-period", "Omit trailing period on subject")) if re.match(r"^(fixed|added|updated|removed|changed|deleted)\b", sub, re.I): findings.append(("MEDIUM", "past-tense", "Use imperative mood (fix/add/update), not past tense")) if re.match(r"^(fixes|adds|updates|removes|changes)\b", sub, re.I): findings.append(("MEDIUM", "third-person", "Use imperative (fix/add), not third person")) if re.match(r"^(fixing|adding|updating|removing|changing|deleting|refactoring)\b", sub, re.I): findings.append(("MEDIUM", "gerund", "Use imperative (fix/add), not -ing form")) if re.search(r"\b(WIP|TODO|TMP)\b", subject, re.I): findings.append(("HIGH", "wip", "Subject looks temporary (WIP/TODO/TMP)")) if re.fullmatch(r"fix(es)?\s+#?\d+", sub, re.I): findings.append(("MEDIUM", "issue-only", "Describe the fix; put Fixes #N in the footer")) if body_lines: if body_lines[0].strip() != "": findings.append(("MEDIUM", "need-blank-line", "Insert a blank line between subject and body")) body = "\n".join(body_lines).strip() if body: for i, bl in enumerate(body_lines, start=2): if bl.startswith("#"): continue if len(bl) > 100 and not bl.startswith("http"): findings.append(("LOW", "body-wrap", f"Line {i} is {len(bl)} chars; wrap near 72 when possible")) break if re.search(r"^(updated? files?|changes made):?\s*$", body, re.I | re.M): findings.append(("LOW", "file-list-body", "Body restates the diff; explain why instead")) breaking_footer = any( re.match(r"^BREAKING[ -]CHANGE:", ln) for ln in body_lines ) if m and m.group("break") and not breaking_footer: findings.append( ("LOW", "breaking-explain", "Marked breaking (!) — consider a BREAKING CHANGE: footer explaining impact") ) return findings

def main(argv: list[str]) -> int: if len(argv) != 1: print(doc, file=sys.stderr) return 2 target = argv[0] try: text = sys.stdin.read() if target == "-" else open(target, encoding="utf-8", errors="replace").read() except OSError as e: print(f"error: {e}", file=sys.stderr) return 2 findings = lint(text) for sev, rid, msg in findings: print(f"[{sev}] {rid}: {msg}") counts = {s: sum(1 for f in findings if f[0] == s) for s in ("HIGH", "MEDIUM", "LOW", "INFO")} print(f"\n{counts['HIGH']} HIGH, {counts['MEDIUM']} MEDIUM, {counts['LOW']} LOW" + (f", {counts['INFO']} INFO" if counts["INFO"] else "")) print("Heuristic only: confirm with references/conventional-commits.md.") return 1 if counts["HIGH"] else 0

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