#!/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 && 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 ` 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 ` 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 "" && 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 shlex import sys _SEPARATORS = {";", "&&", "||", "|", "&", "\n"} 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): if not tokens: return ("other", None) if tokens[0] == "cd" and len(tokens) > 1: return ("cd", tokens[1]) if tokens[0] == "git" and "push" in tokens[1:]: return ("push", tokens) return ("other", None) def _events(cmd): """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 "push" 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[:])) 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 == "push": 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()