diff --git a/CHANGELOG.md b/CHANGELOG.md index a7c1626..c857193 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- `skills/deva-clean`: Claude Code skill for mount-safe workspace disk + cleanup. `scripts/triage.sh` (read-only, deterministic) maps bind + mounts from /proc/mounts, git-triages local worktrees for unpushed or + uncommitted work via live ls-remote + merge ancestry, and emits a + tiered dry-run plan; SKILL.md layers the judgment calls and gates + execution behind explicit approval (#401) + ## [0.13.0] - 2026-07-07 ### Changed diff --git a/skills/deva-clean/SKILL.md b/skills/deva-clean/SKILL.md new file mode 100644 index 0000000..011b053 --- /dev/null +++ b/skills/deva-clean/SKILL.md @@ -0,0 +1,75 @@ +--- +name: deva-clean +description: Disk cleanup for deva container workspaces - reclaim space without touching bind mounts or unpushed work. Use when the user wants disk space reclaimed in a deva workspace, asks to remove stale worktrees, or a disk-full symptom traces into the workspace. Deleting a single known build dir needs no skill - just delete it. +--- + +# deva-clean: mount-safe workspace disk cleanup + +A deva workspace mixes two kinds of directories that look identical in +ls: bind mounts declared in `.deva` (live host repos) and local dirs +created during work (git worktrees, node_modules, package stores). +rm -rf on the wrong one destroys a live repo on the host. And because +the workspace root is itself a host mount, local dirs still consume +host disk - usually the disk that is actually full. + +Two passes. Pass one is deterministic and scripted; pass two executes +only what got approved. + +## Pass one: triage + +Run the bundled script - read-only, emits the tiered plan, never +deletes: + + scripts/triage.sh [workspace] [--fetch] + +It maps mounts from /proc/mounts (ground truth - .deva drifts), shows +which disk is under pressure, walks the non-mount dirs, gives every +candidate four verdicts (type, dirty, pushed, freshness), and finds +stale worktree metadata in the mounted parent repos. Verdict semantics +live in the script header; two encode traps worth knowing: + +- The pushed test is live ls-remote SHA comparison plus ancestry in + the default branch - never upstream config. A branch created off + origin/develop shows "ahead 1" while never pushed itself. +- MERGED is monotonic, provable even against stale refs. Absence is + not: a branch absent on remote with stale refs is UNVERIFIED, not + safe. Rerun with --fetch for a definitive verdict. + +Then layer the judgment the script refuses to automate: + +- Pressure: container overlay genuinely full (rare - on shared-VM + runtimes overlay df reports the whole VM disk) means container + caches (~/.cache, package stores) are the target, not the workspace. +- Stray files: the script walks dirs only; shell-typo artifacts like + "&1" are yours to spot. +- Promotions: KEEP entries flagged MODIFIED TRACKED FILES move to + tier 2 only when you can name every dirty file as a generated + artifact (lockfiles, test output) and say so in the plan. +- Demotions: tier 1/2 entries that are functionally live - symlink + targets, open-PR checkouts - move to keep. +- Keepers: offer cache-prune (node_modules, dist, coverage) as the + low-risk alternative; for LOCAL-ONLY work, say exactly what would + be lost and suggest pushing it. + +Done when every candidate sits in a tier you can defend. Present the +plan and stop - pass two starts only from explicit approval, tier by +tier. + +## Pass two: execute approved tiers + +- Linked worktrees: `git -C worktree remove ` is the + only removal path. Its refusal on a dirty tree is the safety net, so + no --force - and no rm -rf, which bypasses the net and leaves stale + metadata behind. +- Tier 2 trees: first delete the named generated files (restore + tracked ones with `git checkout --`), so the tree is genuinely clean + and the safety net stays armed. +- Stale metadata: `git -C worktree prune`. +- Standalone repos (pushed, clean) and plain dirs: rm -rf. + +Safe by construction: `git worktree remove` deletes the checkout only; +branch refs live in the parent repo's .git and survive. Committed work +is never lost - only uncommitted files can be. The metadata edit inside +the parent's .git is normal bookkeeping, not touching the mount. + +Done when df shows the reclaim and a triage rerun reports empty tiers. diff --git a/skills/deva-clean/scripts/triage.sh b/skills/deva-clean/scripts/triage.sh new file mode 100755 index 0000000..7128b70 --- /dev/null +++ b/skills/deva-clean/scripts/triage.sh @@ -0,0 +1,188 @@ +#!/usr/bin/env bash +# triage.sh - deva-clean pass one: read-only triage of a deva workspace. +# Emits a tiered dry-run plan on stdout. Never deletes anything. +# +# Usage: triage.sh [WORKSPACE] [--fetch] +# +# WORKSPACE workspace root (default: current directory) +# --fetch refresh each repo's default branch before the merged +# check (writes refs into the repo's .git only; worktrees +# and mounts are untouched) +# +# Verdicts: +# PUSHED-EXACT remote branch tip == local HEAD (live ls-remote) +# MERGED HEAD is ancestor of origin/; monotonic, so +# provable even against stale refs +# LOCAL-ONLY branch absent on remote, HEAD not in fresh default +# UNVERIFIED branch absent on remote, default ref stale and no +# --fetch: could be recently merged - treat as keep +# DIVERGED remote branch exists but tip differs from HEAD +# OFFLINE ls-remote failed: no live evidence +# NO-REMOTE repo has no origin remote +set -uo pipefail +shopt -s nullglob dotglob + +WS=$PWD +DO_FETCH=0 +while [[ $# -gt 0 ]]; do + case "$1" in + --fetch) DO_FETCH=1 ;; + -h|--help) sed -n '2,21p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + -*) echo "unknown flag: $1" >&2; exit 2 ;; + *) WS=$(cd "$1" && pwd) || exit 2 ;; + esac + shift +done +NET_TIMEOUT=${DEVA_CLEAN_TIMEOUT:-20} + +# --- step 1: mount map (ground truth: the kernel, not .deva) ---------- +mapfile -t MOUNTS < <(awk -v ws="$WS" '$2 == ws || index($2, ws"/") == 1 {print $2}' /proc/mounts | sort) + +is_mount() { + local m + for m in "${MOUNTS[@]}"; do [[ $m == "$1" ]] && return 0; done + return 1 +} + +has_mount_below() { + local m + for m in "${MOUNTS[@]}"; do [[ $m == "$1"/* ]] && return 0; done + return 1 +} + +echo "== deva-clean triage: $WS" +echo "== mounts: ${#MOUNTS[@]} (off limits)" +is_mount "$WS" || echo "!! workspace root is not a bind mount - is this a deva workspace?" + +# --- step 2: locate the pressure -------------------------------------- +echo +echo "-- pressure" +df -h / "$WS" | awk 'NR==1 || $NF=="/" || $NF=="'"$WS"'"' + +# --- step 3: candidates = non-mount dirs; descend only through dirs +# --- that still contain mounts beneath them ---------------------------- +CANDIDATES=() +collect() { + local e + for e in "$1"/*/; do + e=${e%/} + [[ ${e##*/} == .git ]] && continue + if is_mount "$e"; then continue; fi + if has_mount_below "$e"; then collect "$e"; else CANDIDATES+=("$e"); fi + done +} +collect "$WS" + +# --- step 4: four verdicts per candidate ------------------------------- +declare -A DEF_BRANCH FETCH_STATE # cached per remote URL +T1=() T2=() KEEP=() JUDGE=() +T1_KB=0 T2_KB=0 + +kb() { du -sk "$1" 2>/dev/null | cut -f1; } +hsize() { echo "$1" | awk '{ if ($1>=1048576) printf "%.1fG", $1/1048576; else if ($1>=1024) printf "%.0fM", $1/1024; else printf "%dK", $1 }'; } + +for d in "${CANDIDATES[@]}"; do + size_kb=$(kb "$d"); size=$(hsize "$size_kb") + + if [[ -f $d/.git ]]; then type=linked + elif [[ -d $d/.git ]]; then type=standalone + else JUDGE+=("$size $d (no git evidence - judgment call)"); continue; fi + + head=$(git -C "$d" rev-parse HEAD 2>/dev/null) || { KEEP+=("$size $d unreadable git state"); continue; } + br=$(git -C "$d" rev-parse --abbrev-ref HEAD) + porcelain=$(git -C "$d" status --porcelain 2>/dev/null) + dirty=0; untracked_only=1 + while IFS= read -r line; do + [[ -z $line ]] && continue + dirty=$((dirty + 1)) + [[ $line == '??'* ]] || untracked_only=0 + done <<< "$porcelain" + + url=$(git -C "$d" remote get-url origin 2>/dev/null) || url="" + verdict="" + if [[ -z $url ]]; then + verdict=NO-REMOTE + else + if [[ -z ${DEF_BRANCH[$url]:-} ]]; then + DEF_BRANCH[$url]=$(timeout "$NET_TIMEOUT" git -C "$d" ls-remote --symref origin HEAD 2>/dev/null \ + | awk '$1=="ref:"{sub("refs/heads/","",$2); print $2; exit}') + fi + def=${DEF_BRANCH[$url]} + if [[ -z $def ]]; then + verdict=OFFLINE + else + remote_sha="" + [[ $br != HEAD ]] && remote_sha=$(timeout "$NET_TIMEOUT" git -C "$d" ls-remote origin "refs/heads/$br" 2>/dev/null | cut -f1) + if [[ -n $remote_sha && $remote_sha == "$head" ]]; then + verdict=PUSHED-EXACT + else + base_ref="" base_note="" + if [[ $DO_FETCH == 1 && -z ${FETCH_STATE[$url]:-} ]]; then + if timeout $((NET_TIMEOUT * 3)) git -C "$d" fetch origin "$def" >/dev/null 2>&1; then + FETCH_STATE[$url]=fresh + else FETCH_STATE[$url]=failed; fi + fi + common=$(git -C "$d" rev-parse --path-format=absolute --git-common-dir) + if [[ ${FETCH_STATE[$url]:-} == fresh ]]; then + base_ref=FETCH_HEAD base_note=fresh + elif git -C "$d" rev-parse -q --verify "origin/$def" >/dev/null 2>&1; then + base_ref="origin/$def" + base_note="refs from $(stat -c %y "$common/FETCH_HEAD" 2>/dev/null | cut -d' ' -f1 || echo unknown)" + fi + if [[ -n $base_ref ]] && git -C "$d" merge-base --is-ancestor "$head" "$base_ref" 2>/dev/null; then + verdict="MERGED($def,$base_note)" + elif [[ -n $remote_sha ]]; then + verdict=DIVERGED + elif [[ $base_note == fresh ]]; then + verdict=LOCAL-ONLY + else + verdict="UNVERIFIED($base_note; rerun with --fetch)" + fi + fi + fi + fi + + row="$size $d [$type $br] dirty=$dirty $verdict" + case $verdict in + PUSHED-EXACT|MERGED*) + if [[ $dirty -eq 0 ]]; then + if [[ $type == linked ]]; then + parent=$(dirname "$(git -C "$d" rev-parse --path-format=absolute --git-common-dir)") + T1+=("$row"$'\n'" \$ git -C $parent worktree remove $d") + else + T1+=("$row"$'\n'" \$ rm -rf $d") + fi + T1_KB=$((T1_KB + size_kb)) + elif [[ $untracked_only -eq 1 ]]; then + T2+=("$row"$'\n'" untracked: $(echo "$porcelain" | sed 's/^?? //' | head -5 | tr '\n' ' ')") + T2_KB=$((T2_KB + size_kb)) + else + KEEP+=("$row - MODIFIED TRACKED FILES (promote to tier 2 only with judgment)") + fi ;; + *) KEEP+=("$row") ;; + esac +done + +# --- stale worktree metadata in mounted parent repos ------------------- +PRUNABLE=() +for m in "${MOUNTS[@]}"; do + [[ -d $m/.git ]] || continue + n=$(git -C "$m" worktree list --porcelain 2>/dev/null | grep -c '^prunable') || n=0 + [[ $n -gt 0 ]] && PRUNABLE+=("$m: $n stale entries -> \$ git -C $m worktree prune") +done + +# --- step 5: the plan --------------------------------------------------- +section() { + echo; echo "-- $1"; shift + local x any=0 + for x in "$@"; do [[ -n $x ]] && { printf ' %s\n' "$x"; any=1; }; done + [[ $any -eq 0 ]] && echo " (none)" + return 0 +} +section "TIER 1: safe (clean + pushed/merged), reclaim ~$(hsize $T1_KB)" "${T1[@]:-}" +section "TIER 2: confirm (pushed/merged, untracked files only), reclaim ~$(hsize $T2_KB)" "${T2[@]:-}" +section "KEEP: would lose the only copy" "${KEEP[@]:-}" +section "JUDGMENT: no git evidence, decide case by case" "${JUDGE[@]:-}" +section "stale worktree metadata" "${PRUNABLE[@]:-}" +echo +echo "== plan only - nothing was deleted. Execution is pass two (see SKILL.md)."