From 8d8dd89b8aaf6a65aa00f60ab8233afb37cb4bdf Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 22:55:31 +0400 Subject: [PATCH 1/2] =?UTF-8?q?fix(hooks):=20=D1=86=D0=B5=D0=BB=D1=8C=20?= =?UTF-8?q?=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20git=20worktree=20add=20=D0=B2?= =?UTF-8?q?=20=D1=82=D0=BE=D0=B9=20=D0=B6=D0=B5=20=D0=BA=D0=BE=D0=BC=D0=B0?= =?UTF-8?q?=D0=BD=D0=B4=D0=B5=20=E2=80=94=20=D0=BF=D0=BE=20=D0=BD=D0=BE?= =?UTF-8?q?=D0=B2=D0=BE=D0=B9=20=D0=B2=D0=B5=D1=82=D0=BA=D0=B5=20(O-4,=20#?= =?UTF-8?q?63)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hooks/harness_target.py | 45 ++++++++++++++++++++++++------ openspec/specs/hook-guards/spec.md | 14 ++++++++++ tests/test-hook-guard-main.sh | 4 +++ 3 files changed, 55 insertions(+), 8 deletions(-) diff --git a/hooks/harness_target.py b/hooks/harness_target.py index 063486b..8f21da3 100644 --- a/hooks/harness_target.py +++ b/hooks/harness_target.py @@ -267,17 +267,20 @@ def _branch(w): class _State: - def __init__(self, cwd, out, depth): + def __init__(self, cwd, out, depth, wts): self.cur, self.out, self.depth, self.dirs = cwd, out, depth, [] + self.wts = wts # [(путь нового worktree, ветка)] из git worktree add -b|-B в этой же команде -def parse(cmd, cwd, _out=None, _depth=0): +def parse(cmd, cwd, _out=None, _depth=0, _wts=None): """Цели записи команды Bash в порядке выполнения. Чистая функция: файловую систему не читает. - cwd — каталог, от которого считаются относительные пути (None — неизвестен).""" + cwd — каталог, от которого считаются относительные пути (None — неизвестен). + Новые worktree из git worktree add -b попадают в _wts (путь → ветка): decide_guard оценивает + по ним цели под ещё не созданным каталогом worktree.""" out = [] if _out is None else _out if _depth > 5: return out - st = _State(cwd or None, out, _depth) + st = _State(cwd or None, out, _depth, [] if _wts is None else _wts) toks = Lexer(cmd).tokens() stack, words, redirs, prev = [], [], [], None i = 0 @@ -378,11 +381,11 @@ def _simple(words, redirs, st): for k, (t, d) in enumerate(args): if t.startswith("-") and not t.startswith("--") and "c" in t[1:]: if k + 1 < len(args) and not args[k + 1][1]: - parse(args[k + 1][0], cur, out, st.depth + 1) + parse(args[k + 1][0], cur, out, st.depth + 1, st.wts) break return _SAME if head == "eval": - parse(" ".join(t for t, d in args), cur, out, st.depth + 1) + parse(" ".join(t for t, d in args), cur, out, st.depth + 1, st.wts) return _SAME if xargs and head in ALL_PATHS + DEST + ("chmod", "chown", "chgrp", "sed", "perl"): out.append(Target("file", None, head)) # пути придут из stdin @@ -521,10 +524,31 @@ def _git(args, st, cur): name, found = (None if ops[0] == ("-", False) else _branch(ops[0])), True # «-» — предыдущая if found: add(Target("switch", path, name)) + elif sub == "worktree": + _worktree(rest, path, st.wts) elif sub == "push": _push(rest, path, add) +def _worktree(rest, repo, wts): + """git worktree add <путь> -b|-B <ветка>: (путь нового worktree, ветка) для целей той же команды.""" + if not rest or rest[0][0] != "add": + return + branch, pos, k = None, [], 1 + while k < len(rest): + t = rest[k][0] + if t in ("-b", "-B") and k + 1 < len(rest): + branch = _branch(rest[k + 1]) + k += 2 + continue + if not t.startswith("-"): + pos.append(rest[k]) + k += 1 + if branch is None or not pos or pos[0][1] or repo is None: + return + wts.append((_join(repo, pos[0][0]), branch)) + + def _push(rest, path, add): delete, everything, tags, pos, k = False, False, False, [], 0 while k < len(rest): @@ -615,8 +639,10 @@ WT_HINT = "Работай в worktree на ветке (superpowers:using-git-wor def decide_guard(tool, ti, cwd): """Сообщение блокировки guard-main или None.""" cwd = cwd or None + wts = [] # [(реальный путь нового worktree, ветка)] из git worktree add -b|-B в этой же команде if tool == "Bash": - targets = parse(ti.get("command") or "", cwd) + targets = parse(ti.get("command") or "", cwd, _wts=wts) + wts = [(os.path.realpath(p), b) for p, b in wts] elif tool in ("Edit", "Write", "MultiEdit"): fp = ti.get("file_path") or "" targets = [Target("file", _abs(cwd, fp), tool)] if fp else [] @@ -640,7 +666,10 @@ def decide_guard(tool, ti, cwd): if not r: continue root, branch, real = r - if root in over: + wt = next((b for p, b in reversed(wts) if real == p or real.startswith(p + os.sep)), None) + if wt is not None: # цель — в worktree, который эта же команда создаёт на новой ветке (#63) + branch = wt + elif root in over: branch = over[root] if t.kind == "git": if t.op.startswith("push>") or t.op == "push --all": diff --git a/openspec/specs/hook-guards/spec.md b/openspec/specs/hook-guards/spec.md index 5b3fe75..596a660 100644 --- a/openspec/specs/hook-guards/spec.md +++ b/openspec/specs/hook-guards/spec.md @@ -176,6 +176,20 @@ guard-main SHALL блокировать git-мутации — `commit`, `merge` - **WHEN** cwd — worktree на ветке, команда `git switch - && git merge x` - **THEN** guard-main завершается с кодом 2 +### Requirement: Новый worktree внутри той же команды +`git worktree add <путь> -b|-B <ветка>` в вызове Bash SHALL задавать ветку `<ветка>` для целей записи под `<путь>` в +последующих командах того же вызова — в том числе когда каталога worktree на момент проверки ещё нет, а цель задана +относительно него после `cd <путь>` или через `git -C <путь>`. Цель вне `<путь>` MUST оцениваться по ветке объемлющего +репозитория. + +#### Scenario: Создание worktree и openspec archive в одной цепочке +- **WHEN** cwd — репозиторий на main, команда `git worktree add .claude/worktrees/x -b feat/x origin/main && cd .claude/worktrees/x && openspec archive y -y` +- **THEN** guard-main завершается с кодом 0 + +#### Scenario: openspec archive без cd после создания worktree +- **WHEN** cwd — репозиторий на main, команда `git worktree add .claude/worktrees/x -b feat/x origin/main && openspec archive y -y` +- **THEN** guard-main завершается с кодом 2 + ### Requirement: Префиксы команды Голова команды SHALL определяться после присваиваний `VAR=x` и префиксов `sudo`, `env`, `nice`, `timeout`, `ionice`, `stdbuf`, `xargs`, `nohup`, `command`, `exec`, `time`, включая значения их опций (`nice -n 5`, `sudo -u u`, diff --git a/tests/test-hook-guard-main.sh b/tests/test-hook-guard-main.sh index 476aaa7..9a01917 100644 --- a/tests/test-hook-guard-main.sh +++ b/tests/test-hook-guard-main.sh @@ -26,6 +26,10 @@ assert_eq "вложенный worktree: git commit в нём" "$(rc "$W" Bash 'g assert_eq "вложенный worktree: запись в основной checkout на main" "$(rc "$W" Bash "echo x > $S/n/a.md")" 2 assert_eq "вложенный worktree: Write в основной checkout рядом" "$(rc "$W" Write "$S/n/.claude/x.md")" 2 assert_eq "git worktree add .claude/worktrees/… на main" "$(rc "$S/n" Bash 'git worktree add .claude/worktrees/y -b feat/y origin/main')" 0 +# цепочка создания worktree и записи в нём одной командой (#63, O-4): цель под новым путём — по новой ветке +assert_eq "worktree add -b && cd && openspec archive (#63)" "$(rc "$S/n" Bash 'git worktree add .claude/worktrees/z -b feat/z origin/main && cd .claude/worktrees/z && openspec archive y -y')" 0 +assert_eq "worktree add -C && cd && openspec archive (#63)" "$(rc "$S/ws" Bash "git -C $S/n worktree add .claude/worktrees/q -b feat/q origin/main && cd $S/n/.claude/worktrees/q && openspec archive y -y")" 0 +assert_eq "worktree add -b без cd: openspec archive в main — блок (#63)" "$(rc "$S/n" Bash 'git worktree add .claude/worktrees/z -b feat/z origin/main && openspec archive y -y')" 2 # файловые цели Bash assert_eq "echo > в main" "$(rc "$S/m" Bash 'echo x > notes.md')" 2 assert_eq "make 2>build.log в main" "$(rc "$S/m" Bash 'make 2>build.log')" 2 -- 2.47.2 From e064de7c9dfdc3b8b826bf726a98c4f96cef9e51 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 23:09:34 +0400 Subject: [PATCH 2/2] =?UTF-8?q?fix(hooks):=20wt-=D0=BF=D0=B0=D1=80=D1=8B?= =?UTF-8?q?=20=E2=80=94=20=D0=BF=D0=BE=D0=B7=D0=B8=D1=86=D0=B8=D1=8F,=20sw?= =?UTF-8?q?itch-=D0=BF=D1=80=D0=B8=D0=BE=D1=80=D0=B8=D1=82=D0=B5=D1=82,=20?= =?UTF-8?q?=D0=BA=D0=BE=D1=80=D0=B5=D0=BD=D1=8C/=D0=BF=D1=80=D0=B5=D0=B4?= =?UTF-8?q?=D0=BE=D0=BA,=20--reason=20(T1.1-T1.4,=20#63)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hooks/harness_target.py | 34 ++++++++++++++++++++-------------- tests/test-hook-guard-main.sh | 8 ++++++++ 2 files changed, 28 insertions(+), 14 deletions(-) diff --git a/hooks/harness_target.py b/hooks/harness_target.py index 8f21da3..abe235c 100644 --- a/hooks/harness_target.py +++ b/hooks/harness_target.py @@ -269,14 +269,14 @@ def _branch(w): class _State: def __init__(self, cwd, out, depth, wts): self.cur, self.out, self.depth, self.dirs = cwd, out, depth, [] - self.wts = wts # [(путь нового worktree, ветка)] из git worktree add -b|-B в этой же команде + self.wts = wts # [(путь, ветка, позиция в out)] из git worktree add -b|-B в этой же команде def parse(cmd, cwd, _out=None, _depth=0, _wts=None): """Цели записи команды Bash в порядке выполнения. Чистая функция: файловую систему не читает. cwd — каталог, от которого считаются относительные пути (None — неизвестен). - Новые worktree из git worktree add -b попадают в _wts (путь → ветка): decide_guard оценивает - по ним цели под ещё не созданным каталогом worktree.""" + Новые worktree из git worktree add -b попадают в _wts (путь → ветка + позиция): decide_guard + оценивает по ним только цели, идущие в команде после создания worktree.""" out = [] if _out is None else _out if _depth > 5: return out @@ -525,13 +525,13 @@ def _git(args, st, cur): if found: add(Target("switch", path, name)) elif sub == "worktree": - _worktree(rest, path, st.wts) + _worktree(rest, path, st) elif sub == "push": _push(rest, path, add) -def _worktree(rest, repo, wts): - """git worktree add <путь> -b|-B <ветка>: (путь нового worktree, ветка) для целей той же команды.""" +def _worktree(rest, repo, st): + """git worktree add <путь> -b|-B <ветка>: (путь, ветка, позиция в целях) для последующих целей команды.""" if not rest or rest[0][0] != "add": return branch, pos, k = None, [], 1 @@ -541,12 +541,15 @@ def _worktree(rest, repo, wts): branch = _branch(rest[k + 1]) k += 2 continue + if t in ("--reason",) and k + 1 < len(rest): + k += 2 + continue if not t.startswith("-"): pos.append(rest[k]) k += 1 if branch is None or not pos or pos[0][1] or repo is None: return - wts.append((_join(repo, pos[0][0]), branch)) + st.wts.append((_join(repo, pos[0][0]), branch, len(st.out))) def _push(rest, path, add): @@ -639,17 +642,17 @@ WT_HINT = "Работай в worktree на ветке (superpowers:using-git-wor def decide_guard(tool, ti, cwd): """Сообщение блокировки guard-main или None.""" cwd = cwd or None - wts = [] # [(реальный путь нового worktree, ветка)] из git worktree add -b|-B в этой же команде + wts = [] # [(реальный путь, ветка, позиция в targets)] из git worktree add -b|-B в этой же команде if tool == "Bash": targets = parse(ti.get("command") or "", cwd, _wts=wts) - wts = [(os.path.realpath(p), b) for p, b in wts] + wts = [(os.path.realpath(p), b, pos) for p, b, pos in wts] elif tool in ("Edit", "Write", "MultiEdit"): fp = ti.get("file_path") or "" targets = [Target("file", _abs(cwd, fp), tool)] if fp else [] else: return None over = {} # корень → ветка после git switch/checkout -b в этой же команде (None — не определена) - for t in targets: + for idx, t in enumerate(targets): if t.kind == "switch": r = resolve(t.path) if t.path else None if r: @@ -666,11 +669,14 @@ def decide_guard(tool, ti, cwd): if not r: continue root, branch, real = r - wt = next((b for p, b in reversed(wts) if real == p or real.startswith(p + os.sep)), None) - if wt is not None: # цель — в worktree, который эта же команда создаёт на новой ветке (#63) - branch = wt - elif root in over: + if root in over: # явный switch/checkout в этой же команде выигрывает у пары worktree (R-1) branch = over[root] + else: + wt = next((b for p, b, pos in reversed(wts) + if idx >= pos and not (p == root or root.startswith(p + os.sep)) + and (real == p or real.startswith(p + os.sep))), None) + if wt is not None: # цель идёт после worktree add и лежит в нём — ветка из add (#63) + branch = wt if t.kind == "git": if t.op.startswith("push>") or t.op == "push --all": return "BLOCKED [guard-main]: git %s — репозиторий %s. В main только через PR." % ( diff --git a/tests/test-hook-guard-main.sh b/tests/test-hook-guard-main.sh index 9a01917..85bc647 100644 --- a/tests/test-hook-guard-main.sh +++ b/tests/test-hook-guard-main.sh @@ -30,6 +30,14 @@ assert_eq "git worktree add .claude/worktrees/… на main" "$(rc "$S/n" Bash ' assert_eq "worktree add -b && cd && openspec archive (#63)" "$(rc "$S/n" Bash 'git worktree add .claude/worktrees/z -b feat/z origin/main && cd .claude/worktrees/z && openspec archive y -y')" 0 assert_eq "worktree add -C && cd && openspec archive (#63)" "$(rc "$S/ws" Bash "git -C $S/n worktree add .claude/worktrees/q -b feat/q origin/main && cd $S/n/.claude/worktrees/q && openspec archive y -y")" 0 assert_eq "worktree add -b без cd: openspec archive в main — блок (#63)" "$(rc "$S/n" Bash 'git worktree add .claude/worktrees/z -b feat/z origin/main && openspec archive y -y')" 2 +# fix-round 1 ревью PR #71 (T1.1–T1.4, R-1): пара «путь→ветка» — только для целей после add; switch и корень выигрывают +assert_eq "wt-пара не действует на цель до worktree add (T1.1)" "$(rc "$S/m" Bash 'echo x > .claude/worktrees/pre/f.md && git worktree add .claude/worktrees/pre -b feat/pre origin/main')" 2 +assert_eq "явный switch после worktree add — мутация по main (T1.2)" "$(rc "$S/m" Bash 'git worktree add .claude/worktrees/s -b feat/s origin/main && cd .claude/worktrees/s && git checkout main && git commit -m x')" 2 +assert_eq "worktree add . — пара не действует (T1.3)" "$(rc "$S/m" Bash 'git worktree add . -b feat/dot && git commit -m x')" 2 +assert_eq "worktree add .. — пара не действует (T1.3)" "$(rc "$S/m" Bash 'git worktree add .. -b feat/up || git commit -m x')" 2 +assert_eq "worktree add --reason (T1.4)" "$(rc "$S/m" Bash 'git worktree add --reason foo x -b feat/x origin/main && cd x && openspec archive y -y')" 0 +assert_eq "wt-пара не действует на основной checkout через -C (T1.6e)" "$(rc "$S/m" Bash "git worktree add .claude/worktrees/e -b feat/e origin/main && git -C $S/m commit -m x")" 2 +assert_eq "граница префикса пути пары (T1.6f)" "$(rc "$S/m" Bash 'git worktree add foo -b feat/foo && echo x > foobar/f.md')" 2 # файловые цели Bash assert_eq "echo > в main" "$(rc "$S/m" Bash 'echo x > notes.md')" 2 assert_eq "make 2>build.log в main" "$(rc "$S/m" Bash 'make 2>build.log')" 2 -- 2.47.2