diff --git a/AGENTS.md b/AGENTS.md index c54ced4f59..c0632cf057 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -578,14 +578,31 @@ own dedicated branch, and you MUST confirm the base branch with the operator bef # HARD LINKS (`cp -al`), never a symlink: ~5s for the whole tree and near-zero extra # disk (the inodes are shared), and unlike a symlink it does not break the dev server. cp -al "$(git -C rev-parse --show-toplevel)/node_modules" node_modules + # `.husky/_` is gitignored, so a fresh worktree does NOT have it and + # `core.hooksPath=.husky/_` then points at a directory that does not exist — + # every pre-commit gate goes silently mute. Copy it too. + cp -a "$(git -C rev-parse --show-toplevel)/.husky/_" .husky/_ ``` + `scripts/dev/new-worktree.sh [base]` does all of the above (canonical path, + hard-linked `node_modules`, `.husky/_`) and then **verifies** the hook is actually + executable, so prefer it over running the steps by hand. + **Never `ln -s` node_modules.** Turbopack rejects a symlink that resolves outside the project root, so `npm run dev` dies with a FATAL panic (`Symlink [project]/node_modules is invalid, it points out of the filesystem root`) while typecheck, lint and the test runners all keep passing — the error names "filesystem root", not the worktree, so it reads like a Next/build bug and costs real time to trace (incident 2026-07-31, #9043). + **A worktree without `.husky/_` runs NO pre-commit gate — and says nothing.** `git` + resolves `core.hooksPath` relative to the worktree top; when the directory is missing it + simply finds no hook and commits. Nothing is printed, the commit succeeds, and the + identity/lint/docs gates never ran. This is how 59 commits carrying a stale identity + override (name of a contributor + the maintainer's e-mail) got past + `scripts/check/check-git-identity.sh` between 2026-08-29 and 09-02 — they were all made in + `cp -al` worktrees. Verify with `ls .husky/_/pre-commit` inside a new worktree, or just use + `scripts/dev/new-worktree.sh`, which fails loudly when the hook is not executable. + 3. **Work, commit, push, open the PR — all from inside the worktree.** Never `git checkout` a different branch inside a worktree another session might share. 4. **Tear down only your own** worktree + branch when done, from the main checkout: diff --git a/scripts/dev/new-worktree.sh b/scripts/dev/new-worktree.sh new file mode 100755 index 0000000000..b8fa5559d3 --- /dev/null +++ b/scripts/dev/new-worktree.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env sh +# Cria uma worktree isolada seguindo o protocolo obrigatório do AGENTS.md +# (Git Workflow → "Worktree isolation" / Hard Rule #19), incluindo os dois +# passos que são fáceis de esquecer e falham em silêncio: +# +# 1. node_modules por HARD LINK (`cp -al`), nunca symlink — um symlink que +# resolve fora da raiz mata o Turbopack com um FATAL que culpa a +# "filesystem root" e não a worktree (incidente 2026-07-31, #9043). +# 2. `.husky/_` copiado — é gitignored, então uma worktree nova NÃO o tem, e +# `core.hooksPath=.husky/_` aponta para um diretório inexistente: TODOS os +# hooks de pre-commit ficam mudos, sem aviso nenhum. Foi assim que 59 +# commits com identidade trocada passaram pelo gate entre 29/08 e 02/09 +# (ver .mailmap e scripts/check/check-git-identity.sh). +# +# Uso: scripts/dev/new-worktree.sh [base-branch] +# Ex.: scripts/dev/new-worktree.sh fix/12345-algo release/v3.8.51 + +set -e + +BRANCH="$1" +BASE="$2" + +if [ -z "$BRANCH" ]; then + echo "uso: scripts/dev/new-worktree.sh [base-branch]" >&2 + echo " ex: scripts/dev/new-worktree.sh fix/12345-algo release/v3.8.51" >&2 + exit 1 +fi + +# O checkout PRINCIPAL, mesmo quando este script roda de dentro de outra worktree: +# `--show-toplevel` devolveria a worktree atual, e a nova nasceria aninhada nela. +MAIN=$(dirname "$(git rev-parse --path-format=absolute --git-common-dir)") +cd "$MAIN" + +# Sem base explícita, usa a release ativa (maior release/* por semver) — nunca +# `main` e nunca "a branch em que eu estou", conforme a Hard Rule #19. +if [ -z "$BASE" ]; then + BASE=$(git ls-remote --heads origin 'refs/heads/release/*' \ + | sed 's#.*refs/heads/##' | sort -V | tail -1) + [ -z "$BASE" ] && { echo "não consegui resolver a release ativa; passe a base explicitamente" >&2; exit 1; } + echo "base não informada — usando a release ativa: $BASE" +fi + +DIR=".claude/worktrees/${BRANCH##*/}" +[ -e "$DIR" ] && { echo "já existe: $DIR" >&2; exit 1; } + +git fetch origin "$BASE" --quiet +git worktree add "$DIR" -b "$BRANCH" "origin/$BASE" + +# `.husky/_` PRIMEIRO: é minúsculo e é o que decide se os gates locais rodam. +# Copiar node_modules antes seria arriscar abortar (set -e) numa árvore de ~10 GB +# e deixar a worktree sem hook nenhum — exatamente o defeito que este script existe +# para impedir. +if [ -d "$MAIN/.husky/_" ]; then + cp -a "$MAIN/.husky/_" "$DIR/.husky/_" +else + echo "AVISO: .husky/_ não existe no checkout principal — rode 'npm install' lá primeiro" >&2 +fi + +# node_modules: hard links, ~5s e disco quase zero (inodes compartilhados). +# Um `cp -al SRC DEST` com DEST já existente aninharia SRC DENTRO dele +# (node_modules/node_modules), então DEST não pode existir aqui. +if [ -d "$MAIN/node_modules/node_modules" ]; then + echo "AVISO: $MAIN/node_modules/node_modules existe — resíduo de um cp -al aninhado." >&2 + echo " Ele infla a cópia e esgota o limite de hard links; convém removê-lo." >&2 +fi +if [ -d "$MAIN/node_modules" ]; then + # Falha parcial (limite de hard links, disco) não pode derrubar a worktree inteira: + # os hooks já estão no lugar e o npm install continua sendo uma saída válida. + if cp -al "$MAIN/node_modules" "$DIR/node_modules" 2>"$DIR/.cp-node-modules.log"; then + echo "node_modules: $(ls "$DIR/node_modules" | wc -l) entradas (hard links)" + rm -f "$DIR/.cp-node-modules.log" + else + echo "AVISO: a cópia de node_modules falhou parcialmente (veja $DIR/.cp-node-modules.log)." >&2 + echo " Primeiras linhas:" >&2 + head -3 "$DIR/.cp-node-modules.log" >&2 + fi +else + echo "AVISO: node_modules não existe no checkout principal — rode 'npm install' lá primeiro" >&2 +fi + +# Verificação: o hook precisa estar REALMENTE ativo, não apenas presente. +HOOKS_PATH=$(git -C "$DIR" config --get core.hooksPath || echo ".git/hooks") +if [ -x "$DIR/$HOOKS_PATH/pre-commit" ]; then + echo "hooks: ativos ($HOOKS_PATH/pre-commit)" +else + echo "AVISO: pre-commit NÃO está ativo em $DIR/$HOOKS_PATH — os gates locais não vão rodar" >&2 + exit 1 +fi + +echo +echo "pronto: $DIR (branch $BRANCH, base $BASE)" +echo " cd $DIR"