diff --git a/hooks/harness_target.py b/hooks/harness_target.py index 063486b..abe235c 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 # [(путь, ветка, позиция в out)] из 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,34 @@ 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) elif sub == "push": _push(rest, path, add) +def _worktree(rest, repo, st): + """git worktree add <путь> -b|-B <ветка>: (путь, ветка, позиция в целях) для последующих целей команды.""" + 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 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 + st.wts.append((_join(repo, pos[0][0]), branch, len(st.out))) + + def _push(rest, path, add): delete, everything, tags, pos, k = False, False, False, [], 0 while k < len(rest): @@ -615,15 +642,17 @@ WT_HINT = "Работай в worktree на ветке (superpowers:using-git-wor def decide_guard(tool, ti, cwd): """Сообщение блокировки guard-main или None.""" cwd = cwd or None + wts = [] # [(реальный путь, ветка, позиция в targets)] из 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, 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: @@ -640,8 +669,14 @@ def decide_guard(tool, ti, cwd): if not r: continue root, branch, real = r - if 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/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..85bc647 100644 --- a/tests/test-hook-guard-main.sh +++ b/tests/test-hook-guard-main.sh @@ -26,6 +26,18 @@ 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 +# 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