#!/usr/bin/env bash # gstack-uninstall — remove gstack skills, state, and browse daemons # # Usage: # gstack-uninstall — interactive uninstall (prompts before removing) # gstack-uninstall --force — remove everything without prompting # gstack-uninstall --keep-state — remove skills but keep all gstack state, # including the repo's .gstack/ and .gstack-worktrees/ # gstack-uninstall --force-worktrees — also delete .gstack-worktrees/ checkouts # that have uncommitted changes (never a # locked worktree) # # Per-project state: git-tracked files under .gstack/ (ship policy and the # like) are never deleted; only the files git ignores go, and a .gstack/ that # holds its own git repository is kept. A checkout under .gstack-worktrees/ # (nested ones included) with uncommitted changes, commits no branch or tag # holds, a status git cannot read, or no registration as this repository's # worktree stops the uninstall (exit 2, before anything is removed) unless # --force-worktrees is given; a locked worktree, or a worktree list git cannot # read, stops it either way. The check runs again after the prompt, still before # anything is removed. Run from inside the install being removed, a .gstack/ # holding its own git repository also stops it. # # State: only the default root ~/.gstack/ is ever deleted. A state root # selected elsewhere (GSTACK_HOME, GSTACK_STATE_ROOT, GSTACK_STATE_DIR or # plugin data; chain in bin/gstack-state-root.sh) is left in place and the # exact removal command is printed. Uninstall refuses (exit 2) when ~/.gstack # resolves to /, $HOME or an ancestor, the gstack checkout, the current git # repository, or an ancestor of either. # Docs: https://github.com/garrytan/gstack/blob/main/docs/state-root.md # # What gets REMOVED: # ~/.claude/skills/gstack — global Claude skill install (git clone or vendored) # ~/.claude/skills/{skill} — per-skill symlinks created by setup # ~/.codex/skills/gstack* — Codex skill install + per-skill symlinks # ~/.factory/skills/gstack* — Factory Droid skill install + per-skill symlinks # ~/.kiro/skills/gstack* — Kiro skill install + per-skill symlinks # ~/.cursor/skills/gstack* — Cursor skill install + per-skill symlinks # ~/.config/opencode/skills/gstack* — OpenCode skill install + per-skill symlinks # ~/.copilot/skills/gstack* — GitHub Copilot CLI skill install + per-skill symlinks # ~/.gemini/antigravity-cli/skills/gstack* — Antigravity CLI skill install + per-skill symlinks # ~/.gemini/skills/gstack* — Gemini CLI skill install + per-skill symlinks (gstack-managed only) # harness registrations — every file gstack-harness wrote into OpenClaw, # Hermes, a Dot or another harness (from its # ledger; edited files move to a backup), and # its gstack:begin/end context block # installs.tsv rows — install registry rows for removed installs # ~/.gstack/ — global state (config, analytics, sessions, projects, # repos, installation-id, browse error logs) # .claude/skills/gstack* — project-local skill install (--local installs) # .gstack/ — per-project browse state (in current git repo; # git-tracked files are kept) # .gstack-worktrees/ — per-project test worktrees (in current git repo; # dirty ones need --force-worktrees) # .agents/skills/gstack* — Codex/Gemini sidecar (in current git repo) # .cursor/skills/gstack* — project-local Cursor skills (in current git repo) # Running browse daemons — stopped via SIGTERM before cleanup # # What is NOT REMOVED: # ~/Library/Caches/ms-playwright/ — Playwright Chromium (shared, may be used by other tools) # ~/.gstack-dev/ — developer eval artifacts (only present in gstack contributors) # # Env overrides (for testing): # GSTACK_DIR — override auto-detected gstack root # # NOTE: Uses set -uo pipefail (no -e) — uninstall must never abort partway. set -uo pipefail if [ -z "${HOME:-}" ]; then echo "ERROR: \$HOME is not set" >&2 exit 1 fi GSTACK_DIR="${GSTACK_DIR:-$(cd "$(dirname "$0")/.." && pwd)}" # Only the default root is ever deleted; RESOLVED_ROOT (the full chain) is # used read-only (daemon discovery) and reported when it is elsewhere. STATE_DIR="$HOME/.gstack" _STATE_DOC="https://github.com/garrytan/gstack/blob/main/docs/state-root.md" . "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade ($_STATE_DOC)" >&2; exit 1; } # Skill names without a process per skill (#2120); an older copy lacking the helper keeps the pipeline. . "$(dirname "$0")/gstack-skill-name.sh" 2>/dev/null \ || gstack_skill_md_name() { GSTACK_SKILL_NAME=$(grep -m1 '^name:' "$1" 2>/dev/null | sed 's/^name:[[:space:]]*//' | tr -d '[:space:]' || true); } gstack_state_root_select RESOLVED_ROOT="$_gstack_sr_root" RESOLVED_VAR="$_gstack_sr_var" _GIT_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || true)" # ─── Parse flags ───────────────────────────────────────────── FORCE=0 KEEP_STATE=0 FORCE_WORKTREES=0 while [ $# -gt 0 ]; do case "$1" in --force) FORCE=1; shift ;; --keep-state) KEEP_STATE=1; shift ;; --force-worktrees) FORCE_WORKTREES=1; shift ;; -h|--help) sed -n '2,/^[^#]/{ /^#/s/^# \{0,1\}//p; }' "$0" exit 0 ;; *) echo "Unknown option: $1" >&2 echo "Usage: gstack-uninstall [--force] [--keep-state] [--force-worktrees]" >&2 exit 1 ;; esac done # ─── State-root safety ─────────────────────────────────────── _real() { (cd -P -- "$1" 2>/dev/null && pwd -P); } # _is_at_or_above TARGET PATH — TARGET is PATH or an ancestor of it. _is_at_or_above() { [ -n "$2" ] || return 1 [ "$1" = "$2" ] && return 0 case "$2/" in "${1%/}/"*) return 0 ;; esac return 1 } _refuse() { echo "gstack-uninstall: refusing to delete $STATE_DIR: $1" >&2 echo "fix: $2 (docs: $_STATE_DOC)" >&2 exit 2 } if [ "$KEEP_STATE" -eq 0 ] && [ -d "$STATE_DIR" ]; then _STATE_REAL="$(_real "$STATE_DIR")" _HOME_REAL="$(_real "$HOME")" _CHECKOUT_REAL="$(_real "$GSTACK_DIR")" _GIT_REAL="" [ -n "$_GIT_ROOT" ] && _GIT_REAL="$(_real "$_GIT_ROOT")" if [ -z "$_STATE_REAL" ] || [ "$_STATE_REAL" = "/" ]; then _refuse "it resolves to the filesystem root /" "remove the link with rm -- '$STATE_DIR', or re-run with --keep-state" elif _is_at_or_above "$_STATE_REAL" "$_HOME_REAL"; then _refuse "it resolves to $_STATE_REAL, which is your home directory or an ancestor of it" "remove the link with rm -- '$STATE_DIR', or re-run with --keep-state" elif _is_at_or_above "$_STATE_REAL" "$_CHECKOUT_REAL"; then _refuse "it resolves to $_STATE_REAL, which is the gstack checkout $_CHECKOUT_REAL or an ancestor of it" "remove the link with rm -- '$STATE_DIR', or re-run with --keep-state" elif _is_at_or_above "$_STATE_REAL" "$_GIT_REAL"; then _refuse "it resolves to $_STATE_REAL, which is the current git repository $_GIT_REAL or an ancestor of it" "run gstack-uninstall from outside $_GIT_REAL, or re-run with --keep-state" fi fi # The resolved root is left in place when it is not the default root. _LEAVE_ROOT="" if [ "$KEEP_STATE" -eq 0 ] && [ -e "$RESOLVED_ROOT" ]; then _RESOLVED_REAL="$(_real "$RESOLVED_ROOT")" _DEFAULT_REAL="$(_real "$STATE_DIR")" if [ "$RESOLVED_ROOT" != "$STATE_DIR" ] && { [ -z "$_RESOLVED_REAL" ] || [ "$_RESOLVED_REAL" != "$_DEFAULT_REAL" ]; }; then _LEAVE_ROOT="$RESOLVED_ROOT" fi fi # ─── Per-project worktree safety ───────────────────────────── # Every checkout on disk under .gstack-worktrees/ (a folder holding a .git), # registered or not, including checkouts nested inside another one, # NUL-separated so no name can split into a relative path. Symlinks are never # followed; a symlinked .gstack-worktrees is only unlinked. _project_worktrees() { [ -d "$_GIT_ROOT/.gstack-worktrees" ] || return 0 find "$_GIT_ROOT/.gstack-worktrees" -name .git -prune -print0 2>/dev/null | while IFS= read -r -d '' _dotgit; do printf '%s\0' "${_dotgit%/.git}" done } # Fills _REGISTERED_WORKTREES with the real paths of the worktrees this # repository registered and _LOCKED_WORKTREES with the locked ones. The list is # read NUL-separated, so a path holding a newline stays one unquoted path. _read_registered_worktrees() { local _field _path="" _locked=0 _REGISTERED_WORKTREES=() _LOCKED_WORKTREES=() while IFS= read -r -d '' _field; do case "$_field" in "worktree "*) _path="$(_real "${_field#worktree }")"; _locked=0 ;; locked|"locked "*) _locked=1 ;; "") [ -n "$_path" ] || continue _REGISTERED_WORKTREES+=("$_path") [ "$_locked" -eq 0 ] || _LOCKED_WORKTREES+=("$_path") _path="" ;; esac done < <(_worktree_list git -C "$_GIT_ROOT") } # "$@" worktree list --porcelain, NUL-separated. git before 2.36 has no -z and # fails here, so the uninstall refuses instead of reading a list it cannot split. _worktree_list() { "$@" worktree list --porcelain -z 2>/dev/null } # _in_list VALUE ITEM... — VALUE equals one of the ITEMs. _in_list() { local _want="$1" _item shift for _item in "$@"; do [ "$_item" = "$_want" ] && return 0; done return 1 } # git inside a checkout, with its hooks and fsmonitor turned off. _wt_git() { local _dir="$1" shift git -c core.fsmonitor=false -c core.hooksPath=/dev/null --no-optional-locks -C "$_dir" "$@" } # Prints why deleting checkout $1 would lose work; prints nothing when it is safe. # A checkout this repository did not register is never opened with git, so its # own config cannot run anything; a submodule of a registered lane is the one # exception, read with rev-list under _wt_git. _worktree_risk() { local _dir _status _head _local _dir="$(_real "$1")" case "$_dir" in ""|*" "*) echo "it is not a registered worktree of this repository"; return 0 ;; esac if ! _in_list "$_dir" ${_REGISTERED_WORKTREES[@]+"${_REGISTERED_WORKTREES[@]}"}; then _is_registered_submodule "$_dir" || { echo "it is not a registered worktree of this repository"; return 0; } _local="$(_wt_git "$_dir" rev-list -n 1 --all --not --remotes 2>/dev/null)" || { echo "git cannot read its history"; return 0; } [ -z "$_local" ] || echo "it is a submodule with commits no remote-tracking branch contains" return 0 fi [ "$(_real "$(_wt_git "$1" rev-parse --show-toplevel 2>/dev/null)")" = "$_dir" ] || { echo "git cannot read it as a checkout"; return 0; } _status="$(_wt_git "$1" status --porcelain --untracked-files=all --ignore-submodules=none 2>/dev/null)" || { echo "git cannot read its status"; return 0; } [ -z "$_status" ] || { echo "it has uncommitted changes"; return 0; } _head="$(_wt_git "$1" rev-parse -q --verify HEAD 2>/dev/null)" || return 0 [ -n "$(git -C "$_GIT_ROOT" for-each-ref --contains "$_head" --count=1 --format=x 2>/dev/null)" ] || echo "it has commits no branch or tag contains" } # Succeeds when checkout $1 is a submodule of the closest checkout under # .gstack-worktrees that holds it, and that checkout is a registered worktree or # (for a nested submodule) itself such a submodule: its index records exactly # this path as a gitlink, so its status reports the submodule's changes. Commits # only the submodule's own refs hold are checked by the caller. _is_registered_submodule() { local _wt _parent="" _rel _entry for _wt in ${_CHECKOUTS_ON_DISK[@]+"${_CHECKOUTS_ON_DISK[@]}"}; do case "$1" in "$_wt"/*) [ ${#_wt} -gt ${#_parent} ] && _parent="$_wt" ;; esac done [ -n "$_parent" ] || return 1 _in_list "$_parent" ${_REGISTERED_WORKTREES[@]+"${_REGISTERED_WORKTREES[@]}"} || _is_registered_submodule "$_parent" || return 1 _rel="${1#"$_parent"/}" _entry="$(_wt_git "$_parent" ls-files --stage -z -- ":(literal)$_rel" 2>/dev/null | tr '\0' '\n')" case "$_entry" in 160000\ *) ;; *) return 1 ;; esac [ "${_entry#* }" = "$_rel" ] } # Prints one "refusing to delete" line per checkout under .gstack-worktrees/ # that must be kept; prints nothing when every one can go. A locked worktree # is kept even with --force-worktrees. _worktree_refusals() { local _wt _risk if ! _worktree_list git -C "$_GIT_ROOT" >/dev/null; then echo "gstack-uninstall: refusing to delete $_GIT_ROOT/.gstack-worktrees/: git cannot list this repository's worktrees" return 0 fi _read_registered_worktrees _CHECKOUTS_ON_DISK=() while IFS= read -r -d '' _wt; do _CHECKOUTS_ON_DISK+=("$(_real "$_wt")") done < <(_project_worktrees) while IFS= read -r -d '' _wt; do [ -n "$_wt" ] || continue if _in_list "$(_real "$_wt")" ${_LOCKED_WORKTREES[@]+"${_LOCKED_WORKTREES[@]}"}; then _risk="it is locked (git worktree lock)" elif [ "$FORCE_WORKTREES" -eq 1 ]; then continue else _risk="$(_worktree_risk "$_wt")" fi [ -z "$_risk" ] || echo "gstack-uninstall: refusing to delete $_wt: $_risk" done < <(_project_worktrees) } _WORKTREES_FIX="fix: commit the work to a branch or stash it, unlock a locked worktree with git worktree unlock , re-run with --keep-state to keep every worktree, or re-run with --force-worktrees to delete the unlocked ones anyway" # Succeeds when the current repository sits inside a gstack install this # uninstall deletes, so nothing in it can be kept by keeping one folder. _git_root_in_install() { local _install _root _root="$(_real "$_GIT_ROOT")" for _install in "$HOME/.claude/skills/gstack" "$HOME"/.codex/skills/gstack* "$HOME"/.factory/skills/gstack* "$HOME"/.kiro/skills/gstack* \ "$HOME"/.cursor/skills/gstack* "$HOME"/.config/opencode/skills/gstack* "$HOME"/.copilot/skills/gstack* \ "$HOME"/.gemini/antigravity-cli/skills/gstack* "$HOME"/.gemini/skills/gstack*; do [ -d "$_install" ] || continue _is_at_or_above "$(_real "$_install")" "$_root" && return 0 done return 1 } # Every keep decision for per-project state, made before anything is removed: # exits 2 when a checkout must be kept, or when the repository's .gstack/ holds # its own git repository inside the install being deleted. $1, when set, is # printed before the fix line. _check_project_keep() { [ -n "$_GIT_ROOT" ] || return 0 if [ "$KEEP_STATE" -eq 0 ] && [ -d "$_GIT_ROOT/.gstack-worktrees" ] && [ ! -L "$_GIT_ROOT/.gstack-worktrees" ]; then _REFUSALS="$(_worktree_refusals)" if [ -n "$_REFUSALS" ]; then printf '%s\n' "$_REFUSALS" >&2 [ -z "${1:-}" ] || echo "$1" >&2 echo "$_WORKTREES_FIX" >&2 exit 2 fi fi if [ -d "$_GIT_ROOT/.gstack" ] && [ ! -L "$_GIT_ROOT/.gstack" ] && [ -n "$(find "$_GIT_ROOT/.gstack" -name .git -print -quit 2>/dev/null)" ] && _git_root_in_install; then echo "gstack-uninstall: refusing to uninstall: $_GIT_ROOT/.gstack/ holds its own git repository, and it sits inside the gstack install being removed" >&2 echo "fix: move $_GIT_ROOT/.gstack/ out of the install (or push its commits), then re-run gstack-uninstall" >&2 exit 2 fi } # ─── Deletion inventory ────────────────────────────────────── CLAUDE_SKILLS="$HOME/.claude/skills" # Skill-name inventory (#2563 gate a): every name setup could have installed — # each source skill's directory name, its frontmatter name, their gstack- # prefixed variants, and the alias dirs. Built BEFORE the install root is # removed. A real directory in ~/.claude/skills is only deletable when its # name is in this inventory AND its SKILL.md carries the generated banner. # The seed names below are the alias dirs setup's _install_alias_skill_md # creates (setup: link_claude_root_skill_alias + the connect-chrome call # sites) — keep in sync with setup if an alias is added or renamed there. _INVENTORY=" _gstack-command connect-chrome gstack-connect-chrome " if [ -d "$GSTACK_DIR" ]; then for _SRC in "$GSTACK_DIR"/*/; do [ -f "$_SRC/SKILL.md" ] || continue _SRC_NAME=${_SRC%/}; _SRC_NAME=${_SRC_NAME##*/} gstack_skill_md_name "$_SRC/SKILL.md"; _FM_NAME=$GSTACK_SKILL_NAME for _N in "$_SRC_NAME" "$_FM_NAME"; do [ -n "$_N" ] || continue case "$_INVENTORY" in *" $_N "*) ;; *) _INVENTORY="$_INVENTORY$_N gstack-$_N " ;; esac done done fi _in_skill_inventory() { case "$_INVENTORY" in *" $1 "*) return 0 ;; *) return 1 ;; esac; } # Prints what the removal step does with ~/.claude/skills entry $1: unlink, # delete, keep (listed, not provably gstack-managed), or nothing. Per-skill # entries created by setup come in three shapes: # 1. symlink entry (oldest installs) # 2. real dir + SYMLINKED SKILL.md (standard Unix install) # 3. real dir + REAL-FILE SKILL.md (Windows copy install, #2563) # Shape 3 was skipped entirely — gstack-uninstall exited 0 and reported # success while leaving ~52 gstack-* directories behind on Windows. _claude_entry_action() { local _name="${1##*/}" _target [ "$_name" = "gstack" ] && return 0 if [ -L "$1" ]; then _target="$(readlink "$1" 2>/dev/null || true)" case "$_target" in gstack/*|*/gstack/*) echo unlink ;; esac elif [ -d "$1" ] && { [ -f "$1/SKILL.md" ] || [ -L "$1/SKILL.md" ]; }; then if [ -L "$1/SKILL.md" ]; then # Shape 2: provenance readable from the symlink target itself. # Gate 1: the name must be in gstack's skill inventory (parity with # shape 3). Gate 2: the target must contain "gstack" as an ANCHORED # path segment (gstack/*|*/gstack/*, same pattern as shape 1) — a # bare *gstack* substring match would wipe a user's own skill whose # SKILL.md merely lives under e.g. ~/tools/gstack-fork/. _target="$(readlink "$1/SKILL.md" 2>/dev/null || true)" if _in_skill_inventory "$_name"; then case "$_target" in gstack/*|*/gstack/*) echo delete ;; *) echo keep ;; esac else echo keep fi elif _in_skill_inventory "$_name" && grep -q '