#!/usr/bin/env bash # ff-clean.sh - reclaim a fleetflow run's lanes, branches, and cache dirs. # # Per lane (worktree lane under /wt-): # zero commits + clean -> git worktree remove + branch -D ("removed") # zero commits + dirty -> "kept" unless --force (--force removes) # N>0 commits -> "kept (N commits)" (never auto-remove) # locked ACL-litter dir -> "locked (needs elevation)" (continue, non-fatal) # Then removes each lane's cache dir under FLEETFLOW_CACHE_ROOT (default # $HOME/.fleet-worker/cache/-). Removal never aborts the run: a locked # lane is reported and skipped, and the script exits 0 once it has tried them all. # stdout: one TSV line per lane: idstatusdetail. stderr: chatter. # # Exit codes: 0 done (incl. some locked lanes) | 2 usage / no such run set -u . "$(dirname "${BASH_SOURCE[0]}")/_env.sh" FF_VERSION="1.1.0" usage() { cat <<'EOF' Usage: ff-clean.sh --run NAME [--repo PATH] [--force] --run NAME run name under /.fleetflow/ --repo PATH repo root (default: git toplevel of cwd) --force also remove DIRTY zero-commit lanes (committed lanes are ALWAYS kept - land or branch them first, then clean) Lanes with commits are never removed (their work would be lost); clean only after ff-collect has gated them and the orchestrator has landed/branched the keepers. Locked codex sandbox-litter dirs are reported and skipped. EXAMPLES ff-clean.sh --run currency ff-clean.sh --run currency --force ff-clean.sh --run currency --repo /path/to/repo | column -t EOF } err() { echo "ff-clean: $*" >&2; } emit() { printf '%s\t%s\t%s\n' "$1" "$2" "$3"; } # id status detail RUN="" REPO="" FORCE=0 while [ $# -gt 0 ]; do case "$1" in --run) RUN="${2:-}"; shift 2 ;; --repo) REPO="${2:-}"; shift 2 ;; --force) FORCE=1; shift ;; -h|--help) usage; exit 0 ;; *) err "unknown argument: $1"; usage >&2; exit 2 ;; esac done command -v jq >/dev/null || { err "jq required"; exit 2; } command -v git >/dev/null || { err "git required"; exit 2; } [ -n "$RUN" ] || { err "--run required"; usage >&2; exit 2; } [ -n "$REPO" ] || REPO="$(git rev-parse --show-toplevel 2>/dev/null)" || true [ -n "$REPO" ] && [ -d "$REPO" ] || { err "not in a git repo (or --repo invalid)"; exit 2; } RUNDIR="$REPO/.fleetflow/$RUN" MANIFEST="$RUNDIR/manifest.json" [ -d "$RUNDIR" ] || { err "no such run: $RUNDIR"; exit 2; } CACHE_ROOT="${FLEETFLOW_CACHE_ROOT:-$HOME/.fleet-worker/cache}" BASE="$(jq -r '.base // "main"' "$MANIFEST" 2>/dev/null)"; [ -n "$BASE" ] || BASE="main" # enumerate lane ids: manifest packets (authoritative) + any wt-* dirs left on # disk (e.g. manifest pruned but lanes remain) + journal started ids (fallback). list_lane_ids() { local ids="" [ -f "$MANIFEST" ] && ids="$(jq -r '.packets[].id' "$MANIFEST" 2>/dev/null)" local d for d in "$RUNDIR"/wt-*; do [ -d "$d" ] || continue ids="$ids $(basename "$d" | sed 's/^wt-//')" done if [ -z "$ids" ] && [ -f "$RUNDIR/journal.jsonl" ]; then ids="$(jq -r 'select(.type=="started") | .id' "$RUNDIR/journal.jsonl" 2>/dev/null)" fi printf '%s\n' "$ids" | awk 'NF && !seen[$0]++' } # remove_lane : worktree remove (+ branch -D on success). Reports # "locked" if the OS refuses to delete (codex AppContainer-ACL litter). remove_lane() { local id="$1" f="$2" wt="$RUNDIR/wt-$id" br="fleetflow/$RUN/$id" wflag="" [ "$f" = 1 ] && wflag="--force" if git -C "$REPO" worktree remove $wflag "$wt" 2>>"$RUNDIR/$id.clean.err"; then git -C "$REPO" branch -D "$br" >/dev/null 2>>"$RUNDIR/$id.clean.err" \ && emit "$id" removed "worktree + branch gone" \ || emit "$id" removed "worktree gone (branch delete skipped)" else emit "$id" locked "needs elevation (see $RUNDIR/$id.clean.err)" fi } # clean_cache : best-effort removal of the lane's cache/tmp dir. clean_cache() { local id="$1" c="$CACHE_ROOT/$RUN-$id" [ -d "$c" ] || return 0 if rm -rf "$c" 2>>"$RUNDIR/$id.clean.err"; then err "cache removed: $c" else err "cache locked (needs elevation): $c"; fi } NLANES="$(list_lane_ids | wc -l | tr -d ' ')" err "cleaning run '$RUN': $NLANES lane(s); FORCE=$FORCE; base=$BASE; cache=$CACHE_ROOT" if [ "$NLANES" = "0" ]; then err "no lanes to clean" exit 0 fi # base ref must exist for rev-list counting; fall back to HEAD once for the run git -C "$REPO" show-ref --verify --quiet "refs/heads/$BASE" || BASE="HEAD" while read -r id; do [ -n "$id" ] || continue wt="$RUNDIR/wt-$id" if [ -d "$wt" ]; then commits="$(git -C "$wt" rev-list --count "$BASE..HEAD" 2>/dev/null || echo 0)" dirt="$(git -C "$wt" status --porcelain 2>/dev/null)" if [ "${commits:-0}" -eq 0 ] && [ -z "$dirt" ]; then remove_lane "$id" 0 elif [ "${commits:-0}" -eq 0 ] && [ -n "$dirt" ]; then if [ "$FORCE" = 1 ]; then remove_lane "$id" 1 else emit "$id" kept "dirty, zero commits (use --force)"; fi else emit "$id" kept "$commits commits" fi else emit "$id" kept "no worktree lane" fi clean_cache "$id" done < <(list_lane_ids) exit 0