Repository navigation
setup: cleanup_old_claude_symlinks() derives orphans from the payload dir, so it can never clean orphans (37 dead skill dirs, ~3 months) #2204
Description
Summary
cleanup_old_claude_symlinks() discovers orphaned skill directories by iterating the gstack payload directory. When the payload is gone — which is exactly when orphans exist — the loop body never executes, so the orphans are never cleaned. The function is structurally incapable of running in the situation it exists to handle.
In my case this left 37 dead skill directories in a project repo for roughly three months. Each .claude/skills/<name>/ contained only a SKILL.md symlink into a .claude/skills/gstack/ payload that no longer existed. They never loaded; nothing surfaced an error; /gstack-upgrade never repaired them.
Verified present on origin/main @ 11de390 (v1.58.5.0).
The defect
setup:613 (as of 11de390):
cleanup_old_claude_symlinks() {
local gstack_dir="$1"
local skills_dir="$2"
local removed=()
for skill_dir in "$gstack_dir"/*/; do # <-- iterates the PAYLOAD
if [ -f "$skill_dir/SKILL.md" ]; then
...
elif [ -d "$old_target" ] && [ -L "$old_target/SKILL.md" ]; then
link_dest="$(readlink "$old_target/SKILL.md" 2>/dev/null || true)"
case "$link_dest" in
*gstack*) rm -rf "$old_target"; removed+=("$skill_name") ;;The list of directories to clean is derived from "$gstack_dir"/*/. If $gstack_dir has been deleted, the glob doesn't match, the loop body never runs, and zero orphans are removed. The orphans live in $skills_dir, not $gstack_dir — that's where the scan should happen.
Why /gstack-upgrade can't recover
gstack-upgrade/SKILL.md, install-type detection:
elif [ -d ".claude/skills/gstack" ]; then INSTALL_TYPE="vendored"
elif [ -d "$HOME/.claude/skills/gstack" ]; then INSTALL_TYPE="vendored-global"Once the project payload is deleted, detection falls through to vendored-global. Every subsequent upgrade operates on $HOME/.claude/skills/gstack and never revisits the project directory. The orphans are permanent — I had to remove them by hand.
How the payload likely disappeared
The team-mode branch in gstack-upgrade/SKILL.md deletes the payload and does not touch its 37 siblings:
git rm -r --cached .claude/skills/gstack/ 2>/dev/null || true
if ! grep -qF '.claude/skills/gstack/' .gitignore 2>/dev/null; then
echo '.claude/skills/gstack/' >> .gitignore
fi
rm -rf "$LOCAL_GSTACK"My repo's .gitignore contains exactly that appended line, committed the same day the 37 symlinks were created. I want to be careful here: I cannot prove this branch ran — gstack-config get team_mode returns empty on my machine today, and the .gitignore line landed inside a human commit, so it may have been added by hand.
It also doesn't matter much, because the payload is gitignored in the vendored layout, so any of these removes it with no way back:
git clean -fdx- a fresh clone or
git worktree add - the team-mode branch above
Whatever the trigger, cleanup_old_claude_symlinks() cannot subsequently clean up. That's the part worth fixing.
Reproduction
# from a repo with a vendored gstack install
rm -rf .claude/skills/gstack # simulate clean -fdx / fresh worktree / team-mode branch
~/.claude/skills/gstack/setup # or: /gstack-upgrade
# every .claude/skills/<name>/SKILL.md is now a dangling symlink, and stays that way
find .claude/skills -maxdepth 2 -name SKILL.md -type l ! -exec test -e {} \; -printThese orphans are easy to miss: a directory whose SKILL.md is a dangling symlink is invisible to [ -f "$d/SKILL.md" ] but still shows up in find and in a plain ls. diff -rq against a good copy reports DIFFERS, which reads as "contents differ" and sent me down the wrong path for a while.
Suggested fix
Scan the destination, not the source. Roughly:
cleanup_old_claude_symlinks() {
local gstack_dir="$1" skills_dir="$2"
local removed=()
for old_target in "$skills_dir"/*/; do
[ -L "$old_target/SKILL.md" ] || continue
link_dest="$(readlink "$old_target/SKILL.md" 2>/dev/null || true)"
case "$link_dest" in *gstack*) ;; *) continue ;; esac
# dangling, or points into the payload we're replacing → stale either way
if [ ! -e "$old_target/SKILL.md" ] || [ -n "$gstack_dir" ]; then
rm -rf "$old_target"
removed+=("$(basename "$old_target")")
fi
done
[ ${#removed[@]} -gt 0 ] && echo " cleaned up old entries: ${removed[*]}"
}This keeps the existing migration behavior (payload present → clean the flat names it's replacing) and additionally reaps dangling *gstack* symlinks when the payload is gone. The *gstack* guard on readlink preserves the current provenance check, so user-authored skills are never touched.
Two smaller things that would have helped independently:
setupcould warn when it finds.claude/skills/<name>/SKILL.mdsymlinks with no payload, even if it declines to delete them.- The team-mode branch in
gstack-upgrade/SKILL.mdshould call the cleanup helper afterrm -rf "$LOCAL_GSTACK", rather than leaving the siblings behind.
Environment
- gstack
1.58.5.0(11de390), global install at~/.claude/skills/gstack - macOS (Darwin 27.0.0), Claude Code
2.1.205 - Affected project had a vendored install created 2026-04-12; orphans discovered and removed 2026-07-09
Possibly related
- setup creates absolute symlinks for vendored installs, breaks portability #954 —
setupcreates absolute symlinks for vendored installs, breaks portability - gstack-uninstall leaves orphaned ~/.claude/skills/gstack-* directories (prefix-mode install) #896 (closed) —
gstack-uninstallleaves orphaned~/.claude/skills/gstack-*directories (prefix-mode) - Skills hardcode
~/.claude/skills/gstack/paths — any install dir not namedgstacksilently breaks aftersetup#1882 — skills hardcode~/.claude/skills/gstack/paths
Distinct from all three: this is the flat-name layout, the payload is absent rather than misnamed or non-portable, and the failure is in cleanup reachability rather than path resolution.
Activity
fix(setup): reap dangling skill dirs when the payload is gone
v1.69.0.0 fix: the silent-failure wave — 6 fixes, 5 community PRs abs…