fix(hooks): финальное ревью — checkout main, префиксы с опциями, ссылки, литеральные пути, push текущей ветки (#6)
I-1, I-2, M-3, M-4, M-5 (переоценены в Important), M-8; спека и design.md синхронизированы (M-7). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
2fd2396c5c
commit
2f96a13861
@ -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 <x> без «--»: ветка или путь — неизвестно; защищённые имена и «-» — точно смена ветки,
|
||||
# переменная — ветка не определена (последующие 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
|
||||
|
||||
@ -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).
|
||||
|
||||
@ -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 <workspace>/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
|
||||
|
||||
@ -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)")"
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user