From 2f96a13861bc300f323f6356d3aa3f8cd6215d8d Mon Sep 17 00:00:00 2001 From: "andrey.epifantsev" Date: Fri, 16 Oct 2026 02:24:07 +0400 Subject: [PATCH] =?UTF-8?q?fix(hooks):=20=D1=84=D0=B8=D0=BD=D0=B0=D0=BB?= =?UTF-8?q?=D1=8C=D0=BD=D0=BE=D0=B5=20=D1=80=D0=B5=D0=B2=D1=8C=D1=8E=20?= =?UTF-8?q?=E2=80=94=20checkout=20main,=20=D0=BF=D1=80=D0=B5=D1=84=D0=B8?= =?UTF-8?q?=D0=BA=D1=81=D1=8B=20=D1=81=20=D0=BE=D0=BF=D1=86=D0=B8=D1=8F?= =?UTF-8?q?=D0=BC=D0=B8,=20=D1=81=D1=81=D1=8B=D0=BB=D0=BA=D0=B8,=20=D0=BB?= =?UTF-8?q?=D0=B8=D1=82=D0=B5=D1=80=D0=B0=D0=BB=D1=8C=D0=BD=D1=8B=D0=B5=20?= =?UTF-8?q?=D0=BF=D1=83=D1=82=D0=B8,=20push=20=D1=82=D0=B5=D0=BA=D1=83?= =?UTF-8?q?=D1=89=D0=B5=D0=B9=20=D0=B2=D0=B5=D1=82=D0=BA=D0=B8=20(#6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I-1, I-2, M-3, M-4, M-5 (переоценены в Important), M-8; спека и design.md синхронизированы (M-7). Co-Authored-By: Claude Opus 5.5 --- hooks/harness_target.py | 90 +++++++++++++++---- openspec/changes/hooks-write-target/design.md | 22 +++++ .../specs/hook-guards/spec.md | 56 +++++++++++- skills/harness-doctor/scripts/doctor.sh | 2 +- tests/test-doctor.sh | 1 + tests/test-hook-guard-main.sh | 13 +++ tests/test-hook-parse.sh | 13 ++- tests/test-hook-require-plan.sh | 1 + 8 files changed, 176 insertions(+), 22 deletions(-) diff --git a/hooks/harness_target.py b/hooks/harness_target.py index 1850e02..0c7d6c2 100644 --- a/hooks/harness_target.py +++ b/hooks/harness_target.py @@ -28,7 +28,22 @@ OP_CHARS = set(";&|()<>") WRITE_REDIRS = (">", ">>", ">|", "&>", "&>>") KEYWORDS = ("if", "then", "else", "elif", "fi", "do", "done", "while", "until", "!", "{", "}") SKIP_HEADS = ("for", "case", "select", "function") -PREFIXES = ("sudo", "env", "nohup", "command", "exec", "nice", "time", "builtin", "xargs") +PREFIXES = ("sudo", "env", "nohup", "command", "exec", "nice", "time", "builtin", "xargs", "timeout", "ionice", + "stdbuf") +# Опции префиксов, которые забирают следующее слово как значение (иначе значение примут за голову команды). +PREFIX_VALUED = { + "sudo": ("-u", "-g", "-C", "-D", "-h", "-p", "-r", "-t", "-U", "--user", "--group", "--chdir"), + "nice": ("-n", "--adjustment"), + "env": ("-u", "--unset", "-S", "--split-string"), + "timeout": ("-s", "--signal", "-k", "--kill-after"), + "ionice": ("-c", "--class", "-n", "--classdata", "-p", "--pid", "-P", "--pgid", "-u", "--uid"), + "stdbuf": ("-i", "--input", "-o", "--output", "-e", "--error"), + "xargs": ("-I", "-n", "-P", "-L", "-d", "-a", "-E", "-s", "--max-args", "--max-procs", "--max-lines", + "--delimiter", "--arg-file", "--replace"), +} +NOFOLLOW = ("rm", "rmdir", "unlink", "ln", "mv") # меняют саму ссылку, а не её цель +CUR_BRANCH = re.compile(r"(\$\(|`)\s*git\s+(branch\s+--show-current|rev-parse\s+--abbrev-ref\s+HEAD|" + r"symbolic-ref\s+(-q\s+)?--short\s+HEAD)\s*(\)|`)") ALL_PATHS = ("tee", "touch", "mkdir", "rm", "rmdir", "truncate", "unlink", "mv", "shred") DEST = ("cp", "install", "ln", "rsync") GIT_MUT = ("commit", "merge", "rebase", "cherry-pick", "revert", "reset", "am", "add", "rm", "mv", "restore") @@ -195,12 +210,20 @@ class Lexer: # ---------- разбор ---------- def _join(cur, p): - """Абсолютный нормализованный путь; у glob — каталог до первого метасимвола; None, если не определить.""" + """Путь из команды Bash: у glob — каталог до первого метасимвола, дальше как _abs.""" if p is None: return None m = re.search(r"[*?\[]", p) if m: p = os.path.dirname(p[:m.start()]) or "." + return _abs(cur, p) + + +def _abs(cur, p): + """Абсолютный нормализованный путь; None, если относительный и cwd неизвестен. Литерал, без glob — + для file_path инструментов (src/[slug].ts — имя файла, а не шаблон).""" + if p is None: + return None if os.path.isabs(p): return os.path.normpath(p) if not cur: @@ -311,8 +334,26 @@ def _simple(words, redirs, st): elif h in PREFIXES and not ws[0][1]: xargs = xargs or h == "xargs" ws.pop(0) - while ws and (ws[0][0].startswith("-") or (h == "env" and ASSIGN.match(ws[0][0]))): - ws.pop(0) + valued = PREFIX_VALUED.get(h, ()) + while ws: + t = ws[0][0] + if h == "env" and ASSIGN.match(t): + ws.pop(0) + elif h == "env" and (t in ("-C", "--chdir") or t.startswith("--chdir=")): + ws.pop(0) + if "=" in t: + v = (t.split("=", 1)[1], False) + else: + v = ws.pop(0) if ws else ("", True) + cur = None if v[1] else _abs(cur, v[0]) # env -C DIR: каталог только этой команды + elif t.startswith("-") and t != "-": + ws.pop(0) + if t in valued and ws: + ws.pop(0) + else: + break + if h == "timeout" and ws: + ws.pop(0) # длительность else: break if not ws or ws[0][1]: @@ -414,12 +455,12 @@ def _simple(words, redirs, st): if ops and ops[0][0] in ("archive", "new", "init", "update"): out.append(Target("file", cur, "openspec " + ops[0][0])) elif head == "git": - _git(args, st) + _git(args, st, cur) return _SAME -def _git(args, st): - repo, k, unknown = st.cur, 0, False +def _git(args, st, cur): + repo, k, unknown = cur, 0, False while k < len(args): t, d = args[k] if t == "-C" and k + 1 < len(args): @@ -459,6 +500,12 @@ def _git(args, st): add(Target("switch", path, _branch(rest[flag + 1]))) elif "--" in words and words.index("--") < len(words) - 1: add(Target("git", path, "checkout --")) + else: + # checkout без «--»: ветка или путь — неизвестно; защищённые имена и «-» — точно смена ветки, + # переменная — ветка не определена (последующие git-мутации блокируются) + ops = _operands(rest) + if ops and (ops[0][1] or ops[0][0] in ("main", "master", "-")): + add(Target("switch", path, None if ops[0][1] or ops[0][0] == "-" else ops[0][0])) elif sub == "switch": name, found = None, False for j, w in enumerate(words): @@ -471,7 +518,7 @@ def _git(args, st): if not found: ops = _operands(rest) if ops: - name, found = _branch(ops[0]), True + name, found = (None if ops[0] == ("-", False) else _branch(ops[0])), True # «-» — предыдущая if found: add(Target("switch", path, name)) elif sub == "push": @@ -479,11 +526,13 @@ def _git(args, st): def _push(rest, path, add): - delete, everything, pos, k = False, False, [], 0 + delete, everything, tags, pos, k = False, False, False, [], 0 while k < len(rest): t = rest[k][0] if t in ("--delete", "-d"): delete = True + elif t == "--tags": + tags = True elif t in ("--all", "--mirror", "--branches"): everything = True elif t in ("--repo", "-o", "--push-option", "--receive-pack", "--exec"): @@ -496,7 +545,7 @@ def _push(rest, path, add): return refs = pos[1:] if not refs: - if not delete: + if not delete and not tags: # --tags без refspec пушит только теги add(Target("git", path, "push")) return for text, dyn in refs: @@ -505,6 +554,9 @@ def _push(rest, path, add): dst = src if dst.startswith("refs/heads/"): dst = dst[len("refs/heads/"):] + if dyn and CUR_BRANCH.fullmatch(dst): + add(Target("git", path, "push")) # цель — $(git branch --show-current) и т. п., то есть текущая ветка + continue if dst == "HEAD" and not delete and not colon: add(Target("git", path, "push")) continue @@ -527,12 +579,16 @@ def _git_out(d, *args): return r.stdout.strip() if r.returncode == 0 else None -def resolve(path): +def resolve(path, follow=True): """(корень, ветка, реальный путь) git-репозитория, в котором лежит path, или None — вне репозитория. Ветка '' — detached HEAD. Симлинки раскрываются до поиска (в том числе висячие: запись создаст файл у цели - ссылки), затем берётся ближайший существующий предок.""" - real = os.path.realpath(path) - p = real + ссылки), затем берётся ближайший существующий предок. follow=False — для команд, меняющих саму ссылку + (rm, ln, mv): раскрывается только каталог, последний элемент пути остаётся как есть.""" + if follow: + real = p = os.path.realpath(path) + else: + p = os.path.realpath(os.path.dirname(path)) # поиск от каталога: exists/isdir разыменовали бы ссылку + real = os.path.join(p, os.path.basename(path)) while not os.path.exists(p): parent = os.path.dirname(p) if parent == p: @@ -563,7 +619,7 @@ def decide_guard(tool, ti, cwd): targets = parse(ti.get("command") or "", cwd) elif tool in ("Edit", "Write", "MultiEdit"): fp = ti.get("file_path") or "" - targets = [Target("file", _join(cwd, fp), tool)] if fp else [] + targets = [Target("file", _abs(cwd, fp), tool)] if fp else [] else: return None over = {} # корень → ветка после git switch/checkout -b в этой же команде (None — не определена) @@ -580,7 +636,7 @@ def decide_guard(tool, ti, cwd): return ("BLOCKED [guard-main]: git %s — репозиторий не определён (переменная в -C, --git-dir или " "--work-tree). Укажи git -C <абсолютный путь>." % t.op) continue - r = resolve(t.path) + r = resolve(t.path, follow=not (t.kind == "file" and t.op in NOFOLLOW)) if not r: continue root, branch, real = r @@ -603,7 +659,7 @@ def decide_guard(tool, ti, cwd): def decide_plan(fp, cwd): """Сообщение блокировки require-plan или None.""" - path = _join(cwd or None, fp) if fp else None + path = _abs(cwd or None, fp) if fp else None r = resolve(path) if path else None if not r: return None diff --git a/openspec/changes/hooks-write-target/design.md b/openspec/changes/hooks-write-target/design.md index ef70fb4..68efa00 100644 --- a/openspec/changes/hooks-write-target/design.md +++ b/openspec/changes/hooks-write-target/design.md @@ -196,3 +196,25 @@ docs/retro/scripts/hook_cases_build.py сборка корпуса из выг которые прошли, и новый хук обязан их пропускать. Цена ошибки: нет. - 16.10.2026. Лексер собственный, без `shlex` (см. Decisions, 2). Цена ошибки: ~150 строк своего разбора; покрыт `test-hook-parse` и корпусом. +- 16.10.2026, финальное ревью (I-1). `git checkout main|master` — смена ветки; `git checkout -`, `git switch -` и + ветка в переменной — ветка не определена (git-мутации после неё блокируются). `checkout <другое имя>` событием не + считается: без ФС не отличить ветку от пути. Цена ошибки: ложный блок `checkout "$f" && git commit`. +- 16.10.2026, финальное ревью (I-2). Префиксы `timeout`, `ionice`, `stdbuf` добавлены, у `sudo`, `nice`, `env`, + `timeout`, `ionice`, `stdbuf`, `xargs` известны опции со значением; у `timeout` снимается длительность; `env -C DIR` + меняет каталог только этой команды. Цена ошибки: незнакомая опция со значением снова спрячет голову (fail-open). +- 16.10.2026, финальное ревью (M-3). `rm`, `rmdir`, `unlink`, `ln`, `mv` оцениваются по месту ссылки, а не по её + цели. Известные пробелы: `rm -rf link/` с косой чертой (удаляет содержимое цели) и `mv a link-на-каталог` (кладёт + в цель) оцениваются по месту ссылки. Цена ошибки: пропуск записи в main через ссылку на каталог. +- 16.10.2026, финальное ревью (M-4). `file_path` Edit/Write — литерал: glob-обрезка только для путей из команд Bash. +- 16.10.2026, финальное ревью (M-5). `git push --tags` без refspec не считается push ветки; refspec-подстановка + текущей ветки — push текущей ветки. Цена ошибки: нет (подстановки перечислены явно, прочие — «не определена»). +- 16.10.2026, финальное ревью (M-7), решения прототипа, не записанные раньше: + - `git push` с неизвестной целевой веткой (`"$BR"`) блокируется при любой текущей ветке; в сообщении нет корня + репозитория. Цена ошибки: один ложный блок, сообщение просит указать ветку явно. + - Динамическое имя ветки со статическим префиксом, который не может стать защищённым (`feat/$ID`), считается + рабочей веткой (`_branch`). Цена ошибки: нет — `feat/…` не может быть `main`. + - `openspec new|init|update` считаются записью в cwd, как и `archive` (пишут в `openspec/`). + - Целями не считаются все `/dev/*`, а не только четыре пути из спеки (`/dev/fd/N` и т. п. — тоже не файлы + репозитория). + - Git-мутация при пустом `cwd` во входе хука блокируется («репозиторий не определён»). Claude Code `cwd` передаёт + всегда; пропуск такого входа — deferred (M-1). diff --git a/openspec/changes/hooks-write-target/specs/hook-guards/spec.md b/openspec/changes/hooks-write-target/specs/hook-guards/spec.md index cf53758..0bc8893 100644 --- a/openspec/changes/hooks-write-target/specs/hook-guards/spec.md +++ b/openspec/changes/hooks-write-target/specs/hook-guards/spec.md @@ -148,9 +148,9 @@ guard-main SHALL блокировать git-мутации — `commit`, `merge` - **THEN** guard-main завершается с кодом 0 ### Requirement: Смена ветки внутри команды -`git switch -c|-C <ветка>`, `git switch <ветка>` и `git checkout -b|-B <ветка>` SHALL менять ветку репозитория для -последующих целей той же команды. Если имя новой ветки нельзя определить, последующие git-мутации в этом репозитории -MUST блокироваться, а файловые цели — пропускаться. +`git switch -c|-C <ветка>`, `git switch <ветка>`, `git checkout -b|-B <ветка>` и `git checkout main|master` SHALL +менять ветку репозитория для последующих целей той же команды. Если новую ветку нельзя определить (`-`, переменная), +последующие git-мутации в этом репозитории MUST блокироваться, а файловые цели — пропускаться. #### Scenario: Архивная ветка и коммит в одной команде - **WHEN** cwd — репозиторий на main, команда `git switch -q -c chore/archive-x && git add openspec && git commit -m a` @@ -164,6 +164,39 @@ MUST блокироваться, а файловые цели — пропуск - **WHEN** cwd — репозиторий на main, команда `git switch "$B" && git commit -m x` - **THEN** guard-main завершается с кодом 2 +#### Scenario: checkout main и merge из worktree +- **WHEN** cwd — worktree на ветке, команда `git checkout main && git pull && git merge --no-ff feat/x && git push` +- **THEN** guard-main завершается с кодом 2 + +#### Scenario: Переключение на предыдущую ветку +- **WHEN** cwd — worktree на ветке, команда `git switch - && git merge x` +- **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`, +`timeout -s KILL 5`). `env -C <каталог>` MUST менять каталог только для этой команды. + +#### Scenario: timeout перед push в main +- **WHEN** cwd — worktree на ветке, команда `timeout 60 git push origin main` +- **THEN** guard-main завершается с кодом 2 + +#### Scenario: nice -n перед коммитом в main +- **WHEN** cwd — репозиторий на main, команда `nice -n 5 git commit -m x` +- **THEN** guard-main завершается с кодом 2 + +### Requirement: Команды над самой ссылкой +Для `rm`, `rmdir`, `unlink`, `ln` и `mv` местом записи SHALL считаться сама ссылка (каталог, где она лежит), а не её +цель; для остальных целей ссылка MUST раскрываться. + +#### Scenario: Удаление ссылки в workspace на main-репозиторий +- **WHEN** cwd — каталог workspace вне git, команда `rm /link`, где `link` указывает на репозиторий на main +- **THEN** guard-main завершается с кодом 0 + +#### Scenario: Удаление ссылки внутри main +- **WHEN** cwd — репозиторий на main, команда `rm LINK.md`, где `LINK.md` — ссылка наружу +- **THEN** guard-main завершается с кодом 2 + ### Requirement: Git-мутация с неопределённым репозиторием блокируется Если для git-мутации нельзя определить репозиторий (`-C` с переменной, `--git-dir`, `--work-tree`, неразрешимый текущий каталог), guard-main SHALL заблокировать вызов и в сообщении предложить `git -C <абсолютный путь>`. @@ -193,6 +226,19 @@ refspec с текущей защищённой ветки, refspec `HEAD`, `main - **WHEN** cwd — worktree на ветке, команда `git push origin HEAD:main` - **THEN** guard-main завершается с кодом 2 +### Requirement: Текущая ветка и теги в push +Refspec-подстановка текущей ветки (`$(git branch --show-current)`, `$(git rev-parse --abbrev-ref HEAD)`, +`$(git symbolic-ref --short HEAD)`) SHALL считаться push текущей ветки. `git push --tags` без refspec MUST NOT +считаться push ветки. + +#### Scenario: push текущей ветки через подстановку +- **WHEN** cwd — worktree на ветке, команда `git push -u origin "$(git branch --show-current)"` +- **THEN** guard-main завершается с кодом 0 + +#### Scenario: push тегов с main +- **WHEN** cwd — репозиторий на main, команда `git push --tags` +- **THEN** guard-main завершается с кодом 0 + ### Requirement: Без исключения для OpenSpec guard-main SHALL применять одни и те же правила к изменениям в `openspec/`, включая `openspec archive`: архив OpenSpec делается отдельным PR (решение владельца 11.10.2026). @@ -237,6 +283,10 @@ require-plan SHALL определять корень, ветку, `.claude/code- - **WHEN** cwd — основной checkout, Edit файла `src/a.py` в worktree, в корне которого есть `.bounded` - **THEN** require-plan завершается с кодом 0 +#### Scenario: Имя файла со скобками — литерал +- **WHEN** Edit файла `src/[slug].ts` в репозитории без плана и без `.bounded` +- **THEN** require-plan завершается с кодом 2 + #### Scenario: code-paths из репозитория файла - **WHEN** в репозитории файла `.claude/code-paths` содержит `hooks/`, Edit файла `hooks/x.sh` без плана - **THEN** require-plan завершается с кодом 2 diff --git a/skills/harness-doctor/scripts/doctor.sh b/skills/harness-doctor/scripts/doctor.sh index d4ce09d..8eb923d 100755 --- a/skills/harness-doctor/scripts/doctor.sh +++ b/skills/harness-doctor/scripts/doctor.sh @@ -69,7 +69,7 @@ if [ -f "$HC" ] && [ -f "$CC/hooks/harness_target.py" ]; then [ "$rc" = 0 ] && ok "$(printf '%s\n' "$out" | tail -1)" || warn "$(printf '%s\n' "$out" | tail -1) — расхождения выше" else miss "корпус хуков (skills/harness-doctor/scripts/hook_corpus.py, hooks/harness_target.py)"; fi -echo "## Проект:$(cd "$DIR" 2>/dev/null && pwd)" +echo "## Проект: $(cd "$DIR" 2>/dev/null && pwd)" if root=$(git -C "$DIR" rev-parse --show-toplevel 2>/dev/null); then main=$(git -C "$root" rev-parse --git-common-dir 2>/dev/null); main=$(cd "$root" && cd "$(dirname "$main")" 2>/dev/null && pwd) info "git root: $root$( [ "$main" != "$root" ] && echo " (worktree; основной: $main)")" diff --git a/tests/test-doctor.sh b/tests/test-doctor.sh index 4f3630e..4f93bbd 100644 --- a/tests/test-doctor.sh +++ b/tests/test-doctor.sh @@ -25,6 +25,7 @@ assert_grep "самотест: openspec-only коммит на main — блок assert_grep "самотест: Write на main" 'ok +guard-main: Write на main → exit 2' <(printf '%s' "$out") assert_grep "самотест: Write на ветке" 'ok +guard-main: Write на ветке → exit 0' <(printf '%s' "$out") assert_grep "корпус хуков сходится" 'ok +корпус хуков: [0-9]+/[0-9]+' <(printf '%s' "$out") +assert_grep "заголовок раздела проекта (M-8)" '^## Проект: /' <(printf '%s' "$out") assert_grep "проект: режим workspace" 'workspace' <(printf '%s' "$out") assert_grep "проект: целая ссылка" 'ok +roles/admin.md' <(printf '%s' "$out") assert_grep "проект: битая ссылка" 'WARN +agents/none.md' <(printf '%s' "$out") diff --git a/tests/test-hook-guard-main.sh b/tests/test-hook-guard-main.sh index bfb8be1..363e237 100644 --- a/tests/test-hook-guard-main.sh +++ b/tests/test-hook-guard-main.sh @@ -63,6 +63,19 @@ assert_eq "запись через симлинк в main" "$(rc "$S/ws" Write " assert_eq "пустой cwd и относительный путь — пропуск" "$(rc "" Write "a.md")" 0 assert_eq "запись внутри .git/ — не рабочее дерево" "$(rc "$S/m" Bash 'echo x >> .git/info/exclude')" 0 assert_eq "путь с пробелом в main" "$(rc "$S/m" Bash 'touch "a b.md"')" 2 +# финальное ревью: I-1, I-2, M-3, M-4, M-5 +assert_eq "checkout main && merge && push из worktree (I-1)" "$(rc "$S/wt" Bash 'git checkout main && git pull && git merge --no-ff feat/6-x && git push')" 2 +assert_eq "checkout master && commit (I-1)" "$(rc "$S/wt" Bash 'git checkout master && git commit -am x')" 2 +assert_eq "switch - && merge (I-1)" "$(rc "$S/wt" Bash 'git switch - && git merge x')" 2 +assert_eq "timeout 60 git push origin main (I-2)" "$(rc "$S/wt" Bash 'timeout 60 git push origin main')" 2 +assert_eq "nice -n 5 git commit на main (I-2)" "$(rc "$S/m" Bash 'nice -n 5 git commit -m x')" 2 +ln -s "$S/m" "$S/ws/mlink"; ln -s /etc/hosts "$S/m/LINK.md" +assert_eq "rm симлинка в workspace на main-репозиторий (M-3)" "$(rc "$S/ws" Bash "rm $S/ws/mlink")" 0 +assert_eq "ln -sfn поверх симлинка в workspace (M-3)" "$(rc "$S/ws" Bash "ln -sfn $S/m $S/ws/mlink")" 0 +assert_eq "rm симлинка внутри main, указывающего наружу (M-3)" "$(rc "$S/m" Bash 'rm LINK.md')" 2 +assert_grep "путь Edit с [скобками] не режется (M-4)" 'docs/\[x\]\.md' <(err "$S/ws" Write "$S/m/docs/[x].md") +assert_eq "push текущей ветки через подстановку из worktree (M-5)" "$(rc "$S/wt" Bash 'git push -u origin "$(git branch --show-current)"')" 0 +assert_eq "push --tags с main (M-5)" "$(rc "$S/m" Bash 'git push --tags')" 0 # сбой разбора и сообщение assert_eq "не JSON — пропуск" "$(printf 'not json' | bash "$G" 2>/dev/null; echo $?)" 0 assert_eq "незакрытая кавычка — пропуск" "$(rc "$S/m" Bash 'echo "x > f')" 0 diff --git a/tests/test-hook-parse.sh b/tests/test-hook-parse.sh index 55236d1..2b69216 100644 --- a/tests/test-hook-parse.sh +++ b/tests/test-hook-parse.sh @@ -68,7 +68,18 @@ assert_eq "stash list/show — чтение" "$(t 'git stash list; git stash sho assert_eq "stash — мутация" "$(t 'git stash')" "git /r stash" assert_eq "checkout -b — смена ветки" "$(t 'git checkout -b f')" "switch /r f" assert_eq "checkout -- пути" "$(t 'git checkout -- a.txt')" "git /r checkout --" -assert_eq "checkout ветки — не мутация" "$(t 'git checkout main')" "" +assert_eq "checkout main — смена ветки (I-1)" "$(t 'git checkout main && git merge x')" "switch /r main${NL}git /r merge" +assert_eq "checkout ветки фичи — не событие" "$(t 'git checkout feat/x')" "" +assert_eq "switch - — ветка неизвестна (I-1)" "$(t 'git switch - && git merge x')" "switch /r -${NL}git /r merge" +assert_eq "timeout 60 прячет голову (I-2)" "$(t 'timeout 60 git push origin main')" "git /r push>main" +assert_eq "timeout -s KILL 5 (I-2)" "$(t 'timeout -s KILL 5 rm x')" "file /r/x rm" +assert_eq "nice -n 10 (I-2)" "$(t 'nice -n 10 git commit -m x')" "git /r commit" +assert_eq "sudo -u bob (I-2)" "$(t 'sudo -u bob touch f')" "file /r/f touch" +assert_eq "env -C меняет каталог команды (I-2)" "$(t 'env -C /w git commit -m x')" "git /w commit" +assert_eq "xargs -n 1 rm (I-2)" "$(t 'xargs -n 1 rm < list')" "file - rm" +assert_eq "push --tags — не ветка (M-5)" "$(t 'git push --tags')" "" +assert_eq "push текущей ветки через \$(git branch --show-current) (M-5)" "$(t 'git push -u origin "$(git branch --show-current)"')" "git /r push" +assert_eq "push текущей ветки через rev-parse (M-5)" "$(t 'git push origin $(git rev-parse --abbrev-ref HEAD)')" "git /r push" assert_eq "switch -c && commit" "$(t 'git switch -q -c feat/x && git commit -m y')" "switch /r feat/x${NL}git /r commit" assert_eq "apply --check — чтение" "$(t 'git apply --check p.diff')" "" assert_eq "apply — мутация" "$(t 'git apply p.diff')" "git /r apply" diff --git a/tests/test-hook-require-plan.sh b/tests/test-hook-require-plan.sh index 4760365..624db5f 100644 --- a/tests/test-hook-require-plan.sh +++ b/tests/test-hook-require-plan.sh @@ -18,6 +18,7 @@ rc(){ j "$@" | bash "$H" 2>/dev/null; echo $?; } assert_eq "файл в B без плана при cwd в A с .bounded" "$(rc "$S/a" "$S/b/src/a.py")" 2 assert_eq "файл в worktree с .bounded при cwd на main" "$(rc "$S/f" "$S/wt/src/a.py")" 0 assert_eq "src на глубине без плана" "$(rc "$S/b" "$S/b/api/src/a.py")" 2 +assert_eq "имя файла с [скобками] в src без плана (M-4)" "$(rc "$S/b" "$S/b/src/[slug].ts")" 2 assert_eq "не код — пропуск" "$(rc "$S/b" "$S/b/docs/a.md")" 0 assert_eq "вне репозитория — пропуск" "$(rc "$S/b" "$S/out/src/a.py")" 0 assert_eq "code-paths: hooks/ без плана" "$(rc "$S/a" "$S/c/hooks/x.sh")" 2