Files
ISEP/hooks/lib/push_target_dir.py
T
isep-hand 49a7145e70 兩支閘改看「指令要動的那個 repo」,不看 hook 自己的環境/cwd(inkstone/ISEP#109 → comment 6629)
① 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
2026-09-07 06:31:45 +00:00

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()