|
| 1 | +#!/usr/bin/env bash |
| 2 | +# triage.sh - deva-clean pass one: read-only triage of a deva workspace. |
| 3 | +# Emits a tiered dry-run plan on stdout. Never deletes anything. |
| 4 | +# |
| 5 | +# Usage: triage.sh [WORKSPACE] [--fetch] |
| 6 | +# |
| 7 | +# WORKSPACE workspace root (default: current directory) |
| 8 | +# --fetch refresh each repo's default branch before the merged |
| 9 | +# check (writes refs into the repo's .git only; worktrees |
| 10 | +# and mounts are untouched) |
| 11 | +# |
| 12 | +# Verdicts: |
| 13 | +# PUSHED-EXACT remote branch tip == local HEAD (live ls-remote) |
| 14 | +# MERGED HEAD is ancestor of origin/<default>; monotonic, so |
| 15 | +# provable even against stale refs |
| 16 | +# LOCAL-ONLY branch absent on remote, HEAD not in fresh default |
| 17 | +# UNVERIFIED branch absent on remote, default ref stale and no |
| 18 | +# --fetch: could be recently merged - treat as keep |
| 19 | +# DIVERGED remote branch exists but tip differs from HEAD |
| 20 | +# OFFLINE ls-remote failed: no live evidence |
| 21 | +# NO-REMOTE repo has no origin remote |
| 22 | +set -uo pipefail |
| 23 | +shopt -s nullglob dotglob |
| 24 | + |
| 25 | +WS=$PWD |
| 26 | +DO_FETCH=0 |
| 27 | +while [[ $# -gt 0 ]]; do |
| 28 | + case "$1" in |
| 29 | + --fetch) DO_FETCH=1 ;; |
| 30 | + -h|--help) sed -n '2,21p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; |
| 31 | + -*) echo "unknown flag: $1" >&2; exit 2 ;; |
| 32 | + *) WS=$(cd "$1" && pwd) || exit 2 ;; |
| 33 | + esac |
| 34 | + shift |
| 35 | +done |
| 36 | +NET_TIMEOUT=${DEVA_CLEAN_TIMEOUT:-20} |
| 37 | + |
| 38 | +# --- step 1: mount map (ground truth: the kernel, not .deva) ---------- |
| 39 | +mapfile -t MOUNTS < <(awk -v ws="$WS" '$2 == ws || index($2, ws"/") == 1 {print $2}' /proc/mounts | sort) |
| 40 | + |
| 41 | +is_mount() { |
| 42 | + local m |
| 43 | + for m in "${MOUNTS[@]}"; do [[ $m == "$1" ]] && return 0; done |
| 44 | + return 1 |
| 45 | +} |
| 46 | + |
| 47 | +has_mount_below() { |
| 48 | + local m |
| 49 | + for m in "${MOUNTS[@]}"; do [[ $m == "$1"/* ]] && return 0; done |
| 50 | + return 1 |
| 51 | +} |
| 52 | + |
| 53 | +echo "== deva-clean triage: $WS" |
| 54 | +echo "== mounts: ${#MOUNTS[@]} (off limits)" |
| 55 | +is_mount "$WS" || echo "!! workspace root is not a bind mount - is this a deva workspace?" |
| 56 | + |
| 57 | +# --- step 2: locate the pressure -------------------------------------- |
| 58 | +echo |
| 59 | +echo "-- pressure" |
| 60 | +df -h / "$WS" | awk 'NR==1 || $NF=="/" || $NF=="'"$WS"'"' |
| 61 | + |
| 62 | +# --- step 3: candidates = non-mount dirs; descend only through dirs |
| 63 | +# --- that still contain mounts beneath them ---------------------------- |
| 64 | +CANDIDATES=() |
| 65 | +collect() { |
| 66 | + local e |
| 67 | + for e in "$1"/*/; do |
| 68 | + e=${e%/} |
| 69 | + [[ ${e##*/} == .git ]] && continue |
| 70 | + if is_mount "$e"; then continue; fi |
| 71 | + if has_mount_below "$e"; then collect "$e"; else CANDIDATES+=("$e"); fi |
| 72 | + done |
| 73 | +} |
| 74 | +collect "$WS" |
| 75 | + |
| 76 | +# --- step 4: four verdicts per candidate ------------------------------- |
| 77 | +declare -A DEF_BRANCH FETCH_STATE # cached per remote URL |
| 78 | +T1=() T2=() KEEP=() JUDGE=() |
| 79 | +T1_KB=0 T2_KB=0 |
| 80 | + |
| 81 | +kb() { du -sk "$1" 2>/dev/null | cut -f1; } |
| 82 | +hsize() { echo "$1" | awk '{ if ($1>=1048576) printf "%.1fG", $1/1048576; else if ($1>=1024) printf "%.0fM", $1/1024; else printf "%dK", $1 }'; } |
| 83 | + |
| 84 | +for d in "${CANDIDATES[@]}"; do |
| 85 | + size_kb=$(kb "$d"); size=$(hsize "$size_kb") |
| 86 | + |
| 87 | + if [[ -f $d/.git ]]; then type=linked |
| 88 | + elif [[ -d $d/.git ]]; then type=standalone |
| 89 | + else JUDGE+=("$size $d (no git evidence - judgment call)"); continue; fi |
| 90 | + |
| 91 | + head=$(git -C "$d" rev-parse HEAD 2>/dev/null) || { KEEP+=("$size $d unreadable git state"); continue; } |
| 92 | + br=$(git -C "$d" rev-parse --abbrev-ref HEAD) |
| 93 | + porcelain=$(git -C "$d" status --porcelain 2>/dev/null) |
| 94 | + dirty=0; untracked_only=1 |
| 95 | + while IFS= read -r line; do |
| 96 | + [[ -z $line ]] && continue |
| 97 | + dirty=$((dirty + 1)) |
| 98 | + [[ $line == '??'* ]] || untracked_only=0 |
| 99 | + done <<< "$porcelain" |
| 100 | + |
| 101 | + url=$(git -C "$d" remote get-url origin 2>/dev/null) || url="" |
| 102 | + verdict="" |
| 103 | + if [[ -z $url ]]; then |
| 104 | + verdict=NO-REMOTE |
| 105 | + else |
| 106 | + if [[ -z ${DEF_BRANCH[$url]:-} ]]; then |
| 107 | + DEF_BRANCH[$url]=$(timeout "$NET_TIMEOUT" git -C "$d" ls-remote --symref origin HEAD 2>/dev/null \ |
| 108 | + | awk '$1=="ref:"{sub("refs/heads/","",$2); print $2; exit}') |
| 109 | + fi |
| 110 | + def=${DEF_BRANCH[$url]} |
| 111 | + if [[ -z $def ]]; then |
| 112 | + verdict=OFFLINE |
| 113 | + else |
| 114 | + remote_sha="" |
| 115 | + [[ $br != HEAD ]] && remote_sha=$(timeout "$NET_TIMEOUT" git -C "$d" ls-remote origin "refs/heads/$br" 2>/dev/null | cut -f1) |
| 116 | + if [[ -n $remote_sha && $remote_sha == "$head" ]]; then |
| 117 | + verdict=PUSHED-EXACT |
| 118 | + else |
| 119 | + base_ref="" base_note="" |
| 120 | + if [[ $DO_FETCH == 1 && -z ${FETCH_STATE[$url]:-} ]]; then |
| 121 | + if timeout $((NET_TIMEOUT * 3)) git -C "$d" fetch origin "$def" >/dev/null 2>&1; then |
| 122 | + FETCH_STATE[$url]=fresh |
| 123 | + else FETCH_STATE[$url]=failed; fi |
| 124 | + fi |
| 125 | + common=$(git -C "$d" rev-parse --path-format=absolute --git-common-dir) |
| 126 | + if [[ ${FETCH_STATE[$url]:-} == fresh ]]; then |
| 127 | + base_ref=FETCH_HEAD base_note=fresh |
| 128 | + elif git -C "$d" rev-parse -q --verify "origin/$def" >/dev/null 2>&1; then |
| 129 | + base_ref="origin/$def" |
| 130 | + base_note="refs from $(stat -c %y "$common/FETCH_HEAD" 2>/dev/null | cut -d' ' -f1 || echo unknown)" |
| 131 | + fi |
| 132 | + if [[ -n $base_ref ]] && git -C "$d" merge-base --is-ancestor "$head" "$base_ref" 2>/dev/null; then |
| 133 | + verdict="MERGED($def,$base_note)" |
| 134 | + elif [[ -n $remote_sha ]]; then |
| 135 | + verdict=DIVERGED |
| 136 | + elif [[ $base_note == fresh ]]; then |
| 137 | + verdict=LOCAL-ONLY |
| 138 | + else |
| 139 | + verdict="UNVERIFIED($base_note; rerun with --fetch)" |
| 140 | + fi |
| 141 | + fi |
| 142 | + fi |
| 143 | + fi |
| 144 | + |
| 145 | + row="$size $d [$type $br] dirty=$dirty $verdict" |
| 146 | + case $verdict in |
| 147 | + PUSHED-EXACT|MERGED*) |
| 148 | + if [[ $dirty -eq 0 ]]; then |
| 149 | + if [[ $type == linked ]]; then |
| 150 | + parent=$(dirname "$(git -C "$d" rev-parse --path-format=absolute --git-common-dir)") |
| 151 | + T1+=("$row"$'\n'" \$ git -C $parent worktree remove $d") |
| 152 | + else |
| 153 | + T1+=("$row"$'\n'" \$ rm -rf $d") |
| 154 | + fi |
| 155 | + T1_KB=$((T1_KB + size_kb)) |
| 156 | + elif [[ $untracked_only -eq 1 ]]; then |
| 157 | + T2+=("$row"$'\n'" untracked: $(echo "$porcelain" | sed 's/^?? //' | head -5 | tr '\n' ' ')") |
| 158 | + T2_KB=$((T2_KB + size_kb)) |
| 159 | + else |
| 160 | + KEEP+=("$row - MODIFIED TRACKED FILES (promote to tier 2 only with judgment)") |
| 161 | + fi ;; |
| 162 | + *) KEEP+=("$row") ;; |
| 163 | + esac |
| 164 | +done |
| 165 | + |
| 166 | +# --- stale worktree metadata in mounted parent repos ------------------- |
| 167 | +PRUNABLE=() |
| 168 | +for m in "${MOUNTS[@]}"; do |
| 169 | + [[ -d $m/.git ]] || continue |
| 170 | + n=$(git -C "$m" worktree list --porcelain 2>/dev/null | grep -c '^prunable') || n=0 |
| 171 | + [[ $n -gt 0 ]] && PRUNABLE+=("$m: $n stale entries -> \$ git -C $m worktree prune") |
| 172 | +done |
| 173 | + |
| 174 | +# --- step 5: the plan --------------------------------------------------- |
| 175 | +section() { |
| 176 | + echo; echo "-- $1"; shift |
| 177 | + local x any=0 |
| 178 | + for x in "$@"; do [[ -n $x ]] && { printf ' %s\n' "$x"; any=1; }; done |
| 179 | + [[ $any -eq 0 ]] && echo " (none)" |
| 180 | + return 0 |
| 181 | +} |
| 182 | +section "TIER 1: safe (clean + pushed/merged), reclaim ~$(hsize $T1_KB)" "${T1[@]:-}" |
| 183 | +section "TIER 2: confirm (pushed/merged, untracked files only), reclaim ~$(hsize $T2_KB)" "${T2[@]:-}" |
| 184 | +section "KEEP: would lose the only copy" "${KEEP[@]:-}" |
| 185 | +section "JUDGMENT: no git evidence, decide case by case" "${JUDGE[@]:-}" |
| 186 | +section "stale worktree metadata" "${PRUNABLE[@]:-}" |
| 187 | +echo |
| 188 | +echo "== plan only - nothing was deleted. Execution is pass two (see SKILL.md)." |
0 commit comments