#!/usr/bin/env bash
# gc-abandoned.sh  -  reap runs that stopped and were never picked back up.
#
# The gap this fills: gc-worktrees.sh deliberately skips REGISTERED worktrees,
# because a registered worktree belongs to a live run. That is right, and it
# means nothing at all collects a run that simply stopped - its worktree stays
# registered forever. Measured on one machine: 20 runs reading `in_progress`,
# every one more than a day old, one of them 157 days, holding 28 worktrees
# across 7 repos and 22.8 GB.
#
# Three things keep this from being a foot-gun, and each is a rule rather than a
# heuristic:
#
#   1. A run WAITING FOR YOU is never reaped. An open PR, the Commit or Report
#      phase, or `status: awaiting_input` means the work landed and the pipeline
#      is holding for an answer by design. That is finished work, not residue.
#      A run parked with `verificationFailed` or `waitingFor` is held the same
#      way, even though a rejected verification records `status: failed`.
#   2. A path that is not strictly inside `<repo>/.worktrees/` is never removed.
#      This is not theoretical: four state files on that machine record
#      `worktreePath` as the REPO ROOT, so a sweep that trusted the field would
#      have deleted the checkout. Inside `.worktrees/`, only a path that
#      `git worktree list --porcelain` reports as a worktree top level is a
#      candidate, and it is removed only by `git worktree remove`; when git
#      refuses, the directory stays and the report says `remove-failed`.
#   3. Uncommitted work is never destroyed. It goes into a STASH ENTRY labelled
#      `autopilot/abandoned/<task-id>` - `git stash list` finds it - and the
#      directory is left in place, which costs disk and keeps the work. Losing a
#      day of edits is worse than 750 MB. No branch is created: a branch made
#      after `stash push` points at HEAD and holds none of the stashed work.
#
# TWO PASSES, because the state files are not where the disk is. Measured across
# 28 worktrees holding 23 GB: only 5 of them (1.5 GB) belong to a run that still
# reads `in_progress`. 19 of them (15.6 GB) have NO state file at all, and 3 more
# (5.1 GB) belong to runs that finished and whose worktree outlived them. A
# state-driven sweep alone reaps 4% of the problem, so pass 2 walks the worktrees
# themselves and asks each one what its run is doing.
#
# Pass 2 keeps every rule above and adds the one that matters most: **a worktree
# with no run state on record is REPORTED, never removed.** `.worktrees/` is not
# exclusively ours - the measured machine holds `174`, `pr-4051`, `task-1` and
# `create-screen` there, all hand-made by the user, one of them in active use.
# Nothing distinguishes those from a pipeline worktree whose log was pruned, so
# the honest move is to show them with their size and let the user decide. They
# are the 15.6 GB, and pointing at them is worth more than a rule that guesses.
#
# So --yes removes exactly two things: a worktree whose run is recorded as
# finished, and one whose run is recorded as stopped past its age limit.
#
# Two ages, because two different things are being reaped. A run left at a Phase
# 0 question never started - Phase 0 is almost entirely questions - so there is
# no code to lose and a short window is safe. A run that died mid-development
# may hold real edits, so it gets a long one and the stash rule above.
#
# State is moved to `<run-dir>/artifacts/` before the worktree goes, so the log,
# the findings and the phase history survive the sweep. The same recovery
# contract Phase 4 uses when it removes a finished worktree.
#
# SAFE BY DEFAULT: dry-run. Removes nothing until you pass --yes.
#
# `--only <file>` narrows both passes to the worktree paths listed in the file,
# one per line. The continuous-mode runner uses it to hand over the worktrees it
# created and nothing else: a run it did not start is neither reported nor
# touched, its state included. Pass 2 then walks the listed paths instead of
# `--repos`, because a runner's repos can live anywhere on the disk.
# `--report <file>` appends one JSON line per candidate - task, path, action,
# reason, idle days, size - for a caller that renders its own report.
#
# Usage:
#   gc-abandoned.sh                      # dry-run, default ages
#   gc-abandoned.sh --yes                # apply
#   gc-abandoned.sh --days=14            # mid-development runs idle > 14 days
#   gc-abandoned.sh --phase0-days=3      # Phase 0 leftovers idle > 3 days
#   gc-abandoned.sh --logs <dir>         # another state root (tests)
#   gc-abandoned.sh --repos <dir>        # another checkout root (tests)
#   gc-abandoned.sh --state-only         # pass 1 only
#   gc-abandoned.sh --only <file>        # only the worktree paths listed in <file>
#   gc-abandoned.sh --report <file>      # also append one JSON line per candidate
#
# Exit: 0 on success (including "nothing to do"), 2 on usage error.

set -euo pipefail

LOGS="$HOME/.claude/logs/multi-agent"
REPOS="$HOME"
DELETE=0
DAYS=7
PHASE0_DAYS=1
STATE_ONLY=0
ONLY=""
REPORT=""

usage() {
  grep -E '^#( |$)' "$0" | sed -E 's/^# ?//'
}

while [ "$#" -gt 0 ]; do
  case "$1" in
    --yes | -y) DELETE=1; shift ;;
    --days=*) DAYS="${1#*=}"; shift ;;
    --phase0-days=*) PHASE0_DAYS="${1#*=}"; shift ;;
    --logs) LOGS="${2:-}"; shift 2 ;;
    --repos) REPOS="${2:-}"; shift 2 ;;
    --state-only) STATE_ONLY=1; shift ;;
    --only) ONLY="${2:-}"; shift 2 ;;
    --report) REPORT="${2:-}"; shift 2 ;;
    --abandoned) shift ;; # accepted so the command can forward its own flag
    -h | --help) usage; exit 0 ;;
    *) echo "gc-abandoned: unknown option $1" >&2; exit 2 ;;
  esac
done

case "$DAYS$PHASE0_DAYS" in *[!0-9]*) echo "gc-abandoned: --days and --phase0-days take whole days" >&2; exit 2 ;; esac

command -v jq >/dev/null 2>&1 || { echo "gc-abandoned: jq is required"; exit 0; }
if [ -n "$ONLY" ] && [ ! -f "$ONLY" ]; then echo "gc-abandoned: --only file not found: $ONLY" >&2; exit 2; fi

# Real paths, so a symlinked prefix (/var vs /private/var) cannot hide a match.
ONLY_LIST=""
if [ -n "$ONLY" ]; then
  while IFS= read -r p || [ -n "$p" ]; do
    [ -n "$p" ] || continue
    r=$(cd "$p" 2>/dev/null && pwd -P) || r="$p"
    ONLY_LIST="$ONLY_LIST
$r"
  done < "$ONLY"
fi
listed_only() { # $1 = real path; true when no --only list was given
  [ -z "$ONLY" ] && return 0
  [ -n "$1" ] || return 1
  printf '%s\n' "$ONLY_LIST" | grep -qxF "$1"
}

emit() { # task path action reason idle_days size_kb pass
  [ -n "$REPORT" ] || return 0
  jq -nc --arg task "$1" --arg path "$2" --arg action "$3" --arg reason "$4" \
    --argjson idle "${5:-0}" --argjson kb "${6:-0}" --argjson pass "$7" \
    '{task:$task, path:$path, action:$action, reason:$reason, idleDays:$idle, sizeKb:$kb, pass:$pass}' \
    >> "$REPORT" 2>/dev/null || true
}
[ -d "$LOGS" ] || { echo "gc-abandoned: nothing to do (no state at $LOGS)"; exit 0; }

# The phase from which a stopped run is "waiting on you", not residue, is the
# contract's threshold - Commit onward - not a literal pinned to one numbering.
# runs-index.mjs groups the same way from the same field.
SELF_DIR=$(cd "$(dirname "$0")" && pwd)
WAITING_FROM=$(jq -r '.thresholds.waitingFromPhase' "$SELF_DIR/../schemas/phases.json" 2>/dev/null || echo 4)
case "$WAITING_FROM" in *[!0-9]*|"") WAITING_FROM=4 ;; esac

# GNU form FIRST. `stat -f` is a valid GNU flag (--file-system) that succeeds and
# prints a mount point, so BSD-first silently returns a number that is not a
# timestamp. ADR-0012 lists this among the constructs that look cross-platform
# and are load-bearing; it is the ordering that matters, not the platform.
_mtime() { stat -c %Y "$1" 2>/dev/null || stat -f %m "$1" 2>/dev/null || echo "$NOW"; }

# A run gate-ledger.mjs parked for a person: `verificationFailed` (its own gate
# rejected the work, written with `status: failed`) or `waitingFor` (a question).
# Either holds its worktree for the person who looks, whatever `status` says.
# True when $1 (a physical path) is the top level of a worktree registered with
# the repo that holds its `.worktrees/` directory. Only such a path is ever
# removed, and only through `git worktree remove`: a subdirectory of a worktree
# or a directory made by hand under `.worktrees/` is not ours to delete, and git
# run inside one would act on the parent checkout.
registered_top() {
  local repo="${1%%/.worktrees/*}" line top
  while IFS= read -r line; do
    case "$line" in
      "worktree "*)
        top=$(cd "${line#worktree }" 2>/dev/null && pwd -P) || continue
        [ "$top" = "$1" ] && return 0 ;;
    esac
  done < <(git -C "$repo" worktree list --porcelain 2>/dev/null)
  return 1
}

HELD_JQ='if (.verificationFailed != null) or ((.waitingFor // "") != "") then "held" else "" end'

NOW=$(date +%s)
removed=0
freed_kb=0
kept_dirty=0
skipped_waiting=0
skipped_unsafe=0
skipped_unregistered=0
listed=0
# Paths pass 1 has already decided about. Pass 2 must not revisit them: pass 1
# stashes a dirty worktree and KEEPS it, which leaves the tree clean, and pass 2
# would then see a clean worktree whose run pass 1 just marked `failed` and
# remove the very thing that was preserved. The two passes are complementary,
# not sequential filters over the same set.
HANDLED=""

# Both layouts: phase-tracker.sh writes <task-id>/ and Phase 0 writes
# <project>/<task-id>/. Six task ids on the measured machine exist in both, so a
# sweep that walks one layout leaves the other copy claiming the run is live.
while IFS= read -r state; do
  [ -f "$state" ] || continue
  # Every jq read in this loop is forgiven. A malformed state file is exactly
  # what a sweep for abandoned runs expects to meet, and under `set -e` the
  # first one would end the sweep - leaving every run AFTER it untouched and
  # reporting a clean tree. An empty value already means "unknown", and the
  # rules below refuse to delete on unknown.
  status=$(jq -r '.status // ""' "$state" 2>/dev/null || true)
  # Only runs that CLAIM to be running. A file with no status is unknown, and
  # unknown is not a licence to delete.
  case "$status" in in_progress | paused | awaiting_input) ;; *) continue ;; esac

  task=$(jq -r '.taskId // .jiraId // ""' "$state" 2>/dev/null || true)
  [ -n "$task" ] || continue
  phase=$(jq -r '.currentPhase // "?"' "$state" 2>/dev/null || true)
  # `.pr` is an object in the schema and a bare URL string in three state files
  # on disk. `jq '.pr.url'` on a string errors, and with stderr suppressed that
  # reads as "no PR" - so a run whose PR is already open would be classed as dead
  # and reaped. Read the shape, do not assume it.
  pr=$(jq -r 'if (.pr|type)=="string" then .pr elif (.pr|type)=="object" then (.pr.url // .pr.number // "") else "" end | tostring' "$state" 2>/dev/null || true)
  wt=$(jq -r '.worktreePath // ""' "$state" 2>/dev/null || true)
  held=$(jq -r "$HELD_JQ" "$state" 2>/dev/null || true)

  # Rule 1: waiting for you is not residue.
  if [ "$status" = "awaiting_input" ] || [ -n "$held" ] || [ -n "$pr" ] || { [ "$phase" != "?" ] && [ "$phase" -ge "$WAITING_FROM" ] 2>/dev/null; }; then
    skipped_waiting=$((skipped_waiting + 1))
    continue
  fi

  mtime=$(_mtime "$state" || true)
  [ -n "$mtime" ] || continue
  age_days=$(((NOW - mtime) / 86400))
  if [ "$phase" = "0" ]; then
    limit="$PHASE0_DAYS"; kind="left at a Phase 0 question"
  else
    limit="$DAYS"; kind="stopped at Phase $phase"
  fi
  [ "$age_days" -gt "$limit" ] || continue

  # Rule 2: the path must be strictly inside a .worktrees/ directory. A repo
  # root recorded in worktreePath is the shape that would delete a checkout.
  safe_wt=""
  if [ -n "$wt" ] && [ -d "$wt" ] && [ ! -L "$wt" ]; then
    # A worktree path that no longer resolves is the ordinary case here.
    real=$(cd "$wt" 2>/dev/null && pwd -P || true)
    case "$real" in
      */.worktrees/*)
        if registered_top "$real"; then
          safe_wt="$real"
        else
          skipped_unregistered=$((skipped_unregistered + 1))
        fi ;;
      *) skipped_unsafe=$((skipped_unsafe + 1)) ;;
    esac
  fi

  # --only: a run whose worktree is not on the list is not ours to judge,
  # and that includes marking its state.
  listed_only "$safe_wt" || continue

  size_kb=0
  [ -n "$safe_wt" ] && size_kb=$(du -sk "$safe_wt" 2>/dev/null | cut -f1)

  # Rule 3: uncommitted work is stashed, and the directory stays.
  dirty=0
  if [ -n "$safe_wt" ] && [ -n "$(git -C "$safe_wt" status --porcelain 2>/dev/null)" ]; then
    dirty=1
  fi

  listed=$((listed + 1))
  [ -n "$safe_wt" ] && HANDLED="$HANDLED
$safe_wt"
  human=$(printf '%s' "${size_kb:-0}" | awk '{printf "%.1fG", $1/1048576}')
  if [ "$DELETE" -eq 0 ]; then
    if [ "$dirty" -eq 1 ]; then
      printf '  would stash (uncommitted work): %s - %s, idle %sd, %s\n' "$task" "$kind" "$age_days" "$human"
      emit "$task" "$safe_wt" would-stash "$kind" "$age_days" "${size_kb:-0}" 1
    elif [ -n "$safe_wt" ]; then
      printf '  would remove: %s - %s, idle %sd, %s\n' "$task" "$kind" "$age_days" "$human"
      emit "$task" "$safe_wt" would-remove "$kind" "$age_days" "${size_kb:-0}" 1
    else
      printf '  would mark abandoned (no reapable worktree): %s - %s, idle %sd\n' "$task" "$kind" "$age_days"
      emit "$task" "" would-mark "$kind" "$age_days" 0 1
    fi
    continue
  fi

  run_dir=$(dirname "$state")
  # Forgiven, both of them: this is the salvage copy, and a run whose artefacts
  # cannot be copied is still a run that has to be marked abandoned. Under
  # `set -e` an unreadable state file here would end the entire sweep.
  mkdir -p "$run_dir/artifacts" 2>/dev/null || true
  cp "$state" "$run_dir/artifacts/agent-state.json" 2>/dev/null || true
  tmp="$state.tmp.$$"
  if jq --arg r "abandoned by gc-abandoned after ${age_days}d idle" \
    '.status = "failed" | .haltReason = $r' "$state" > "$tmp" 2>/dev/null; then
    mv "$tmp" "$state"
  else
    rm -f "$tmp"
  fi

  if [ "$dirty" -eq 1 ]; then
    br="autopilot/abandoned/$task"
    git -C "$safe_wt" stash push -u -m "$br" >/dev/null 2>&1 &&
      printf '→ stashed uncommitted work, worktree kept: %s (%s)\n' "$task" "$br" ||
      printf '→ could NOT stash, worktree kept untouched: %s\n' "$task"
    emit "$task" "$safe_wt" stashed "$kind" "$age_days" "${size_kb:-0}" 1
    kept_dirty=$((kept_dirty + 1))
    continue
  fi

  if [ -n "$safe_wt" ]; then
    repo=${safe_wt%%/.worktrees/*}
    if ! git -C "$repo" worktree remove --force "$safe_wt" >/dev/null 2>&1; then
      printf '→ could NOT remove, worktree kept: %s\n' "$task"
      emit "$task" "$safe_wt" remove-failed "$kind" "$age_days" "${size_kb:-0}" 1
      continue
    fi
    # Forgiven: one flaky prune must not end the sweep before its summary line.
    git -C "$repo" worktree prune >/dev/null 2>&1 || true
    printf '→ removed: %s (%s, idle %sd)\n' "$task" "$human" "$age_days"
    emit "$task" "$safe_wt" removed "$kind" "$age_days" "${size_kb:-0}" 1
    freed_kb=$((freed_kb + ${size_kb:-0}))
  else
    printf '→ marked abandoned, no reapable worktree: %s\n' "$task"
    emit "$task" "" marked "$kind" "$age_days" 0 1
  fi
  removed=$((removed + 1))
done <<EOF
$(find "$LOGS" -name agent-state.json -type f -maxdepth 4 -not -path '*/artifacts/*' 2>/dev/null)
EOF

# ---- pass 2: the worktrees themselves ---------------------------------------
#
# Driven from disk rather than from state, because 19 of the 28 worktrees
# measured have no state file to drive from. For each one the question is what
# its run is doing, answered by looking the state up by path and by task id.
stale_wt=0
unattributed=0
unattributed_kb=0
if [ "$STATE_ONLY" -eq 0 ] && [ -d "$REPOS" ]; then
  # Every state file once, so the per-worktree lookup below is a grep and not a
  # re-walk of the log tree for each of 28 directories.
  index=$(find "$LOGS" -name agent-state.json -type f -maxdepth 4 -not -path '*/artifacts/*' 2>/dev/null |
    while IFS= read -r f; do
      # One malformed state file must not truncate the index: under `set -e`
      # a jq failure inside this subshell ends the loop, and the entries after
      # it would silently not exist - which reads as "no worktree matches" and
      # protects nothing.
      jq -r '[(.worktreePath // ""), (.taskId // .jiraId // ""), (.status // ""), (if (.pr|type)=="string" then .pr elif (.pr|type)=="object" then (.pr.url // .pr.number // "") else "" end | tostring), ((.currentPhase // "") | tostring), ('"$HELD_JQ"')] | @tsv' "$f" 2>/dev/null || true
    done)

  # The candidates: every `<repo>/.worktrees/*` under --repos, or exactly the
  # --only list. Both go through the same guards below.
  candidates=""
  if [ -n "$ONLY" ]; then
    candidates="$ONLY_LIST"
  else
    for wtroot in "$REPOS"/*/.worktrees; do
      [ -d "$wtroot" ] || continue
      [ -L "$wtroot" ] && continue
      for d in "$wtroot"/*; do
        candidates="$candidates
$d"
      done
    done
  fi
  while IFS= read -r d; do
      [ -n "$d" ] || continue
      [ -d "$d" ] || continue
      [ -L "$d" ] && continue
      real=$(cd "$d" 2>/dev/null && pwd -P) || continue
      repo=${real%%/.worktrees/*}
      # The namespace guard again, unconditionally: nothing outside a
      # `.worktrees/` directory is a candidate, whatever any record claims.
      case "$real" in */.worktrees/*) ;; *) continue ;; esac
      if ! registered_top "$real"; then
        skipped_unregistered=$((skipped_unregistered + 1))
        continue
      fi
      printf '%s\n' "$HANDLED" | grep -qxF "$real" && continue
      id=$(basename "$real")

      row=$(printf '%s\n' "$index" | awk -F'\t' -v p="$real" -v i="$id" '$1==p || $2==i {print; exit}')
      st=$(printf '%s' "$row" | cut -f3)
      pr=$(printf '%s' "$row" | cut -f4)
      ph=$(printf '%s' "$row" | cut -f5)
      hd=$(printf '%s' "$row" | cut -f6)
      if [ -n "$hd" ]; then
        skipped_waiting=$((skipped_waiting + 1)); continue
      fi

      # Rule 1 again: landed work is not residue, whichever pass finds it. Order
      # matters here - the waiting-phase test is a proxy for "still waiting", and
      # a run whose status already says it FINISHED is not waiting for anyone. A
      # complete run sitting at the report phase was read as waiting and never
      # reaped, which is how three finished worktrees held 5.1 GB indefinitely.
      case "$st" in
        complete | completed | failed) ;;
        awaiting_input)
          skipped_waiting=$((skipped_waiting + 1)); continue ;;
        in_progress | paused)
          continue ;; # pass 1 owns these; do not report them twice
        *)
          if [ -n "$pr" ] || { [ "$ph" != "?" ] && [ "$ph" -ge "$WAITING_FROM" ] 2>/dev/null; }; then
            skipped_waiting=$((skipped_waiting + 1)); continue
          fi ;;
      esac

      attributed=1
      case "$st" in
        complete | completed | failed)
          why="run finished, worktree outlived it" ;;
        *)
          why="no run state on record - yours to judge, never removed here"
          attributed=0 ;;
      esac

      # Age from the newest file in the tree, not from the directory stamp: a
      # directory mtime only tracks its immediate entries, so an actively edited
      # worktree can look untouched for weeks.
      newest=$(find "$real" -type f -not -path '*/.git/*' -print0 2>/dev/null |
        xargs -0 ls -t 2>/dev/null | head -1)
      mt=$(_mtime "${newest:-$real}")
      age_days=$(((NOW - mt) / 86400))
      [ "$age_days" -gt "$DAYS" ] || continue

      size_kb=$(du -sk "$real" 2>/dev/null | cut -f1)
      human=$(printf '%s' "${size_kb:-0}" | awk '{printf "%.1fG", $1/1048576}')
      dirty=0
      [ -n "$(git -C "$real" status --porcelain 2>/dev/null)" ] && dirty=1

      if [ "$attributed" -eq 0 ]; then
        unattributed=$((unattributed + 1))
        unattributed_kb=$((unattributed_kb + ${size_kb:-0}))
        printf '  %s %s - %s, idle %sd, %s\n' \
          "$([ "$DELETE" -eq 0 ] && echo "unattributed:" || echo "left alone:")" \
          "$id" "$why" "$age_days" "$human"
        emit "$id" "$real" unattributed "$why" "$age_days" "${size_kb:-0}" 2
        continue
      fi

      stale_wt=$((stale_wt + 1))
      if [ "$DELETE" -eq 0 ]; then
        if [ "$dirty" -eq 1 ]; then
          printf '  would stash (uncommitted work): %s - %s, idle %sd, %s\n' "$id" "$why" "$age_days" "$human"
          emit "$id" "$real" would-stash "$why" "$age_days" "${size_kb:-0}" 2
        else
          printf '  would remove worktree: %s - %s, idle %sd, %s\n' "$id" "$why" "$age_days" "$human"
          emit "$id" "$real" would-remove "$why" "$age_days" "${size_kb:-0}" 2
        fi
        continue
      fi

      if [ "$dirty" -eq 1 ]; then
        git -C "$real" stash push -u -m "autopilot/abandoned/$id" >/dev/null 2>&1 &&
          printf '→ stashed uncommitted work, worktree kept: %s\n' "$id" ||
          printf '→ could NOT stash, worktree kept untouched: %s\n' "$id"
        emit "$id" "$real" stashed "$why" "$age_days" "${size_kb:-0}" 2
        kept_dirty=$((kept_dirty + 1))
        continue
      fi
      if ! git -C "$repo" worktree remove --force "$real" >/dev/null 2>&1; then
        printf '→ could NOT remove, worktree kept: %s\n' "$id"
        emit "$id" "$real" remove-failed "$why" "$age_days" "${size_kb:-0}" 2
        continue
      fi
      # Same reason as the first pass: a failed prune is not a reason to stop.
      git -C "$repo" worktree prune >/dev/null 2>&1 || true
      printf '→ removed worktree: %s (%s, %s, idle %sd)\n' "$id" "$why" "$human" "$age_days"
      emit "$id" "$real" removed "$why" "$age_days" "${size_kb:-0}" 2
      freed_kb=$((freed_kb + ${size_kb:-0}))
      removed=$((removed + 1))
  done <<EOF_CANDIDATES
$candidates
EOF_CANDIDATES
fi
listed=$((listed + stale_wt))

note=""
[ "$skipped_waiting" -gt 0 ] && note="$note, $skipped_waiting waiting on you (kept)"
[ "$skipped_unsafe" -gt 0 ] && note="$note, $skipped_unsafe path(s) outside .worktrees/ (kept)"
[ "$skipped_unregistered" -gt 0 ] && note="$note, $skipped_unregistered path(s) not a registered worktree (kept)"
[ "$kept_dirty" -gt 0 ] && note="$note, $kept_dirty stashed and kept"
if [ "$unattributed" -gt 0 ]; then
  uh=$(printf '%s' "$unattributed_kb" | awk '{printf "%.1fG", $1/1048576}')
  note="$note, $unattributed worktree(s) holding $uh with no run state (listed, untouched)"
fi

if [ "$DELETE" -eq 0 ]; then
  if [ "$listed" -eq 0 ]; then
    printf 'gc-abandoned: nothing to do%s\n' "${note:+ (}${note#, }${note:+)}"
  else
    printf '══ gc-abandoned (dry-run): %d run(s) would be reaped%s ══\n' "$listed" "$note"
  fi
  exit 0
fi

freed_h=$(printf '%s' "$freed_kb" | awk '{printf "%.1fG", $1/1048576}')
printf '══ gc-abandoned: reaped %d run(s), freed ~%s%s ══\n' "$removed" "$freed_h" "$note"
exit 0
