[Sync] llm stuff

This commit is contained in:
Coja
2026-09-17 20:58:21 +02:00
parent ed7e34552d
commit d8f6b30e2c
52 changed files with 2233 additions and 237 deletions
+256
View File
@@ -0,0 +1,256 @@
#!/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()
+36
View File
@@ -0,0 +1,36 @@
#!/bin/sh
# Prefill the commit editor from pending Claude commit messages (one-shot).
#
# Claude sessions save suggestions with `~/.config/git/claude-commit-msg save` -- one file per
# message under $(git rev-parse --git-path CLAUDE_COMMIT_MSG.d)/, tagged with the paths it
# describes. On the next `git commit` / lazygit `C` this pastes the message(s) whose paths are
# part of the commit above git's comment block, then consumes them (moved to used/). Several
# sessions can hand off in parallel without overwriting each other.
#
# Falls back to the legacy single file $(git rev-parse --git-path CLAUDE_COMMIT_MSG) when the
# CLI is not deployed on this host (not re-stowed yet). Never exits non-zero: a helper problem
# must not abort a commit.
msg_file=$1
source=$2
# Only prefill a fresh editor session -- skip -m/-F (message), merge, squash, amend (commit).
case "$source" in
""|template) ;;
*) exit 0 ;;
esac
cli="$HOME/.config/git/claude-commit-msg"
if [ -f "$cli" ] && command -v python3 >/dev/null 2>&1; then
python3 "$cli" prefill "$msg_file" "$source"
exit 0
fi
# --- legacy fallback: the single one-shot file ---
suggestion=$(git rev-parse --git-path CLAUDE_COMMIT_MSG)
[ -s "$suggestion" ] || exit 0
tmp=$(mktemp) || exit 0
{ cat "$suggestion"; printf '\n'; cat "$msg_file"; } > "$tmp" &&
mv "$tmp" "$msg_file" &&
mv "$suggestion" "$suggestion.last"
exit 0