49a7145e70
① line-needs-own-worktree.sh:出路 `WORKTREE_OK=1 git -C … checkout …` 改認指令字串裡的字面前綴
(舊版讀 hook 自己的環境變數,PreToolUse hook 跟指令不是同一個行程,那行永遠走不通)。
判準是位置不是字:前綴必須掛在會移動 HEAD 的那條 git 指令上。
lib/checkout_target_dir.py 加 `--escape NAME=1`;push_target_dir._classify 在 strip_env 時
把前綴留在 verb 事件裡(find_push_target 行為不變,主線閘 10+19 條照綠)。
② github-contact-guard.sh:remote 名在「這條指令實際會推的那個 repo」裡解(沿用 lib/push_target_dir.py
解 cd 鏈/-C/子殼),解不出來才退回 cwd。判準仍是 remote URL 主機,不是「有 -C 就放行」。
測試:A24 59→69(E 群把閘印的那一行原樣餵回去;舊閘 3 條紅)、
A39 14→26(payload 帶 cwd、cwd≠目標 repo;舊閘 7 條紅=4 誤攔+3 漏擋)。
盤點表:61 支/85 條,改判準不加閘,當場數的。
版本:待總管定版。
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0178ef1fGw3XeZtpN7LaZrm4
203 lines
8.4 KiB
Python
203 lines
8.4 KiB
Python
#!/usr/bin/env python3
|
|
"""hooks/lib/push_target_dir.py -- shared helper (inkstone/ISEP#40 S7-style
|
|
lib helper: source of truth for a small piece of logic, not a gate by
|
|
itself; nothing here decides allow/block on its own).
|
|
|
|
What it answers: given a shell command string that somewhere invokes
|
|
`git ... push ...`, which directory will that push actually execute in?
|
|
|
|
Why this exists (inkstone/ISEP#30 comment 3949, 脈絡見 InkStoneCo#57):
|
|
main-and-prod-push-guard.sh's stamp_ok() used to read `git rev-parse
|
|
--show-toplevel` from the *hook's own* cwd (= the session's real repo) as
|
|
"HERE", and compare it against the repo path the caller wrote into the
|
|
stamp ("WANT"). That works when the push target IS the repo the session is
|
|
standing in. It can never work otherwise: a `cd <other-repo> && git push
|
|
origin HEAD:main` changes the *push's* directory but not the hook's, so
|
|
HERE stays the real repo forever while WANT is (correctly) the other repo
|
|
-- the two can never match, no matter how carefully the caller follows the
|
|
gate's own instructions. This isn't a bad judgment call; that scenario
|
|
simply doesn't exist in the old model.
|
|
|
|
This module extracts the directory the push *actually* runs in, purely by
|
|
tokenizing -- it never executes anything. Two sources, first one found
|
|
wins (in the order a real shell would apply them):
|
|
- a `cd <path>` chain preceding the push, scoped correctly across
|
|
subshells: a `(` inherits the current directory from its parent at the
|
|
moment it opens, but whatever a subshell `cd`s to does NOT leak back
|
|
out to sibling commands after the matching `)` closes (this mirrors
|
|
real bash: a subshell's cwd change is local to that subshell). This
|
|
scoping is load-bearing, not cosmetic: without it, `(cd /repo-A &&
|
|
true); git push origin main` would misattribute the later push (which
|
|
really runs wherever the outer shell already was) to /repo-A, and a
|
|
stale/legitimate stamp for /repo-A could then wrongly wave through a
|
|
push into whatever the outer cwd actually is -- the exact "stamp
|
|
opened for repo A also opens the door for repo B" shape 2026-08-11
|
|
already burned us on once.
|
|
- a `-C <path>` flag on the git invocation itself, which further wins
|
|
over any `cd` chain (matches git's own precedence: `-C` sets the
|
|
directory for that invocation regardless of the shell's cwd).
|
|
|
|
Multiple relative `cd`/`-C` hops are combined by plain string join here
|
|
(no `..`/`~`/`$()` resolution) -- resolving the combined expression to a
|
|
real, canonical, absolute path is left to the caller, which does it with a
|
|
read-only `cd "<expr>" && pwd` in a throwaway subshell. That two-step split
|
|
matters: this module only ever *parses*, so it stays side-effect-free even
|
|
when fed a hostile or malformed command; only the caller's final `cd`
|
|
touches the filesystem, and `cd` cannot execute anything, it can only fail
|
|
to find a directory.
|
|
|
|
If no `cd`/`-C` applies (the push runs wherever the hook itself is, i.e.
|
|
today's behaviour), or the command doesn't parse, prints nothing -- the
|
|
caller falls back to its existing cwd-based resolution. That fallback
|
|
direction is deliberately the *safe* one: on any parse ambiguity we hand
|
|
back "unknown" rather than guess, and an unresolved HERE can only make the
|
|
gate keep blocking (fail toward blocking), never open a door it wouldn't
|
|
have opened before.
|
|
|
|
Usage:
|
|
printf '%s' "$CMD" | python3 push_target_dir.py
|
|
"""
|
|
import re
|
|
import shlex
|
|
import sys
|
|
|
|
_SEPARATORS = {";", "&&", "||", "|", "&", "\n"}
|
|
_ENV_ASSIGN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=")
|
|
|
|
|
|
def _join(base, path):
|
|
"""Combine a cwd-so-far (`base`, or None if unknown/hook-cwd) with a
|
|
`cd`/`-C` argument written in the command. Absolute paths and `~`
|
|
replace the base outright; anything else is appended textually --
|
|
normalizing `..`/`.` is intentionally left to the caller's real `cd`."""
|
|
if not path:
|
|
return base
|
|
if path == "-" or path.startswith("$"):
|
|
# `cd -` (previous dir) and `$VAR`/`$(...)` expansions can't be
|
|
# resolved by tokenizing alone -- treat as "unknown" rather than
|
|
# guess wrong, which keeps the caller on its safe fallback path.
|
|
return None
|
|
if path.startswith("/") or path.startswith("~"):
|
|
return path
|
|
if base is None:
|
|
return path
|
|
return base.rstrip("/") + "/" + path
|
|
|
|
|
|
def _classify(tokens, verbs=("push",), strip_env=False):
|
|
"""`verbs` is which git subcommand(s) count as the action being located.
|
|
It defaults to push so find_push_target's behaviour is byte-identical to
|
|
before; checkout_target_dir.py passes checkout/switch instead. Matching
|
|
stays deliberately loose here (the verb may sit anywhere after `git`) --
|
|
callers re-walk the returned token list to confirm command position, so
|
|
a false positive at this layer costs nothing."""
|
|
body = tokens
|
|
if strip_env:
|
|
# `FOO=1 git checkout …` -- an env prefix doesn't change which command
|
|
# runs. Opt-in so find_push_target's behaviour stays byte-identical;
|
|
# checkout_target_dir.py turns it on (its predecessor stripped these,
|
|
# and dropping that would have been a silent regression -- caught by
|
|
# the worktree gate's own case ㉒).
|
|
#
|
|
# 2026-09-07 (inkstone/ISEP#109 -> comment 6587): the prefix is skipped
|
|
# for *classifying* the statement, but the "verb" event hands back the
|
|
# full token list, prefix included. The worktree gate's escape hatch is
|
|
# exactly that prefix (`WORKTREE_OK=1 git checkout …`), and a PreToolUse
|
|
# hook can only see it in the command string -- it never reaches the
|
|
# hook's own environment. Dropping it here made the printed way out
|
|
# unwalkable. Callers that don't care simply skip leading NAME=value
|
|
# tokens themselves (checkout_target_dir._moves_head does).
|
|
i = 0
|
|
while i < len(tokens) and _ENV_ASSIGN.match(tokens[i]):
|
|
i += 1
|
|
body = tokens[i:]
|
|
if not body:
|
|
return ("other", None)
|
|
if body[0] == "cd" and len(body) > 1:
|
|
return ("cd", body[1])
|
|
if body[0] == "git" and any(v in body[1:] for v in verbs):
|
|
return ("verb", tokens)
|
|
return ("other", None)
|
|
|
|
|
|
def _events(cmd, verbs=("push",), strip_env=False):
|
|
"""Tokenize cmd into (kind, value) events in source order: 'enter'/
|
|
'exit' for parens (subshell boundaries), 'cd'/'push'/'other' for
|
|
statements split on the usual shell separators. Returns [] on any
|
|
quoting error -- caller then falls back to cwd-based resolution."""
|
|
try:
|
|
lexer = shlex.shlex(cmd, posix=True, punctuation_chars=True)
|
|
lexer.whitespace_split = True
|
|
toks = list(lexer)
|
|
except ValueError:
|
|
return []
|
|
|
|
events = []
|
|
seg = []
|
|
|
|
def flush():
|
|
if seg:
|
|
# _classify's "verb" branch returns `tokens` by reference; copy
|
|
# before clear() below, or the event's tuple would observe the
|
|
# list emptied out from under it (aliasing, not a value copy).
|
|
events.append(_classify(seg[:], verbs, strip_env))
|
|
seg.clear()
|
|
|
|
for tok in toks:
|
|
if tok == "(":
|
|
flush()
|
|
events.append(("enter", None))
|
|
elif tok == ")":
|
|
flush()
|
|
events.append(("exit", None))
|
|
elif tok in _SEPARATORS:
|
|
flush()
|
|
else:
|
|
seg.append(tok)
|
|
flush()
|
|
return events
|
|
|
|
|
|
def find_push_target(cmd):
|
|
events = _events(cmd)
|
|
if not events:
|
|
return ""
|
|
|
|
stack = [None] # cwd-so-far per paren depth; None = "same as hook cwd"
|
|
result = None
|
|
saw_push = False
|
|
|
|
for kind, val in events:
|
|
if kind == "enter":
|
|
stack.append(stack[-1]) # child subshell inherits current dir
|
|
elif kind == "exit":
|
|
if len(stack) > 1:
|
|
stack.pop() # subshell's own cd's don't leak out
|
|
elif kind == "cd":
|
|
stack[-1] = _join(stack[-1], val)
|
|
elif kind == "verb":
|
|
saw_push = True
|
|
c_path = None
|
|
toks = val
|
|
for j, t in enumerate(toks):
|
|
if t == "-C" and j + 1 < len(toks):
|
|
c_path = toks[j + 1]
|
|
break
|
|
if t.startswith("-C") and len(t) > 2:
|
|
c_path = t[2:]
|
|
break
|
|
result = _join(stack[-1], c_path) if c_path else stack[-1]
|
|
|
|
if not saw_push:
|
|
return ""
|
|
return result or ""
|
|
|
|
|
|
def main():
|
|
cmd = sys.stdin.read()
|
|
sys.stdout.write(find_push_target(cmd))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|