Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
75 changes: 75 additions & 0 deletions skills/deva-clean/SKILL.md
Original file line number Diff line number Diff line change
@@ -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 <parent> worktree remove <path>` 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 <parent> 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.
188 changes: 188 additions & 0 deletions skills/deva-clean/scripts/triage.sh
Original file line number Diff line number Diff line change
@@ -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.
Comment on lines +1 to +3
#
# 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/<default>; 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
Comment on lines +87 to +89

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
Comment on lines +150 to +154
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")
Comment on lines +170 to +171
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)."
Loading