257 lines
9.2 KiB
Python
Executable File
257 lines
9.2 KiB
Python
Executable File
#!/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 (`<git-dir>/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
|
|
`<git-dir>/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:
|
|
|
|
<id>.msg the raw message
|
|
<id>.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()
|