From 20f9841165de07ad66aa1992e106ee231fcede6a Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Tue, 30 Jun 2026 17:50:09 -0600 Subject: [PATCH 1/2] fix(housekeep): make orphaned-worktree loop zsh-safe Claude Code's Bash tool runs under zsh, which does not word-split an unquoted multi-line variable the way bash does. 'for dir in $ORPHANED_DIRS' silently collapsed every orphaned directory into a single iteration whenever more than one existed, breaking the git -C lookup. Switched to a printf | while read loop, which is portable across bash and zsh. --- .claude/skills/housekeep/SKILL.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.claude/skills/housekeep/SKILL.md b/.claude/skills/housekeep/SKILL.md index 8a00f8069..f7a74870e 100644 --- a/.claude/skills/housekeep/SKILL.md +++ b/.claude/skills/housekeep/SKILL.md @@ -113,7 +113,14 @@ Flag any worktree whose combined build artifact size exceeds **500MB** (512000 k Before offering removal, run `git -C status --short` to check for uncommitted changes: ```bash -for dir in $ORPHANED_DIRS; do +# NOTE: iterate with `while IFS= read -r dir`, never `for dir in $ORPHANED_DIRS` — +# the Bash tool in Claude Code runs under zsh, which does NOT word-split an +# unquoted multi-line variable the way bash does. `for dir in $ORPHANED_DIRS` +# would silently collapse every orphaned directory into a single iteration +# whose $dir is the whole newline-joined blob, breaking `git -C "$dir"` the +# moment more than one orphaned directory exists. +printf '%s\n' "$ORPHANED_DIRS" | while IFS= read -r dir; do + [ -z "$dir" ] && continue if [ -d "$dir/.git" ] || [ -f "$dir/.git" ]; then changes=$(git -C "$dir" status --short 2>/dev/null) if [ -n "$changes" ]; then From 59546381ec94aa0e2eccde48805f007bfaf3e446 Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Tue, 30 Jun 2026 17:56:34 -0600 Subject: [PATCH 2/2] fix(skills): clean up lint findings in housekeep skill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the missing Examples section and per-phase Exit condition lines, justifies every previously-bare 2>/dev/null suppression, and quotes the // placeholders that were silently being parsed as shell redirection operators instead of literal fill-ins. No behavior change — these were pre-existing gaps surfaced by running the org's own lint-skill.sh/smoke-test-skill.sh validators against a file that had never been run through them. --- .claude/skills/housekeep/SKILL.md | 45 ++++++++++++++++++++++++++----- 1 file changed, 38 insertions(+), 7 deletions(-) diff --git a/.claude/skills/housekeep/SKILL.md b/.claude/skills/housekeep/SKILL.md index f7a74870e..e4501171c 100644 --- a/.claude/skills/housekeep/SKILL.md +++ b/.claude/skills/housekeep/SKILL.md @@ -27,6 +27,8 @@ Clean up the local repo: remove stale worktrees, delete dirt/temp files, sync wi 4. Record current git status: `git status --short` 5. Warn the user if there are uncommitted changes — housekeeping works best from a clean state +**Exit condition:** repo identity confirmed (`@optave/codegraph`); `DRY_RUN`/`SKIP_UPDATE` are known; current branch and status recorded. + ## Phase 1 — Audit & Clean Worktrees > **Always report disk usage first.** Worktree bloat (per-worktree `node_modules/`, `target/`, `dist/`) is the single largest source of disk waste in this repo — a fresh worktree with `npm install` + a Rust build is ~3GB. Even when no worktree is technically "stale" by branch criteria, the disk footprint must be surfaced so the user can decide what to keep. @@ -36,6 +38,7 @@ Clean up the local repo: remove stale worktrees, delete dirt/temp files, sync wi Always print this, even on `--dry-run`. Use `du -sk` (kilobytes) so the pipeline is portable across BSD (macOS) and GNU (Linux) — `sort -h` is a GNU coreutils extension and is rejected by stock macOS `sort`. ```bash +# 2>/dev/null suppress: .claude/worktrees/ may not exist yet on a fresh repo — zero worktrees, not an error du -sh .claude/worktrees 2>/dev/null # Portable per-worktree sort: kilobytes through sort -n, then format back to human-readable. du -sk .claude/worktrees/*/ 2>/dev/null | sort -n | awk '{ @@ -82,6 +85,7 @@ for wt in .claude/worktrees/*/; do breakdown="" for sub in node_modules target dist; do if [ -d "$wt$sub" ]; then + # 2>/dev/null suppress: the dir can vanish between the glob and this du (e.g. concurrent cleanup) — skip it, don't fail the scan sz=$(du -sk "$wt$sub" 2>/dev/null | awk '{print $1}') [ -n "$sz" ] && total_kb=$((total_kb + sz)) && breakdown="$breakdown $sub=${sz}K" fi @@ -89,6 +93,7 @@ for wt in .claude/worktrees/*/; do # .codegraph: only measure the two files we will actually remove for f in "$wt.codegraph/graph.db" "$wt.codegraph/graph.db-journal"; do if [ -f "$f" ]; then + # 2>/dev/null suppress: same rationale as the node_modules/target/dist loop above — a vanished file mid-scan is skipped, not fatal sz=$(du -sk "$f" 2>/dev/null | awk '{print $1}') [ -n "$sz" ] && total_kb=$((total_kb + sz)) && breakdown="$breakdown $(basename "$f")=${sz}K" fi @@ -122,6 +127,7 @@ Before offering removal, run `git -C status --short` to check for uncommi printf '%s\n' "$ORPHANED_DIRS" | while IFS= read -r dir; do [ -z "$dir" ] && continue if [ -d "$dir/.git" ] || [ -f "$dir/.git" ]; then + # 2>/dev/null suppress: best-effort only — a failed git check on a non-worktree directory is expected and harmless here changes=$(git -C "$dir" status --short 2>/dev/null) if [ -n "$changes" ]; then echo "SKIP $dir — has uncommitted changes:" @@ -145,8 +151,8 @@ git worktree prune - List them with their disk size and **always ask the user for confirmation before removing**, regardless of `--full` - If confirmed: ```bash - git worktree remove - git branch -d # only if fully merged + git worktree remove "" + git branch -d "" # only if fully merged ``` **For bloated (non-stale) worktrees:** @@ -154,10 +160,10 @@ git worktree prune - Ask the user whether to **clean build artifacts only** (keep the source) — these regenerate on the next `npm install` / `cargo build` / `codegraph build` - If confirmed, for each selected worktree: ```bash - rm -rf /node_modules - rm -rf /target - rm -rf /dist - rm -f /.codegraph/graph.db /.codegraph/graph.db-journal + rm -rf "/node_modules" + rm -rf "/target" + rm -rf "/dist" + rm -f "/.codegraph/graph.db" "/.codegraph/graph.db-journal" ``` - **Never run `npm install` / `cargo clean` inside the target worktree** — it may be in use by another Claude Code session @@ -166,6 +172,8 @@ git worktree prune > **Never force-remove** a worktree with uncommitted changes. List it as "has uncommitted work" and skip — but still report its disk size so the user knows what it's costing. > **Never delete source files** in a bloated worktree — only delete the four regeneratable artifact paths above. +**Exit condition:** worktree disk usage has been reported; every worktree is classified (orphaned / stale / bloated / active) with confirmed removals and artifact cleanups completed outside `DRY_RUN`. + ## Phase 2 — Delete Dirt Files Remove temporary and generated files that accumulate over time. There are two distinct categories of dirt that require different discovery commands: @@ -188,13 +196,16 @@ Search for and remove files found by the two discovery commands above (never tou **Stale lock files** (`.codegraph/*.lock` older than 1 hour): Before removing, first check if `lsof` is available (`command -v lsof`). If `lsof` is **not installed** (common in Docker/CI minimal containers where it exits 127), **skip lock file removal entirely** and print a warning: `"lsof not available — skipping lock file cleanup (cannot verify no process holds the file)"`. When `lsof` IS available, use `lsof "$f"` to verify no process holds the file. If the file is held, **skip it** and warn — concurrent Claude Code sessions may hold legitimate long-lived locks. ```bash +# > /dev/null 2>&1: suppress command path on success and shell's "not found" on failure — the else branch reports the skip explicitly if ! command -v lsof > /dev/null 2>&1; then echo "lsof not available — skipping lock file cleanup (cannot verify no process holds the file)" else for f in .codegraph/*.lock; do [ -f "$f" ] || continue + # 2>/dev/null suppress: BSD vs GNU stat take different flags — try GNU syntax first, fall back to BSD age=$(( $(date +%s) - $(stat --format='%Y' "$f" 2>/dev/null || stat -f '%m' "$f" 2>/dev/null) )) [ -z "$age" ] && continue + # > /dev/null 2>&1: suppress lsof's own output — only its exit code (held vs free) matters here if [ "$age" -gt 3600 ] && ! lsof "$f" > /dev/null 2>&1; then if [ "$DRY_RUN" = "true" ]; then echo "[DRY RUN] Would remove stale lock: $f" @@ -215,6 +226,7 @@ Find untracked files (both gitignored and non-ignored) larger than 1MB. Use both ```bash # Non-ignored untracked files git ls-files --others --exclude-standard | while read f; do + # 2>/dev/null suppress: BSD vs GNU stat take different flags — try GNU syntax first, fall back to BSD; a genuine failure just yields empty $size and is skipped below size=$(stat --format='%s' "$f" 2>/dev/null || stat -f '%z' "$f" 2>/dev/null) [ -z "$size" ] && continue if [ "$size" -gt 1048576 ]; then echo "$f ($size bytes)"; fi @@ -223,6 +235,7 @@ done git clean -fdX --dry-run | sed 's/^Would remove //' | while read f; do # Skip directory entries — stat returns inode size, not content size [ -d "$f" ] && continue + # 2>/dev/null suppress: BSD vs GNU stat fallback, same rationale as above size=$(stat --format='%s' "$f" 2>/dev/null || stat -f '%z' "$f" 2>/dev/null) [ -z "$size" ] && continue if [ "$size" -gt 1048576 ]; then echo "$f ($size bytes) [gitignored]"; fi @@ -241,6 +254,8 @@ Flag these for user review — they might be accidentally untracked binaries. > **Never delete** files that are tracked by git. Only clean untracked/ignored files. +**Exit condition:** known dirt patterns have been removed (or listed, under `DRY_RUN`); large untracked files have been flagged for the user, never deleted automatically. + ## Phase 3 — Sync with Main ### 3a. Fetch latest @@ -269,6 +284,8 @@ git for-each-ref --format='%(refname:short) %(upstream:track)' refs/heads/ Flag any branches marked `[ahead N, behind M]` — these may need attention. +**Exit condition:** the local branch's relationship to `origin/main` has been reported; `main` has been pulled only if it was already checked out and new commits exist; diverged branches have been flagged, not acted on. + ## Phase 4 — Prune Merged Branches ### 4a. Find merged branches @@ -303,12 +320,14 @@ Delete merged branch ''? (y/n) ``` If confirmed, delete the branch: ```bash -git branch -d # safe delete, only if fully merged +git branch -d "" # safe delete, only if fully merged ``` > **Never use `git branch -D`** (force delete). If `-d` fails, the branch has unmerged work — skip it. > **Always confirm before deleting** — consistent with worktree removal in Phase 1c. +**Exit condition:** every fully-merged, non-protected branch has had a confirmed delete decision (or been listed under `DRY_RUN`); stale remote-tracking refs have been pruned. + ## Phase 5 — Update Codegraph **Skip if `SKIP_UPDATE` is set.** @@ -316,6 +335,8 @@ git branch -d # safe delete, only if fully merged > **Source-repo guard:** This phase is only meaningful when codegraph is installed as a *dependency* of a consumer project. Because the pre-flight confirms we are inside the codegraph *source* repo (`"name": "@optave/codegraph"`), comparing the dev version to the published release and running `npm install` would be a no-op — codegraph is not one of its own dependencies. **Skip this entire phase** when running inside the source repo and print: > `Codegraph: skipped (running inside source repo — update via git pull / branch sync instead)` +**Exit condition:** either the phase was skipped (source-repo guard or `SKIP_UPDATE`) and that is printed, or codegraph's version status has been reported. + ## Phase 6 — Verify Repo Health Quick health checks to catch issues: @@ -347,6 +368,8 @@ git fsck --no-dangling 2>&1 | head -20 Flag any errors (rare but important). +**Exit condition:** graph integrity, node-modules integrity, and git integrity have each produced a pass/fail result. + ## Phase 7 — Report Print a summary to the console (no file needed — this is a local maintenance task): @@ -372,6 +395,14 @@ Status: CLEAN ✓ **If `DRY_RUN`:** prefix with `[DRY RUN]` and show what would happen without doing it. +**Exit condition:** the report above has been printed; nothing further runs after it. + +## Examples + +- `/housekeep` — full local cleanup: prune stale/bloated worktrees, remove regeneratable dirt, sync with main, prune merged branches, verify health. +- `/housekeep --dry-run` — preview everything above with zero changes. +- `/housekeep --skip-update` — run the cleanup phases but skip the codegraph-version-check phase. + ## Rules - **Never force-delete** anything — use safe deletes only (`git branch -d`, `git worktree remove`)