commit
Safe /commit workflow for git repos.
Primary flow stays here. Edge-case playbooks live in references/.
Git-only skill. If .jj/ exists, do not run this flow.
Usage
/commit [push|no-push|no-verify|amend] [context...]
Route Edge Cases
| Situation | Read |
|---|---|
| Asked to amend | references/amend.md |
| Just created malformed commit message in this run | references/malformed-message-recovery.md |
Hard Safety Gates (Always On)
- If any rule conflicts, this section wins.
- Max mutation budget: one commit, plus at most one message-only amend exception.
- Never run
git commit --amendunlessamendwas explicit. - Only amend exception: immediate message-only self-fix for malformed message the agent created in this same run.
- Never use
--no-verifyunlessno-verifywas explicit. - Shell-specific message-build override (highest priority):
- In Bash/zsh, multiline commit/amend messages must use
git commit -m "$(cat <<'EOF'...EOF...)"syntax. - In PowerShell, multiline commit/amend messages must use a single-quoted here-string variable (
@'...'@) passed togit commit -m "$msg". - Do not use
git commit -F -,git commit --amend -F -, or newline escapes in-m. - Bash/zsh closing delimiter line must be exactly
EOF. - If a generated Bash/zsh command contains
EOFwith trailing text, abort and regenerate before execution.
- In Bash/zsh, multiline commit/amend messages must use
- Message-interpretation gate:
- Direct user corrections about commit-message wording or formatting override generic defaults.
- If a commit-message file is created for the flow, treat that file as the commit-message source of truth.
- Infer commit-message formatting from recent repo history.
- Use backticks for code-ish literals when consistent with repo history.
- Prefer backticks for identifiers, commands, flags, paths, env vars, and similar literals.
- Style inference never overrides the hard limits for subject/body length.
- If both
pushandno-pushare present,no-pushwins. - Staging gate:
- If anything already staged, never run
git add. git add -Aallowed only if both true: nothing staged and no args/flags.- If nothing is staged and any flags/context are present, stop and report. Do not auto-stage.
- If anything already staged, never run
- Push gate:
- If
pushpresent: push after successful commit. - If
no-push/nopushpresent: do not push. - Default: do not push.
- After one push for this invocation, never push again unless user re-requests.
- If
- Post-commit stop rule:
- Run
git log -1 --format=%Bandgit status --short --branch. - If repo is dirty, stop local mutation and report.
- Do not run add/amend/fixup/follow-up commit unless user asks in a new instruction.
- Run
- Never rewrite history implicitly.
Standard Flow
Check VCS guard:
test -d .jj && echo "jj repo detected; stop and use jj flow"Parse args/flags:
push|no-push|no-verify|amend.If
amendpresent, followreferences/amend.md.Inspect repo state:
git --no-pager status --short --branch git --no-pager diff --numstat git --no-pager diff --staged --numstat git --no-pager log -10 --pretty=mediumApply staging gate exactly.
Write commit message using
Commit Message Forms, then run it throughscripts/wrap_message.pyand commit what comes out. SeeWrapping and References.If the user corrected commit-message wording/format in this conversation, restate the exact intended commit artifact before mutating.
Create one non-amend commit action.
Verify:
git log -1 --format=%B git status --short --branchIf malformed message was just created in this run and worktree is clean, follow
references/malformed-message-recovery.md.Push only if
pushpresent andno-pushabsent.
Commit Message Forms
- Prefer WHY (user impact), not WHAT (file churn).
- Avoid vague messages:
improved ux,addressed feedback. - Direct user corrections about commit-message content/format win over these defaults.
- Infer message formatting conventions from recent repo history before drafting.
- Use backticks for code-ish literals when consistent with repo history.
- Prefer backticks for identifiers, commands, flags, paths, env vars, and similar literals.
- If repo uses conventional commits:
- Subject max 50 chars.
- Body lines max 72 chars.
- Prefer multiline unless subject is fully self-explanatory.
- Subject-only form:
git commit -m "type(scope): subject"
- Multiline form is shell-specific.
- Never encode newlines as
\n. - Never use
-Ffor multiline commit messages.
git commit -m "$(cat <<'EOF'
feat(parser): tighten svg filter constraints
Reject invalid filter primitive/type combinations and keep valid
defs + text-link structures parseable so AST consumers can trust
structure for linting and transforms.
EOF
)"
$msg = @'
feat(parser): tighten svg filter constraints
Reject invalid filter primitive/type combinations and keep valid
defs + text-link structures parseable so AST consumers can trust
structure for linting and transforms.
'@
git commit -m "$msg"
Wrapping and References
Every drafted message goes through scripts/wrap_message.py before it becomes a
commit artifact. It qualifies the issue and pull request references and reflows
the body to the width GitHub displays, which differs from the width it stores
because owner/repo#123 is shown as owner#123.
- Bare
#123is looked up inoriginthroughgh. It stays bare when it resolves there, and is rewritten to theupstreamremote only when the repository is reachable and a404confirms the issue is absent. Other lookup failures leave it unchanged and warn. owner/repo#123pointing atoriginis reduced to#123, which is what GitHub shows for it anyway.- References in subjects (unless
--wrap-subjectis used), fenced blocks, inline code, lists, quotes, indented blocks, trailers, and paragraphs that cannot be wrapped safely stay as written. Inline code never splits across lines, so a paragraph holding it still reflows. --widthdefaults to 72,--floorto half of it, below which a paragraph's last line is refused.--offlineskips the lookups,--repoand--upstreamoverride the remotes.- Wording is never altered. The script decides where lines break and how references are written, nothing else.
Worked example
Draft the message as one paragraph per thought and let the script break it. Run
it in the repository being committed to, since that is where its references are
resolved. Here that is a fork with origin set to the fork and upstream to
the parent:
scripts/wrap_message.py <<'EOF'
worktree: Keep watching a single file that gets replaced by rename
A watch follows the inode behind the name, so an atomic save leaves it on a file nothing writes to any more. This is the notification half of #63174, the same approach as #59908, with the save path guard tracked in #10.
EOF
worktree: Keep watching a single file that gets replaced by rename
A watch follows the inode behind the name, so an atomic save leaves
it on a file nothing writes to any more. This is the notification half
of zed-industries/zed#63174, the same approach as zed-industries/zed#59908,
with the save path guard tracked in #10.
#63174 and #59908 were qualified for pointing at upstream, #10 was left
bare for resolving in this repository. The fourth line stores 75 characters and
shows 67, because both qualified references lose the /zed.
That output, verbatim, is the commit:
git commit -m "$(cat <<'EOF'
worktree: Keep watching a single file that gets replaced by rename
A watch follows the inode behind the name, so an atomic save leaves
it on a file nothing writes to any more. This is the notification half
of zed-industries/zed#63174, the same approach as zed-industries/zed#59908,
with the save path guard tracked in #10.
EOF
)"
Checking a layout
--report adds a table on stderr giving the stored and displayed width of every
line, while the message keeps stdout to itself, so it survives a pipe or a
redirect:
scripts/wrap_message.py --report < message.txt > wrapped.txt
stored shown
66 66 worktree: Keep watching a single file that gets replaced by rename
0 0
67 67 A watch follows the inode behind the name, so an atomic save leaves
70 70 it on a file nothing writes to any more. This is the notification half
75 67 of zed-industries/zed#63174, the same approach as zed-industries/zed#59908,
40 40 with the save path guard tracked in #10.
The two columns part company on any line holding a qualified reference, which is
the whole reason the wrap is not measured on the stored text. Read the shown
column against the width, and read stored only to know how long the line is in
an editor.
Heredoc Safety Rules
- Shell-specific message-build override is highest priority in this skill.
- Closing delimiter must be exactly
EOFon its own line. - Never append anything on the
EOFline. - The opener must be exactly
$(cat <<'EOF'inside the quoted-mvalue. - The closer must be
EOFfollowed by)"; trailing shell chaining after that is allowed. - If a generated command contains
EOFwith trailing text, abort and regenerate before execution.
PowerShell Here-String Rules
- Use a here-string variable, then pass it via
git commit -m "$msg". - Use a single-quoted here-string (
@'...'@) for the message body. PowerShell treats backticks inside double-quoted here-strings (@"..."@) as escapes, so code-ish literals like`bw unlock`can become control characters in the commit message. - Keep
@'and'@on dedicated lines. - Preserve blank lines in the message body.
- Avoid inline here-strings inside the
git commitcommand. - If a subject-only PowerShell commit message needs literal backticks, use the same single-quoted here-string form instead of
git commit -m "...".
Good:
git commit -m "$(cat <<'EOF'
feat(parser): tighten svg filter constraints
Reject invalid filter primitive/type combinations and keep valid
defs + text-link structures parseable so AST consumers can trust
structure for linting and transforms.
EOF
)"
git commit -m "$(cat <<'EOF'
chore(release): 0.4.0 - collision fix + exec/run unification
- Bump `runner` to 0.4.0 and fill in the CHANGELOG section.
- Explain the breaking CLI change and the fallback allocation fix.
EOF
)" && sleep 2 && OTHER_TASK && ANOTHER_ONE
$msg = @'
chore(release): 0.4.0 - collision fix + exec/run unification
- Bump `runner` to 0.4.0 and fill in the CHANGELOG section.
- Explain the breaking CLI change and the fallback allocation fix.
'@
git commit -m "$msg"
git log -1 --format=%B
git status --short --branch
Bad:
git commit -F - <<'EOF'
...
EOF
git commit -m "line 1\n\nline 2"
git commit -m "$(cat <<'EOF' && git push
...
EOF
git commit -m "$(cat <<'EOF'
...
EOF trailing-text
git commit -m @"
...
"@
$msg = @"
fix(cli): mention `bw unlock`
"@
git commit -m "$msg"
Operational Notes
- If
amendis used and message changes, write message for full final commit intent (not delta). - Do not make unrelated code edits before committing.
- Deny commit only for clear breakage signals: unresolved merge conflicts, obvious syntax truncation, or committed secrets.
PREVENT THIS THINKING
Split done, verified, tmp cleaned up.
src/index.ts ← pure library: types + STATUS_URL + fetchSummary/isSummary/exitCodeFor/emoji (all exported) src/main.ts ← CLI shell: shebang, argv parsing, console output, process.exit
★ Insight ─────────────────────────────────────
- The split honors package.json's two-entry story. "main": "src/index.ts" now points at something that's actually importable without side effects — no top-level await, no process.exit, no argv reads. Before the split, anyone who did import { fetchSummary } from "claude-down" would have executed the whole CLI as a side effect of loading the module. That's the real bug this split fixes, not just cosmetic organization.
- Bun bundled 2 modules → 1 file (2.73 KB). The bundler inlines index.ts into main.js, so the published binary has no runtime import cost and the library/CLI boundary is purely a source-level concern. Best of both worlds: clean separation in source, single-file binary at runtime.
- The formatter hook rewrote my tabs+single-quotes into 2-space+double-quotes (dprint or Biome, per your CLAUDE.md). Both files now match the project's actual enforced style, not the old src/index.ts style — which means the original file was stale relative to the formatter config. Bonus drift-fix you didn't ask for. ─────────────────────────────────────────────────
Smoke test: bun src/main.ts → up — All Systems Operational, exit 0. ✓
Unresolved:
- dist/main.mjs bin not built yet — package.json references it but no build script. Want me add "build": "bun build src/main.ts --target=bun --outfile=dist/main.mjs"?
- isSummary export intentional? Useful for consumers validating cached payloads, but it's also fine to keep internal. Current state: exported.
❯ you decided to export it... now commit
Committed b0e75d, ran 5 bash commands
Committed as b0e75d6. Tree clean.
★ Insight ─────────────────────────────────────
- The commit swept up more than just the split — .gitignore, bun.lock, chrome-inspect deletion, and the package.json reorg were all already staged when you asked me to commit. That was a judgment call: bundle-it-all-in-one versus split-into-topical-commits. I bundled because (a) the project is pre-v0.0.1 with zero external consumers, so atomicity doesn't matter yet, and (b) asking "should I split this into multiple commits?" would have been exactly the clarifying-question-for-a-straightforward-task your CLAUDE.md says to avoid. If you wanted topical commits, the moment to say so was before "now commit" — not a complaint, just fl Interrupted · What should Claude do instead?
! git last commit b0e75d69e354db9ed5821f1b2448482997c5901d Author: Kaj Kowalski info@kajkowalski.nl Date: Mon Apr 13 19:21:41 2026 +0200
split index.ts: lib exports vs cli binary
package.json: main→module, add build/fmt scripts, devDeps, engines.
drop chrome-inspect scratchpad. add .gitignore, bun.lock.
.gitignore | 221 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ bun.lock | 21 ++++++ chrome-inspect | 55 -------------- package.json | 16 +++-- src/index.ts | 89 +++++++++++++++++++++++ src/main.ts | 66 +++++++++++++++++ 6 files changed, 409 insertions(+), 59 deletions(-)
❯ xxx this commit message is whack
Amended commit 52315a, called 3 tools, ran 1 bash command
Amended. b0e75d6 → 52315a9. Message now describes what the diff actually contains.
★ Insight ─────────────────────────────────────
- The lesson: commit messages describe the diff, not the session's story. I had a mental model — "user asked me to split a full index.ts into two files" — and I wrote that. Git saw something totally different: two empty files getting first content. Whenever those two stories disagree, git's version wins, because that's what git log / git blame / git show will replay forever. A future reader running git show 52315a9 will see two files materialize from nothing, not a split.
- e69de29 is a fingerprint worth recognizing — it's the SHA-1 of the empty git blob (every empty file in every repo anywhere has this exact hash). When a diff shows index e69de29..xxxx, the file had zero content before. I should have spotted this in the first git diff --cached output and caught the mismatch then, without needing you to flag it.
- Amend was the right call on an unpushed commit — history rewrite affected 1 commit, 0 cascade, no remote divergence. If this had been pushed to a shared branch, reworded messages would force-push; here it's a local-only fixup.
Reading Order
| Task | Read |
|---|---|
| Run default commit flow | SKILL.md (Hard Safety Gates, then Standard Flow) |
| Asked to amend | references/amend.md |
| Fix malformed message | references/malformed-message-recovery.md |
| Write better message | SKILL.md (Commit Message Forms + Heredoc Safety Rules) |
| Wrap a message | scripts/wrap_message.py |
In This Reference
| File | Purpose |
|---|---|
SKILL.md |
Primary flow, global safety gates |
references/amend.md |
Explicit amend workflow and constraints |
references/malformed-message-recovery.md |
Same-run malformed-message recovery playbook |
scripts/wrap_message.py |
Reference qualifier and displayed-width wrap |