ff-clean.sh 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136
  1. #!/usr/bin/env bash
  2. # ff-clean.sh - reclaim a fleetflow run's lanes, branches, and cache dirs.
  3. #
  4. # Per lane (worktree lane under <run>/wt-<id>):
  5. # zero commits + clean -> git worktree remove + branch -D ("removed")
  6. # zero commits + dirty -> "kept" unless --force (--force removes)
  7. # N>0 commits -> "kept (N commits)" (never auto-remove)
  8. # locked ACL-litter dir -> "locked (needs elevation)" (continue, non-fatal)
  9. # Then removes each lane's cache dir under FLEETFLOW_CACHE_ROOT (default
  10. # $HOME/.fleet-worker/cache/<run>-<id>). Removal never aborts the run: a locked
  11. # lane is reported and skipped, and the script exits 0 once it has tried them all.
  12. # stdout: one TSV line per lane: id<TAB>status<TAB>detail. stderr: chatter.
  13. #
  14. # Exit codes: 0 done (incl. some locked lanes) | 2 usage / no such run
  15. set -u
  16. . "$(dirname "${BASH_SOURCE[0]}")/_env.sh"
  17. FF_VERSION="1.1.0"
  18. usage() {
  19. cat <<'EOF'
  20. Usage: ff-clean.sh --run NAME [--repo PATH] [--force]
  21. --run NAME run name under <repo>/.fleetflow/
  22. --repo PATH repo root (default: git toplevel of cwd)
  23. --force also remove DIRTY zero-commit lanes (committed lanes are
  24. ALWAYS kept - land or branch them first, then clean)
  25. Lanes with commits are never removed (their work would be lost); clean only
  26. after ff-collect has gated them and the orchestrator has landed/branched the
  27. keepers. Locked codex sandbox-litter dirs are reported and skipped.
  28. EXAMPLES
  29. ff-clean.sh --run currency
  30. ff-clean.sh --run currency --force
  31. ff-clean.sh --run currency --repo /path/to/repo | column -t
  32. EOF
  33. }
  34. err() { echo "ff-clean: $*" >&2; }
  35. emit() { printf '%s\t%s\t%s\n' "$1" "$2" "$3"; } # id <TAB> status <TAB> detail
  36. RUN="" REPO="" FORCE=0
  37. while [ $# -gt 0 ]; do
  38. case "$1" in
  39. --run) RUN="${2:-}"; shift 2 ;;
  40. --repo) REPO="${2:-}"; shift 2 ;;
  41. --force) FORCE=1; shift ;;
  42. -h|--help) usage; exit 0 ;;
  43. *) err "unknown argument: $1"; usage >&2; exit 2 ;;
  44. esac
  45. done
  46. command -v jq >/dev/null || { err "jq required"; exit 2; }
  47. command -v git >/dev/null || { err "git required"; exit 2; }
  48. [ -n "$RUN" ] || { err "--run required"; usage >&2; exit 2; }
  49. [ -n "$REPO" ] || REPO="$(git rev-parse --show-toplevel 2>/dev/null)" || true
  50. [ -n "$REPO" ] && [ -d "$REPO" ] || { err "not in a git repo (or --repo invalid)"; exit 2; }
  51. RUNDIR="$REPO/.fleetflow/$RUN"
  52. MANIFEST="$RUNDIR/manifest.json"
  53. [ -d "$RUNDIR" ] || { err "no such run: $RUNDIR"; exit 2; }
  54. CACHE_ROOT="${FLEETFLOW_CACHE_ROOT:-$HOME/.fleet-worker/cache}"
  55. BASE="$(jq -r '.base // "main"' "$MANIFEST" 2>/dev/null)"; [ -n "$BASE" ] || BASE="main"
  56. # enumerate lane ids: manifest packets (authoritative) + any wt-* dirs left on
  57. # disk (e.g. manifest pruned but lanes remain) + journal started ids (fallback).
  58. list_lane_ids() {
  59. local ids=""
  60. [ -f "$MANIFEST" ] && ids="$(jq -r '.packets[].id' "$MANIFEST" 2>/dev/null)"
  61. local d
  62. for d in "$RUNDIR"/wt-*; do
  63. [ -d "$d" ] || continue
  64. ids="$ids
  65. $(basename "$d" | sed 's/^wt-//')"
  66. done
  67. if [ -z "$ids" ] && [ -f "$RUNDIR/journal.jsonl" ]; then
  68. ids="$(jq -r 'select(.type=="started") | .id' "$RUNDIR/journal.jsonl" 2>/dev/null)"
  69. fi
  70. printf '%s\n' "$ids" | awk 'NF && !seen[$0]++'
  71. }
  72. # remove_lane <id> <force>: worktree remove (+ branch -D on success). Reports
  73. # "locked" if the OS refuses to delete (codex AppContainer-ACL litter).
  74. remove_lane() {
  75. local id="$1" f="$2" wt="$RUNDIR/wt-$id" br="fleetflow/$RUN/$id" wflag=""
  76. [ "$f" = 1 ] && wflag="--force"
  77. if git -C "$REPO" worktree remove $wflag "$wt" 2>>"$RUNDIR/$id.clean.err"; then
  78. git -C "$REPO" branch -D "$br" >/dev/null 2>>"$RUNDIR/$id.clean.err" \
  79. && emit "$id" removed "worktree + branch gone" \
  80. || emit "$id" removed "worktree gone (branch delete skipped)"
  81. else
  82. emit "$id" locked "needs elevation (see $RUNDIR/$id.clean.err)"
  83. fi
  84. }
  85. # clean_cache <id>: best-effort removal of the lane's cache/tmp dir.
  86. clean_cache() {
  87. local id="$1" c="$CACHE_ROOT/$RUN-$id"
  88. [ -d "$c" ] || return 0
  89. if rm -rf "$c" 2>>"$RUNDIR/$id.clean.err"; then err "cache removed: $c"
  90. else err "cache locked (needs elevation): $c"; fi
  91. }
  92. NLANES="$(list_lane_ids | wc -l | tr -d ' ')"
  93. err "cleaning run '$RUN': $NLANES lane(s); FORCE=$FORCE; base=$BASE; cache=$CACHE_ROOT"
  94. if [ "$NLANES" = "0" ]; then
  95. err "no lanes to clean"
  96. exit 0
  97. fi
  98. # base ref must exist for rev-list counting; fall back to HEAD once for the run
  99. git -C "$REPO" show-ref --verify --quiet "refs/heads/$BASE" || BASE="HEAD"
  100. while read -r id; do
  101. [ -n "$id" ] || continue
  102. wt="$RUNDIR/wt-$id"
  103. if [ -d "$wt" ]; then
  104. commits="$(git -C "$wt" rev-list --count "$BASE..HEAD" 2>/dev/null || echo 0)"
  105. dirt="$(git -C "$wt" status --porcelain 2>/dev/null)"
  106. if [ "${commits:-0}" -eq 0 ] && [ -z "$dirt" ]; then
  107. remove_lane "$id" 0
  108. elif [ "${commits:-0}" -eq 0 ] && [ -n "$dirt" ]; then
  109. if [ "$FORCE" = 1 ]; then remove_lane "$id" 1
  110. else emit "$id" kept "dirty, zero commits (use --force)"; fi
  111. else
  112. emit "$id" kept "$commits commits"
  113. fi
  114. else
  115. emit "$id" kept "no worktree lane"
  116. fi
  117. clean_cache "$id"
  118. done < <(list_lane_ids)
  119. exit 0