fix(devops): cleanup-merged-worktrees.sh — 3 bugs found live-diagnosing 60+ stale worktrees
All checks were successful
CI / changes (pull_request) Successful in 8s
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
All checks were successful
CI / changes (pull_request) Successful in 8s
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
1. remote-alias хардкод 'forgejo' — этот чекаут использует 'origin', скрипт падал на первой же команде. Автодетект вместо хардкода. 2. merge-check через 'git merge-base --is-ancestor branch main' структурно несовместим со squash-merge конвенцией репо (git-pr.md): squash создаёт НОВЫЙ коммит в main, оригинальный коммит ветки никогда не становится предком — ancestor-check ложно-отрицателен на КАЖДОЙ смерженной ветке. Заменено на надёжный прокси: ветка отсутствует на remote (конвенция репо — всегда delete_branch:true при мерже). 3. 'git worktree remove --force' (одинарный) недостаточен для worktree, залоченного супервизором Claude Code — нужен двойной '-f -f' (force применяется дважды: снятие lock + подтверждение удаления). Плюс dirty-gate: worktree с незакоммиченными TRACKED-изменениями (не просто untracked-мусором вроде node_modules) теперь пропускается с явным SKIP — не удаляется вслепую, если там могла остаться нерасхваченная работа. Найдено вживую при диагностике: 69 worktree накопилось, 21 безопасны (branch снят с remote + чистое дерево), 30 dirty (требуют разбора вручную), 13 всё ещё в работе (branch жив на remote). Само удаление НЕ проведено этим коммитом — попытка force-remove из текущей сессии не сработала (см. комментарий в скрипте про harness-managed worktree).
This commit is contained in:
parent
8030f8ecef
commit
b03a12204a
1 changed files with 61 additions and 16 deletions
|
|
@ -5,17 +5,37 @@
|
||||||
# каждой background session. После merge PR worktree остаётся locked
|
# каждой background session. После merge PR worktree остаётся locked
|
||||||
# и не удаляется автоматически — за неделю накапливаются десятки.
|
# и не удаляется автоматически — за неделю накапливаются десятки.
|
||||||
#
|
#
|
||||||
# Скрипт сравнивает branch каждого worktree с `forgejo/main`; если branch
|
# Скрипт определяет, что worktree безопасно удалить, ЕСЛИ ОБА условия:
|
||||||
# полностью в main (merged) — удаляет worktree с `--force` (worktree
|
# 1. branch отсутствует на remote (см. ниже, почему не ancestor-check)
|
||||||
# заперт супервизором). Затем `git worktree prune` чистит references.
|
# 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):
|
# Запускать вручную или через cron (weekly recommended):
|
||||||
# 0 9 * * MON cd /path/to/gendesign && ./scripts/cleanup-merged-worktrees.sh
|
# 0 9 * * MON cd /path/to/gendesign && ./scripts/cleanup-merged-worktrees.sh
|
||||||
#
|
#
|
||||||
# Safe:
|
# Safe:
|
||||||
# - Не трогает unmerged branches (in-progress work, PR ещё не merged)
|
# - Не трогает branches, ещё живые на remote (in-progress work, PR ещё не merged)
|
||||||
# - Не трогает worktree.lock с твоими running sessions (force нужен только
|
# - Не трогает worktree с незакоммиченными TRACKED-изменениями (dirty tree)
|
||||||
# потому что Claude Code ставит lock автоматически)
|
|
||||||
# - Логи action — каждый удалённый branch выводится отдельной строкой
|
# - Логи action — каждый удалённый branch выводится отдельной строкой
|
||||||
#
|
#
|
||||||
# Reference: https://code.claude.com/docs/en/worktrees#clean-up-worktrees
|
# Reference: https://code.claude.com/docs/en/worktrees#clean-up-worktrees
|
||||||
|
|
@ -25,8 +45,17 @@ set -e
|
||||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
cd "$REPO_ROOT"
|
cd "$REPO_ROOT"
|
||||||
|
|
||||||
echo "Fetching forgejo/main..."
|
# Remote-алиас непереносим между чекаутами: где-то `forgejo`, где-то `origin`
|
||||||
git fetch forgejo main --quiet
|
# (см. 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)
|
BEFORE=$(git worktree list | wc -l)
|
||||||
SIZE_BEFORE=$(du -sh .claude/worktrees/ 2>/dev/null | awk '{print $1}')
|
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
|
REMOVED=0
|
||||||
KEPT=0
|
KEPT=0
|
||||||
|
DIRTY=0
|
||||||
|
|
||||||
# Parse `git worktree list --porcelain` into "path branch" pairs.
|
# Parse `git worktree list --porcelain` into "path branch" pairs.
|
||||||
git worktree list --porcelain | awk '/^worktree/{w=$2} /^branch/{b=$2} /^$/{print w" "b}' | \
|
git worktree list --porcelain | awk '/^worktree/{w=$2} /^branch/{b=$2} /^$/{print w" "b}' | \
|
||||||
|
|
@ -49,15 +79,29 @@ while read path branch; do
|
||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if git merge-base --is-ancestor "$short" forgejo/main 2>/dev/null; then
|
# "Merged" proxy: branch no longer exists on remote (squash-merge convention
|
||||||
if git worktree remove --force "$path" 2>/dev/null; then
|
# means ancestor-check would false-negative on every merged branch — see
|
||||||
echo " removed: $short"
|
# header comment). A branch a human/bot deliberately deleted without
|
||||||
REMOVED=$((REMOVED + 1))
|
# merging is rare and the local copy is stale garbage either way.
|
||||||
else
|
if git ls-remote --exit-code --heads "$REMOTE" "$short" >/dev/null 2>&1; then
|
||||||
echo " failed: $short ($path)"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
KEPT=$((KEPT + 1))
|
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
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
|
|
@ -69,3 +113,4 @@ SIZE_AFTER=$(du -sh .claude/worktrees/ 2>/dev/null | awk '{print $1}')
|
||||||
echo ""
|
echo ""
|
||||||
echo "Before: $((BEFORE - 1)) worktrees, $SIZE_BEFORE"
|
echo "Before: $((BEFORE - 1)) worktrees, $SIZE_BEFORE"
|
||||||
echo "After: $((AFTER - 1)) worktrees, $SIZE_AFTER"
|
echo "After: $((AFTER - 1)) worktrees, $SIZE_AFTER"
|
||||||
|
echo "Dirty (skipped, needs manual review): $DIRTY"
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue