#!/usr/bin/env python3 """claude-commit-msg -- hand suggested commit messages from Claude sessions to `git commit`. Several Claude sessions work on one checkout at once, each staging its own round. The old handoff was ONE file (`/CLAUDE_COMMIT_MSG`) that the prepare-commit-msg hook pasted into the editor: the second session silently overwrote the first, and the hook could not know which message described which files. Now every handoff is its own file under `/CLAUDE_COMMIT_MSG.d/`, tagged with the paths it describes, and the hook prefills only the message(s) whose paths are part of the commit being made: .msg the raw message .paths one repo-relative path per line (the staged set at save time) used/ consumed messages, kept for recovery save [--paths P ...] [--slug S] read the message on stdin; paths default to the index list pending messages and how they match the current index show ID | rm ID prefill MSGFILE [SOURCE] hook entry point (prepare-commit-msg) Matching in `prefill`: a message whose paths are all in the commit is a FULL match (prefilled, then consumed); overlapping only partly = PARTIAL (prefilled, kept -- the rest may come in a later commit); no paths recorded = WILDCARD (prefilled, consumed), which is also how a legacy single CLAUDE_COMMIT_MSG file is treated. Several matches are all prefilled, separated by a comment line git strips, so you pick in the editor. Nothing matching = editor untouched. Everything lives inside the git dir, so it never shows in `git status`; `--git-path` keeps it per-worktree. Always exits 0 from `prefill`: a hook failure must never abort a commit. """ import os import re import shutil import subprocess import sys import time def git(*args, check=True): r = subprocess.run(["git", *args], capture_output=True, text=True) if check and r.returncode != 0: sys.exit(f"claude-commit-msg: git {' '.join(args)} failed: {r.stderr.strip()}") return r.stdout def store(): d = git("rev-parse", "--git-path", "CLAUDE_COMMIT_MSG.d").strip() d = os.path.abspath(d) os.makedirs(os.path.join(d, "used"), exist_ok=True) return d def staged_paths(): out = git("diff", "--cached", "--name-only", "-z", check=False) return [p for p in out.split("\0") if p] def comment_char(): c = git("config", "--get", "core.commentchar", check=False).strip() return "#" if not c or c == "auto" else c[0] def pending(d): """[(id, message, paths|None)] oldest first. paths None = wildcard.""" out = [] for name in sorted(os.listdir(d)): if not name.endswith(".msg"): continue mid = name[:-4] try: with open(os.path.join(d, name)) as fh: msg = fh.read() except OSError: continue paths = None pf = os.path.join(d, mid + ".paths") if os.path.exists(pf): with open(pf) as fh: paths = [ln.rstrip("\n") for ln in fh if ln.strip()] out.append((mid, msg, paths)) return out def slugify(text): m = re.match(r"\s*\[([^\]]+)\]", text) # this repo's `[Scope] summary` convention base = m.group(1) if m else " ".join(text.split()[:3]) s = re.sub(r"[^a-z0-9]+", "-", base.lower()).strip("-") return s[:32] or "msg" def cmd_save(argv): paths, slug = None, None i = 0 while i < len(argv): if argv[i] == "--paths": paths = [] i += 1 while i < len(argv) and not argv[i].startswith("--"): paths.append(argv[i]) i += 1 continue if argv[i] == "--slug" and i + 1 < len(argv): slug = argv[i + 1] i += 2 continue sys.exit(f"claude-commit-msg save: unknown argument {argv[i]!r}") msg = sys.stdin.read().rstrip() + "\n" if not msg.strip(): sys.exit("claude-commit-msg save: empty message on stdin") if paths is None: paths = staged_paths() d = store() mid = time.strftime("%Y%m%d-%H%M%S") + "-" + (slug or slugify(msg)) n = 1 while os.path.exists(os.path.join(d, f"{mid}.msg")): n += 1 mid = f"{mid}-{n}" with open(os.path.join(d, mid + ".msg"), "w") as fh: fh.write(msg) with open(os.path.join(d, mid + ".paths"), "w") as fh: fh.write("".join(p + "\n" for p in paths)) print(f"saved {mid} ({len(paths)} path{'s' if len(paths) != 1 else ''}) -> {os.path.join(d, mid + '.msg')}") if not paths: print("warning: no paths recorded (nothing staged?) -- it will prefill on ANY commit", file=sys.stderr) warn_if_hook_is_old() def warn_if_hook_is_old(): """A clone still running the legacy hook (or none) reads only the single file this tool never writes -- the editor would come up empty with no error. Say so at save time.""" hook = git("rev-parse", "--git-path", "hooks/prepare-commit-msg", check=False).strip() try: with open(hook, errors="ignore") as fh: ok = "claude-commit-msg" in fh.read() except OSError: ok = False if not ok: print( "warning: this repo's prepare-commit-msg hook is missing or predates claude-commit-msg, so this\n" " message will NOT be prefilled here. Install the tracked hook once per repo:\n" " ln -sf ~/.config/git/hooks/prepare-commit-msg (git rev-parse --git-path hooks)/prepare-commit-msg", file=sys.stderr, ) def classify(paths, staged): if paths is None or not paths: return "wildcard" ps = set(paths) if ps <= staged: return "full" if ps & staged: return "partial" return "none" def cmd_list(_argv): d = store() staged = set(staged_paths()) items = pending(d) legacy = git("rev-parse", "--git-path", "CLAUDE_COMMIT_MSG").strip() if not items and not os.path.exists(legacy): print("no pending messages") return for mid, msg, paths in items: n = len(paths) if paths else 0 print(f"{mid} [{classify(paths, staged):8}] {n} path{'s' if n != 1 else ''} {msg.splitlines()[0][:70]}") if os.path.exists(legacy) and os.path.getsize(legacy) > 0: with open(legacy) as fh: first = fh.readline().rstrip() print(f"(legacy CLAUDE_COMMIT_MSG) [wildcard] {first[:70]}") def cmd_show(argv): d = store() for mid in argv: with open(os.path.join(d, mid + ".msg")) as fh: sys.stdout.write(fh.read()) def cmd_rm(argv): d = store() for mid in argv: hit = False for ext in (".msg", ".paths"): p = os.path.join(d, mid + ext) if os.path.exists(p): os.remove(p) hit = True if hit: print(f"removed {mid}") elif os.path.exists(os.path.join(d, "used", mid + ".msg")): print(f"{mid} was already consumed by a commit (see used/)") else: print(f"{mid}: no such pending message") def consume(d, mid): for ext in (".msg", ".paths"): p = os.path.join(d, mid + ext) if os.path.exists(p): shutil.move(p, os.path.join(d, "used", mid + ext)) def cmd_prefill(argv): if not argv: return msg_file = argv[0] source = argv[1] if len(argv) > 1 else "" if source not in ("", "template"): # -m/-F, merge, squash, amend: leave alone return d = store() staged = set(staged_paths()) order = {"full": 0, "partial": 1, "wildcard": 2} picked = [] for mid, msg, paths in pending(d): kind = classify(paths, staged) if kind != "none": picked.append((order[kind], mid, msg, kind)) legacy = git("rev-parse", "--git-path", "CLAUDE_COMMIT_MSG").strip() legacy_msg = None if os.path.isfile(legacy) and os.path.getsize(legacy) > 0: with open(legacy) as fh: legacy_msg = fh.read() picked.append((3, "legacy", legacy_msg, "wildcard")) if not picked: return picked.sort(key=lambda t: (t[0], t[1])) cc = comment_char() sep = f"\n{cc} ---- another pending Claude message matched this commit; keep the right one ----\n\n" body = sep.join(m.rstrip("\n") + "\n" for _, _, m, _ in picked) with open(msg_file) as fh: existing = fh.read() tmp = msg_file + ".claude-tmp" with open(tmp, "w") as fh: fh.write(body + "\n" + existing) os.replace(tmp, msg_file) for _, mid, _, kind in picked: if mid == "legacy": os.replace(legacy, legacy + ".last") elif kind in ("full", "wildcard"): consume(d, mid) def main(): cmds = {"save": cmd_save, "list": cmd_list, "show": cmd_show, "rm": cmd_rm, "prefill": cmd_prefill} if len(sys.argv) < 2 or sys.argv[1] not in cmds: sys.exit("usage: claude-commit-msg save [--paths P ...] [--slug S] < message | list | show ID | rm ID | prefill MSGFILE [SOURCE]") if sys.argv[1] == "prefill": try: cmd_prefill(sys.argv[2:]) except Exception as e: # never abort a commit because of the helper print(f"claude-commit-msg prefill: {e}", file=sys.stderr) return cmds[sys.argv[1]](sys.argv[2:]) if __name__ == "__main__": main()