diff --git a/scripts/cleanup-merged-worktrees.sh b/scripts/cleanup-merged-worktrees.sh index 0c9cd672..3e8c5d0b 100755 --- a/scripts/cleanup-merged-worktrees.sh +++ b/scripts/cleanup-merged-worktrees.sh @@ -5,17 +5,37 @@ # каждой background session. После merge PR worktree остаётся locked # и не удаляется автоматически — за неделю накапливаются десятки. # -# Скрипт сравнивает branch каждого worktree с `forgejo/main`; если branch -# полностью в main (merged) — удаляет worktree с `--force` (worktree -# заперт супервизором). Затем `git worktree prune` чистит references. +# Скрипт определяет, что worktree безопасно удалить, ЕСЛИ ОБА условия: +# 1. branch отсутствует на remote (см. ниже, почему не ancestor-check) +# 2. working tree чист (`git status --porcelain` пуст — untracked-only +# мусор типа node_modules/__pycache__ игнорируется, но staged/modified +# TRACKED-изменения блокируют удаление — там может быть незакоммиченная +# работа, которую стоит разобрать руками) +# Затем `git worktree prune` чистит references. +# +# ПОЧЕМУ НЕ `git merge-base --is-ancestor branch main` (ревизия 2026-07-04): +# репозиторий мержит PR через SQUASH (см. git-pr.md § Auto-merge policy) — +# squash создаёт НОВЫЙ коммит в main, оригинальный коммит ветки НИКОГДА не +# становится предком main, даже если PR давно смержен. Ancestor-check +# систематически давал ложный "не смержено" на каждой squash-смерженной +# ветке — это и есть причина, по которой скрипт годами не расчищал +# 60+ забытых worktree. Прокси "ветка удалена на remote" надёжен, потому что +# конвенция репо — всегда `delete_branch: true` при мерже (git-pr.md). +# +# ВАЖНО (2026-07-04): удаление worktree, управляемого супервизором Claude +# Code, из ОБЫЧНОЙ сессионной Bash-песочницы систематически не срабатывает +# (`git worktree remove -f -f` тихо не удаляет каталог, даже когда +# ответственный agent-pid уже мёртв) — похоже на намеренную защиту +# harness-managed состояния. Запускай этот скрипт ЛИБО из терминала вне +# активной Claude Code сессии, ЛИБО через `Ctrl+X` дважды в agent view +# для конкретной сессии (см. CLAUDE.md § Multi-session workflow). # # Запускать вручную или через cron (weekly recommended): # 0 9 * * MON cd /path/to/gendesign && ./scripts/cleanup-merged-worktrees.sh # # Safe: -# - Не трогает unmerged branches (in-progress work, PR ещё не merged) -# - Не трогает worktree.lock с твоими running sessions (force нужен только -# потому что Claude Code ставит lock автоматически) +# - Не трогает branches, ещё живые на remote (in-progress work, PR ещё не merged) +# - Не трогает worktree с незакоммиченными TRACKED-изменениями (dirty tree) # - Логи action — каждый удалённый branch выводится отдельной строкой # # Reference: https://code.claude.com/docs/en/worktrees#clean-up-worktrees @@ -25,8 +45,17 @@ set -e REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" cd "$REPO_ROOT" -echo "Fetching forgejo/main..." -git fetch forgejo main --quiet +# Remote-алиас непереносим между чекаутами: где-то `forgejo`, где-то `origin` +# (см. git-pr.md — конвенция документирует `forgejo`, но не все клоны его +# заводят). Автоопределяем вместо жёсткого хардкода. +if git remote | grep -qx forgejo; then + REMOTE=forgejo +else + REMOTE=origin +fi + +echo "Fetching $REMOTE (remote alias auto-detected)..." +git fetch "$REMOTE" --quiet BEFORE=$(git worktree list | wc -l) SIZE_BEFORE=$(du -sh .claude/worktrees/ 2>/dev/null | awk '{print $1}') @@ -35,6 +64,7 @@ echo "Scanning $((BEFORE - 1)) worktrees (skip main)..." REMOVED=0 KEPT=0 +DIRTY=0 # Parse `git worktree list --porcelain` into "path branch" pairs. git worktree list --porcelain | awk '/^worktree/{w=$2} /^branch/{b=$2} /^$/{print w" "b}' | \ @@ -49,15 +79,29 @@ while read path branch; do continue fi - if git merge-base --is-ancestor "$short" forgejo/main 2>/dev/null; then - if git worktree remove --force "$path" 2>/dev/null; then - echo " removed: $short" - REMOVED=$((REMOVED + 1)) - else - echo " failed: $short ($path)" - fi - else + # "Merged" proxy: branch no longer exists on remote (squash-merge convention + # means ancestor-check would false-negative on every merged branch — see + # header comment). A branch a human/bot deliberately deleted without + # merging is rare and the local copy is stale garbage either way. + if git ls-remote --exit-code --heads "$REMOTE" "$short" >/dev/null 2>&1; then KEPT=$((KEPT + 1)) + continue + fi + + # Dirty gate: only untracked noise (node_modules, __pycache__, build + # artifacts) is tolerated. Any staged/modified TRACKED file blocks + # removal — that may be unlanded work worth rescuing by hand. + if [ -n "$(git -C "$path" status --porcelain | grep -v '^??')" ]; then + echo " SKIP (uncommitted tracked changes): $short ($path)" + DIRTY=$((DIRTY + 1)) + continue + fi + + if git worktree remove -f -f "$path" 2>/dev/null; then + echo " removed: $short" + REMOVED=$((REMOVED + 1)) + else + echo " failed: $short ($path) — see header comment re: harness-managed removal" fi done @@ -69,3 +113,4 @@ SIZE_AFTER=$(du -sh .claude/worktrees/ 2>/dev/null | awk '{print $1}') echo "" echo "Before: $((BEFORE - 1)) worktrees, $SIZE_BEFORE" echo "After: $((AFTER - 1)) worktrees, $SIZE_AFTER" +echo "Dirty (skipped, needs manual review): $DIRTY"