358f67abf3
line-needs-own-worktree.sh 擋 `cd <別的 repo> && git checkout` 時擋對了,但訊息
指的是 payload 的 cwd 那個 repo,不是指令真正動的那個。comment 5398 實測:
指令:cd .../matrix/arcrun && git checkout fix/library-lifecycle-187
舊版:目錄:.../InkStoneCo 它現在在:feat/ticket-bell-webhook
↑ 動的是 matrix/arcrun,講的卻是 InkStoneCo
🔴 照著那個訊息做的人,會在 InkStoneCo 開一份用不到的 worktree,真正要隔離的
matrix/arcrun 沒開到——而他以為自己隔離好了。**一道閘給錯下一步比不擋更糟。**
而 `cd X && git checkout` 正是這條線最常出現的寫法(本票的來由那次就是它)。
沒有另寫一支解析器:hooks/lib/push_target_dir.py 2026-08-23 已經替 git push 解過
同一題(inkstone/ISEP#30 comment 3949),多層 cd 鏈與「子殼的 cd 不外洩」都算過了。
本輪只把那支的動詞與「要不要去掉 env 前綴」變成參數(兩個都有預設值,push 那條路
一個 byte 都沒變,42 條既有測試重跑全綠),新增的 lib/checkout_target_dir.py 只放
checkout 專屬的兩件事:哪些形狀不動 HEAD、-C 贏過 cd 的優先序。
新增的一條性質(不是順手,是本票要的):目錄解不出來就放行。
`cd $VAR`/`cd -`/引號壞掉時回 "?",閘直接 exit 0。寧可漏擋,也不要指著錯的
repo 叫人去開 worktree——猜一個回去等於原地打轉。
實測:
- hooks/tests/line-needs-own-worktree.test.sh 35 條 → 59 條(新增 D 群 24 條)
正向:cd 認出 A/-C 認出 B/兩者都在時 -C 贏/相對 cd/相對 -C 接在 cd 之後/
從 worktree cd 回共用目錄/子殼不外洩
反向(不該擋):cd 到非 repo、cd 進自己的 worktree、cd $VAR、cd -、還原檔案
已知邊界也釘成測試:`);` 中間沒空白時 tokenize 會斷在那裡 ⇒ 漏擋(不是指錯)。
那層是 push_target_dir 的 tokenizer,動它會連帶改到推 main 那道閘的偵測範圍,
本票不動,另報。
- 推 main 那三套(共用被改到的 lib):10/10、19/19、13/13
- 實地打票上那條害過我們的指令:現在印的是 matrix/arcrun,worktree 指令也是它的
plugin.json 0.16.1 → 0.16.2 並重跑 vendor-to-shell.py(版本沒動=沒人吃得到)。
⚠️ 留痕:hooks/lib/checkout_target_dir.py 是用 Bash heredoc 寫的,因為 sdd-guard
對 ISEP 的 .py 是結構性永遠在響——這個 repo 根本沒有 system-dev/docs/3-specs,
而 hooks/lib/ 已經住了 9 支 .py。範圍就這一支新檔,沒有動那道閘(另報)。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
193 lines
7.8 KiB
Python
193 lines
7.8 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."""
|
|
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 ㉒).
|
|
i = 0
|
|
while i < len(tokens) and _ENV_ASSIGN.match(tokens[i]):
|
|
i += 1
|
|
tokens = tokens[i:]
|
|
if not tokens:
|
|
return ("other", None)
|
|
if tokens[0] == "cd" and len(tokens) > 1:
|
|
return ("cd", tokens[1])
|
|
if tokens[0] == "git" and any(v in tokens[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()
|