[Sync] Lua Hyprland chain, licence cleanup

This commit is contained in:
Coja committed 2026-11-09 18:25:35 +01:00
1 parent 93b4f695ce
commit 47166d877f
183 files changed
+5618 -5839

No files matched your search

+112 -10
View File
@@ -1,12 +1,24 @@
#!/usr/bin/env bash
# Regression matrix for git-guard.py: `bash ~/.claude/hooks/git-guard-test.sh` (exit 1 on mismatch).
# Kept as a file on purpose: typed inline, the very commands under test would trip the live hook.
G="$(dirname "$(readlink -f "$0")")/git-guard.py"; fail=0
G="$(dirname "$(readlink -f "$0")")/git-guard.py"
fail=0
# No exempt repository for the rows that do not ask for one, whatever this host's ~/.claude/git-guard-exempt says.
export GIT_GUARD_EXEMPT=""
# `git reset <one arg>` is allowed only for an existing path: give the matrix one to point at.
tmp=$(mktemp -d) && cd "$tmp" && touch existing.py
trap 'rm -rf "$tmp"' EXIT
run() { printf '%s' "{\"tool_name\":\"Bash\",\"tool_input\":{\"command\":$(python3 -c 'import json,sys; print(json.dumps(sys.argv[1]))' "$2")}}" | python3 "$G" 2>/dev/null; rc=$?
got=allow; [ $rc -eq 2 ] && got=block; [ "$got" = "$1" ] && mark=ok || { mark=MISMATCH; fail=1; }; printf '%-8s %-6s %-6s %s\n' "$mark" "$1" "$got" "$(printf '%s' "$2" | head -1)"; }
run() {
printf '%s' "{\"tool_name\":\"Bash\",\"tool_input\":{\"command\":$(python3 -c 'import json,sys; print(json.dumps(sys.argv[1]))' "$2")}}" | python3 "$G" 2>/dev/null
rc=$?
got=allow
[ $rc -eq 2 ] && got=block
[ "$got" = "$1" ] && mark=ok || {
mark=MISMATCH
fail=1
}
printf '%-8s %-6s %-6s %s\n' "$mark" "$1" "$got" "$(printf '%s' "$2" | head -1)"
}
echo "--- must block: commits / history ---"
run block 'git push'
run block 'git -C /home/coja/.dots push origin main'
@@ -28,7 +40,10 @@ run block 'git commit -m "fix: grep pattern; not a command"'
run block 'git revert HEAD'
run block 'git cherry-pick abc123'
run block 'git rebase -i HEAD~3'
run block 'git rebase main'
run block 'git rebase --interactive main'
run block 'git -C . rebase -i --autosquash main'
run block 'git rebase --autostash origin/main'
run block 'git -c rebase.autoStash=true rebase main'
run block 'git merge feature'
run block 'git merge --no-ff feature'
echo "--- must block: discards working-tree changes ---"
@@ -63,11 +78,16 @@ run block 'git stash clear'
run block 'git branch -D feature'
run block 'git branch -f main HEAD~1'
run block 'git branch -M main trunk'
echo "--- must block: gh pr write ops ---"
echo "--- must block: gh pr write ops (create only with --head, edit only title/body) ---"
run block 'gh pr merge 12 --squash'
run block 'gh -R o/r pr create --fill'
run block 'gh pr edit 3 --title x'
run block 'gh pr close 4'
run block 'gh -R o/r pr create --fill'
run block 'gh pr create --title x --body y'
run block 'gh pr create --dry-run --fill'
run block 'gh pr edit 3 --add-reviewer bob'
run block 'gh pr edit 3 --title x --base main'
run block 'gh pr edit --add-label=bug 3'
run block 'gh pr edit 3 -b y -m v2'
echo "--- must allow ---"
run allow 'git status'
run allow 'git log -1 --oneline'
@@ -88,6 +108,13 @@ run allow 'git checkout main'
run allow 'git checkout -b feature'
run allow 'git switch main'
run allow 'git merge --abort'
run allow 'git rebase main'
run allow 'git rebase origin/main'
run allow 'git rebase -Xtheirs main'
run allow 'git rebase --continue'
run allow 'git rebase --abort'
run allow 'git -c core.editor=true rebase --continue'
run allow 'GIT_EDITOR=true git rebase --continue'
run allow 'git stash'
run allow 'git stash list'
run allow 'git stash push -m wip'
@@ -110,12 +137,87 @@ run allow 'python3 - <<EOF
print("git commit is manual")
EOF'
run allow 'gh pr view 12 --json updatedAt'
run allow 'gh pr create --head feat --title x --body y'
run allow 'gh pr create -H feat --fill'
run allow 'gh -R o/r pr create --head=feat --fill'
run allow 'gh pr create --help'
run allow 'gh pr edit 3 --title x'
run allow 'gh pr edit 3 -t x -b "- item --base"'
run allow 'gh pr edit --body-file - 12'
run allow 'gh pr edit 12 -F body.md --repo o/r'
run allow 'gh pr edit 12 --title=x -bBody'
run allow 'gh workflow run ci.yml --ref main'
run allow 'gh run rerun 123 --failed'
run allow 'gh api graphql -f query="mutation{...}"'
run allow 'gh auth status'
run allow 'ls -la HEAD#x; git status'
run allow 'git log --grep="git push" -5'
run allow '~/.config/git/claude-commit-msg list'
echo "--- exempt repo (GIT_GUARD_EXEMPT = a scratch repo; commit and merge pass there, nothing else) ---"
# runin <expect> <cwd> <command>: the hook input carries the session's cwd, as Claude Code sends it.
R=$tmp/repo
WT=$tmp/repo-wt
git init -q "$R" && git -C "$R" -c user.name=t -c user.email=t@t commit -q --allow-empty -m init \
&& git -C "$R" worktree add -q "$WT" 2>/dev/null && mkdir -p "$R/sub" && git init -q "$tmp/other"
runin() {
printf '%s' "$(python3 -c 'import json,sys; print(json.dumps({"tool_name":"Bash","tool_input":{"command":sys.argv[1]},"cwd":sys.argv[2]}))' "$3" "$2")" | GIT_GUARD_EXEMPT=$R python3 "$G" 2>/dev/null
rc=$?
got=allow
[ $rc -eq 2 ] && got=block
[ "$got" = "$1" ] && mark=ok || {
mark=MISMATCH
fail=1
}
printf '%-8s %-6s %-6s [%s] %s\n' "$mark" "$1" "$got" "${2#"$tmp"/}" "$(printf '%s' "$3" | head -1)"
}
runin allow "$R" 'git commit -m "docs: x"'
runin allow "$R" 'git commit -m x -- docs/usage.md'
runin allow "$R" 'git commit -m"fix a bug"'
runin allow "$R" 'git commit -m "-a is not the flag here"'
runin allow "$R" 'git commit --allow-empty -m x'
runin allow "$R/sub" 'git commit -m x'
runin allow "$R" 'git merge --ff-only feature'
runin allow "$R" 'git merge --no-ff feature'
runin allow "$WT" 'git commit -m x'
runin allow "$tmp" "git -C $WT commit -m x"
runin allow "$tmp" "cd $R && git commit -m x"
runin allow "$tmp" 'cd repo && git commit -m x'
runin allow "$tmp" "cd $WT; git status; git merge main"
runin allow "$R" "sh -c 'git commit -m x'"
runin block "$R" 'git push'
runin block "$R" 'git commit --amend --no-edit'
runin block "$R" 'git commit -am wip'
runin block "$R" 'git commit -qa -m wip'
runin block "$R" 'git commit --all -m wip'
runin block "$R" 'git rebase -i main'
runin block "$R" 'git cherry-pick abc123'
runin block "$R" 'git revert HEAD'
runin block "$R" 'git reset --hard HEAD~1'
runin block "$R" 'git checkout -- file.py'
runin block "$R" 'GIT_DIR=/x/.git git commit -m x'
runin block "$R" 'env GIT_WORK_TREE=/x git commit -m x'
runin block "$R" 'git --git-dir=/x/.git commit -m x'
runin block "$R" 'git -C /tmp commit -m x'
runin block "$R" 'git -C $(mktemp -d) commit -m x'
runin block "$tmp" "cd $R && cd /tmp && git commit -m x"
runin block "$R" 'cd - && git commit -m x'
runin block "$R" 'cd $HOME && git commit -m x'
runin block "$R" 'pushd /tmp && git commit -m x'
runin block "$tmp" 'git commit -m x'
runin block "$tmp/other" 'git commit -m x'
runin block "$tmp" "git -C $tmp/other merge x"
echo "--- fail-open ---"
printf '%s' '{"tool_name":"Edit","tool_input":{"command":"git push"}}' | python3 "$G"; [ $? -eq 0 ] && echo "ok non-Bash tool ignored" || { echo "MISMATCH non-Bash"; fail=1; }
printf 'not json' | python3 "$G"; [ $? -eq 0 ] && echo "ok garbage stdin passes" || { echo "MISMATCH garbage"; fail=1; }
[ $fail -eq 0 ] && echo "GIT-GUARD MATRIX: all ok" || { echo "GIT-GUARD MATRIX: mismatches above"; exit 1; }
printf '%s' '{"tool_name":"Edit","tool_input":{"command":"git push"}}' | python3 "$G"
[ $? -eq 0 ] && echo "ok non-Bash tool ignored" || {
echo "MISMATCH non-Bash"
fail=1
}
printf 'not json' | python3 "$G"
[ $? -eq 0 ] && echo "ok garbage stdin passes" || {
echo "MISMATCH garbage"
fail=1
}
[ $fail -eq 0 ] && echo "GIT-GUARD MATRIX: all ok" || {
echo "GIT-GUARD MATRIX: mismatches above"
exit 1
}
+179 -18
View File
@@ -7,15 +7,24 @@ command text, so `git -C . push`, `git -c k=v commit`, `/usr/bin/git push`,
This hook parses each shell segment instead: finds the git/gh token wherever it sits,
skips git's global options (`-C dir`, `-c k=v`, `--git-dir=...`) and blocks
git commit | push | revert | cherry-pick | rebase | merge (not --abort/--quit/--continue)
git commit | push | revert | cherry-pick | merge (not --abort/--quit/--continue)
git rebase -i / --interactive / --autostash
git clean | reset (any form that moves HEAD or unstages everything; `reset -- <paths>`,
`reset HEAD <path>` and `reset <existing path>` are fine) | restore <paths> (--staged
alone is fine, except `--staged .` / `:/`) | checkout -- / checkout . / -f
git stash drop | pop | clear git branch -D / -f / -M
gh pr merge | create | close | edit
gh pr merge | close | create without --head | edit beyond --title/--body
i.e. anything that commits, rewrites history, or discards working-tree changes -- the last
group matters most with several sessions sharing one checkout. Mirrors pi's permission-gate.
On GitHub Claude may open a PR (settings.json asks first) and retitle / redescribe one, nothing
more: `gh pr create` must name its branch with --head, without which gh pushes it itself (even
under --dry-run), and `gh pr edit` must not touch reviewers, labels, base or the like.
A plain `git rebase` (and its --continue / --abort) passes: Claude rebases and fixes the
conflicts when the user asks (global CLAUDE.md), and settings.json asks before each one, as
pi's gate does. `-i` stays blocked (the squash/reword tool, and it needs an editor), and so
does --autostash: it stashes and re-pops other sessions' uncommitted edits, where a plain
rebase refuses to start on a dirty tree.
Exit 2 + one stderr line = the call is blocked and Claude reads the reason (stage and
stop; /commit-msg drafts the message). Mirrors pi's permission-gate.ts.
@@ -31,15 +40,27 @@ README that documents pushing must stay writable -- so a script piped in through
is not inspected. Aliases (`git ci`) are not expanded. The deny list, the auto-mode
classifier and the global CLAUDE.md still cover those; this is the belt for the prefix
rules' braces. Fails open: any parse error lets the command through (exit 0).
Exempt repositories (one main checkout per line in ~/.claude/git-guard-exempt, host-local and
outside the dots repo on purpose: it names projects): there `git commit` and `git merge` pass
-- the user's decision for repos where Claude commits and merges itself. The repo is the one the call
actually targets: the hook input's `cwd`, moved by every `cd` before it in the command and by
`git -C`, resolved to its main checkout (so its worktrees count). Anything that hides the
target (`cd -`, `cd $VAR`, a substitution, `--git-dir`, `GIT_DIR=`) is judged not exempt.
`commit --amend` and `commit -a`/`--all` stay blocked there (they rewrite the last commit of
a shared checkout, or sweep other sessions' edits in), as do push, `rebase -i` and the discard
group. Known gap: a `cd` inside `( ... )` leaks into the commands after the parenthesis.
"""
import json
import os
import re
import shlex
import subprocess
import sys
GIT_BLOCK = {"commit", "push", "clean", "revert", "cherry-pick", "rebase"}
GIT_BLOCK = {"commit", "push", "clean", "revert", "cherry-pick"}
MERGE_OK = {"--abort", "--quit", "--continue"}
REBASE_BLOCK = {"-i", "--interactive", "--autostash"}
STASH_BLOCK = {"drop", "pop", "clear"}
BRANCH_FORCE = {"-D", "-f", "--force", "-M"}
RESET_MODES = {"--hard", "--soft", "--merge", "--keep"}
@@ -50,7 +71,20 @@ _HEX = re.compile(r"^[0-9a-f]{7,40}$")
def looks_like_ref(a):
return (a in ("HEAD", "ORIG_HEAD", "FETCH_HEAD", "MERGE_HEAD") or a.startswith("refs/")
or "~" in a or "^" in a or "@{" in a or bool(_HEX.match(a)))
GH_PR_BLOCK = {"merge", "create", "close", "edit"}
GH_PR_BLOCK = {"merge", "close"}
# `gh pr edit` may only retitle and redescribe; these take their value as the next word (or as
# `--title=x` / `-tx`).
GH_EDIT_OK = {"-t", "--title", "-b", "--body", "-F", "--body-file", "-R", "--repo"}
# Repositories (main checkouts; their worktrees count) where Claude commits and merges itself:
# one path per line, `#` comments; a missing file exempts nothing. GIT_GUARD_EXEMPT
# (colon-separated) replaces the file for the test matrix; it is read from the hook's own
# environment (Claude Code's), which a prefix on the checked command cannot reach.
EXEMPT_FILE = "~/.claude/git-guard-exempt"
EXEMPT_VERBS = {"commit", "merge"}
# `git commit` options whose value is the next word (or, for the short ones, the rest of the word).
COMMIT_ARG_SHORT = set("mFCct")
COMMIT_ARG_LONG = {"--message", "--file", "--author", "--date", "--reuse-message", "--reedit-message",
"--fixup", "--squash", "--template", "--trailer", "--cleanup", "--pathspec-from-file"}
# global options that take a *separate* argument; the `--opt=value` spelling is one token.
GIT_OPT_ARG = {"-C", "-c", "--git-dir", "--work-tree", "--exec-path", "--namespace",
"--super-prefix", "--config-env"}
@@ -72,13 +106,102 @@ def _skip_opts(args, with_arg):
return j
def git_reason(args):
def exempt_repos():
env = os.environ.get("GIT_GUARD_EXEMPT")
if env is not None:
paths = env.split(":")
else:
try:
with open(os.path.expanduser(EXEMPT_FILE)) as f:
paths = [ln.split("#", 1)[0].strip() for ln in f]
except OSError:
return set()
return {os.path.realpath(os.path.expanduser(p)) for p in paths if p}
def main_checkout(d):
"""The main checkout of the repository `d` lies in (a worktree resolves to it), or None."""
try:
out = subprocess.run(["git", "-C", d, "rev-parse", "--path-format=absolute", "--git-common-dir"],
capture_output=True, text=True, timeout=3)
except (OSError, subprocess.SubprocessError):
return None
common = out.stdout.strip().rstrip("/")
if out.returncode != 0 or not common:
return None
return os.path.realpath(os.path.dirname(common))
def in_exempt_repo(opts, cwd, pre):
"""Whether a git call with global options `opts`, run from `cwd` after the words `pre` of
its own command (`env X=1`, ...), targets an exempt repository."""
exempt = exempt_repos()
if not exempt or cwd is None or any(t.startswith(("GIT_DIR=", "GIT_WORK_TREE=", "GIT_COMMON_DIR="))
for t in pre):
return False
d, j = cwd, 0
while j < len(opts):
a = opts[j]
if a in ("--git-dir", "--work-tree") or a.startswith(("--git-dir=", "--work-tree=")):
return False
if a == "-C":
if j + 1 >= len(opts) or "SUBST" in opts[j + 1] or "$" in opts[j + 1]:
return False
d = os.path.join(d, os.path.expanduser(opts[j + 1]))
j += 2 if a in GIT_OPT_ARG else 1
return main_checkout(d) in exempt
def commit_rewrites_or_sweeps(rest):
"""`commit --amend` (rewrites the last commit) or `-a`/`--all` (takes in every modified
tracked file, other sessions' edits included)."""
skip = False
for a in rest:
if skip:
skip = False
continue
if a == "--":
break
if a in ("--amend", "--all"):
return True
if a in COMMIT_ARG_LONG:
skip = True
elif a.startswith("-") and not a.startswith("--"):
for k, ch in enumerate(a[1:], 1):
if ch == "a":
return True
if ch in COMMIT_ARG_SHORT:
skip = k == len(a) - 1 # the value is the next word
break
return False
def change_dir(cwd, args):
"""Where `cd args` leaves the shell started in `cwd`; None when that cannot be told."""
args = [a for a in args if a not in ("-L", "-P", "--")]
if not args:
return os.path.expanduser("~")
t = args[0]
if t == "-" or "SUBST" in t or "$" in t or (cwd is None and not t.startswith(("/", "~"))):
return None
return os.path.normpath(os.path.join(cwd or "/", os.path.expanduser(t)))
def git_reason(args, cwd=None, pre=()):
j = _skip_opts(args, GIT_OPT_ARG)
if j >= len(args):
return None
sub, rest = args[j], args[j + 1:]
if (sub in EXEMPT_VERBS and not (sub == "commit" and commit_rewrites_or_sweeps(rest))
and in_exempt_repo(args[:j], cwd, pre)):
return None
if sub in GIT_BLOCK:
return f"git {sub}"
if sub == "rebase":
flag = next((a for a in rest if a in REBASE_BLOCK), None)
if flag == "--autostash" or any(o.lower().startswith("rebase.autostash") for o in args[:j]):
return "git rebase --autostash (stashes other sessions' uncommitted edits)"
return f"git rebase {flag} (interactive history rewrite)" if flag else None
if sub == "reset":
opts = [a for a in rest if a.startswith("-") and a != "--"]
if RESET_MODES & set(opts):
@@ -109,14 +232,41 @@ def git_reason(args):
return None
def gh_edit_extra(rest):
"""The first `gh pr edit` option that is neither the title nor the body, else None."""
k = 0
while k < len(rest):
a = rest[k]
if a == "--":
break
name = a.split("=", 1)[0]
if name in GH_EDIT_OK:
k += 2 if name == a else 1 # skip the value, which may well start with `-`
continue
if a.startswith("-") and not (a[:2] in GH_EDIT_OK and not a.startswith("--")):
return a
k += 1
return None
def gh_reason(args):
j = _skip_opts(args, GH_OPT_ARG)
if j >= len(args) or args[j] != "pr":
return None
rest = args[j + 1:]
k = _skip_opts(rest, GH_OPT_ARG)
if k < len(rest) and rest[k] in GH_PR_BLOCK:
return f"gh pr {rest[k]}"
if k >= len(rest) or "--help" in rest:
return None
sub, rest = rest[k], rest[k + 1:]
if sub in GH_PR_BLOCK:
return f"gh pr {sub}"
if sub == "create" and not any(a in ("-H", "--head") or a.startswith("--head=")
or (a.startswith("-H") and not a.startswith("--")) for a in rest):
return "gh pr create without --head: gh would push the branch itself"
if sub == "edit":
extra = gh_edit_extra(rest)
if extra:
return f"gh pr edit {extra.split('=', 1)[0]}: only --title / --body edits are allowed"
return None
@@ -146,8 +296,8 @@ def segments(toks):
yield seg
def scan(text, depth=0):
"""Reason string for the first blocked invocation in `text`, else None."""
def scan(text, depth=0, cwd=None):
"""Reason string for the first blocked invocation in `text` (run from `cwd`), else None."""
if depth > 4:
return None
text = _HEREDOC.sub(" ", text)
@@ -163,23 +313,29 @@ def scan(text, depth=0):
while prev != text:
prev, text = text, _SUBST.sub(_grab, text)
for body in bodies:
r = scan(body, depth + 1)
r = scan(body, depth + 1, cwd)
if r:
return r
for toks in segments(tokenize(text)):
if toks[0] == "cd":
cwd = change_dir(cwd, toks[1:])
continue
if toks[0] in ("pushd", "popd"):
cwd = None
continue
for i, tok in enumerate(toks):
base = os.path.basename(tok)
r = None
if base == "git":
r = git_reason(toks[i + 1:])
r = git_reason(toks[i + 1:], cwd, toks[:i])
elif base == "gh":
r = gh_reason(toks[i + 1:])
elif base == "eval":
r = scan(" ".join(toks[i + 1:]), depth + 1)
r = scan(" ".join(toks[i + 1:]), depth + 1, cwd)
elif base in SHELLS and "-c" in toks[i + 1:]:
k = toks.index("-c", i + 1) # `sh -c '<cmd>'`: the real command is one word
if k + 1 < len(toks):
r = scan(toks[k + 1], depth + 1)
r = scan(toks[k + 1], depth + 1, cwd)
if r:
return r
return None
@@ -191,16 +347,21 @@ def main():
if data.get("tool_name", "Bash") != "Bash":
return
cmd = (data.get("tool_input") or {}).get("command") or ""
reason = scan(cmd) if cmd else None
reason = scan(cmd, 0, data.get("cwd") or os.getcwd()) if cmd else None
except Exception:
return # fail open: the deny list still applies
if reason:
short = cmd.strip().splitlines()[0]
short = short if len(short) <= 80 else short[:77] + "..."
sys.stderr.write(
f"git-guard: blocked `{short}` ({reason}). The user commits, pushes and merges "
"manually (global CLAUDE.md): stage and stop, and draft the message with /commit-msg.\n"
)
hint = ("On a PR Claude reads, opens one when asked (with --head <pushed branch>) and edits its "
"--title / --body; pushing, merging, closing and every other edit are the user's."
if reason.startswith("gh ") else
"Rebase with a plain `git rebase` on a clean tree, and only when the user asked; if the "
"tree holds other sessions' edits, tell the user instead of stashing them."
if reason.startswith("git rebase") else
"The user commits, pushes and merges manually (global CLAUDE.md): stage and stop, "
"and draft the message with /commit-msg.")
sys.stderr.write(f"git-guard: blocked `{short}` ({reason}). {hint}\n")
sys.exit(2)