From 8d8dd89b8aaf6a65aa00f60ab8233afb37cb4bdf Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 22:55:31 +0400 Subject: [PATCH] =?UTF-8?q?fix(hooks):=20=D1=86=D0=B5=D0=BB=D1=8C=20=D0=BF?= =?UTF-8?q?=D0=BE=D1=81=D0=BB=D0=B5=20git=20worktree=20add=20=D0=B2=20?= =?UTF-8?q?=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