Skip to content

setup: cleanup_old_claude_symlinks() derives orphans from the payload dir, so it can never clean orphans (37 dead skill dirs, ~3 months) #2204

Closed

Description

@UltraIntelligence

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 {} \; -print

These 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:

  1. setup could warn when it finds .claude/skills/<name>/SKILL.md symlinks with no payload, even if it declines to delete them.
  2. The team-mode branch in gstack-upgrade/SKILL.md should call the cleanup helper after rm -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

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

  1. added a commit that references this issue on Aug 22, 2026
    b15bb9d
  2. added a commit that references this issue on Aug 24, 2026
    ad84005
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions