Files
gstack/bin/gstack-uninstall
T
Garry Tanandcapy-ai[bot] 6c23638d6d Stop gstack-uninstall deleting nested worktrees, locked worktrees and a .gstack repo (GST-2268) (#4267)
* Stop gstack-uninstall deleting nested worktrees, locked worktrees and a .gstack repo (GST-2268)

The worktree check now walks every checkout under .gstack-worktrees/,
nested ones included, and keeps a submodule of a clean lane covered by
that lane's status. A locked worktree stops the uninstall even with
--force-worktrees. A .gstack/ that holds its own git repository is kept
with a message. The check runs again right before .gstack-worktrees/ is
deleted, so work saved while the prompt waited is kept (exit 2). The
manual steps in docs/uninstall.md no longer rm -rf the per-project
folders.

test/uninstall.test.ts is already excluded from the Windows subset by
scripts/lib/windows-curation.ts (it spawns a bin/ shebang script).

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Keep a lane's submodule when it holds commits no remote-tracking branch has (GST-2268)

The submodule exemption trusted the lane's status, which only compares the submodule's HEAD to the recorded gitlink. Commits on the submodule's own branches or stash live in the lane's git dir and were deleted with exit 0. Uninstall now refuses such a submodule, and matches the gitlink by exact path instead of a prefix or glob pathspec.

Review-Finding: s11/outside-voice#1 reader=outside-voice
Review-Finding: s11/adversarial-native#1 reader=adversarial-native
Review-Finding: p1/specialist-batched#1 reader=testing
Review-Finding: p1/red-team#3 reader=red-team
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Explain the submodule rule in the uninstall docs (GST-2268)

docs/uninstall.md now says a submodule inside a clean lane goes only when its commits are on a remote-tracking branch, and the changelog entry drops a mid-sentence colon.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Make every gstack-uninstall keep check run before anything is deleted (GST-2268)

The post-prompt worktree check now runs right after confirmation, before
the install root or anything else is removed, so running uninstall from
inside the gstack install no longer deletes a worktree edited during the
prompt. A .gstack/ holding its own git repository inside the install being
removed now stops the uninstall. A failing git worktree list stops it too,
even with --force-worktrees. The manual cleanup in docs/uninstall.md now
matches worktree paths by prefix, so it never removes a worktree outside
the project whose path merely contains it.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Also stop gstack-uninstall when a .gstack repo sits inside a Cursor, OpenCode, Copilot, Antigravity or Gemini install (GST-2268)

Review-Finding: p1/specialist-batched#1 reader=red-team
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Split a run-on sentence in the uninstall release note (GST-2268)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-11-10 07:15:05 +00:00

789 lines
37 KiB
Bash
Executable File

#!/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
}
# Real paths of the worktrees this repository registered (all, or with
# "locked" only the locked ones).
_registered_worktrees() {
git -C "$_GIT_ROOT" worktree list --porcelain 2>/dev/null | awk -v only="${1:-}" '
/^worktree / { if (path != "" && (only == "" || locked)) print path; path = substr($0, 10); locked = 0 }
/^locked( |$)/ { locked = 1 }
END { if (path != "" && (only == "" || locked)) print path }' | while IFS= read -r _wt; do
_real "$_wt"
done
}
# git inside a registered checkout, with no config able to run a command.
_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 ! printf '%s\n' "$_REGISTERED_WORKTREES" | grep -Fxq -- "$_dir"; 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 registered worktree under
# .gstack-worktrees that holds it: that worktree's 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="" _base _rel _entry
_base="$(_real "$_GIT_ROOT/.gstack-worktrees")"
while IFS= read -r _wt; do
case "$_wt" in "$_base"/*) ;; *) continue ;; esac
case "$1" in "$_wt"/*) [ ${#_wt} -gt ${#_parent} ] && _parent="$_wt" ;; esac
done <<EOF
$_REGISTERED_WORKTREES
EOF
[ -n "$_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 ! git -C "$_GIT_ROOT" worktree list --porcelain >/dev/null 2>&1; then
echo "gstack-uninstall: refusing to delete $_GIT_ROOT/.gstack-worktrees/: git cannot list this repository's worktrees"
return 0
fi
_REGISTERED_WORKTREES="$(_registered_worktrees)"
_LOCKED_WORKTREES="$(_registered_worktrees locked)"
while IFS= read -r -d '' _wt; do
[ -n "$_wt" ] || continue
if [ -n "$_LOCKED_WORKTREES" ] && printf '%s\n' "$_LOCKED_WORKTREES" | grep -Fxq -- "$(_real "$_wt")"; 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 <path>, 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
}
_check_project_keep
_report_left_root() {
[ -n "$_LEAVE_ROOT" ] || return 0
local _val
eval "_val=\${$RESOLVED_VAR:-}"
echo "left in place: $_LEAVE_ROOT (selected by $RESOLVED_VAR=$_val)" >&2
echo "fix: after checking it, remove it with rm -rf -- '$_LEAVE_ROOT' (docs: $_STATE_DOC)" >&2
}
# ─── Confirmation ────────────────────────────────────────────
if [ "$FORCE" -eq 0 ]; then
echo "This will remove gstack from your system:"
{ [ -d "$HOME/.claude/skills/gstack" ] || [ -L "$HOME/.claude/skills/gstack" ]; } && echo " ~/.claude/skills/gstack (+ per-skill symlinks)"
[ -d "$HOME/.codex/skills" ] && echo " ~/.codex/skills/gstack*"
[ -d "$HOME/.factory/skills" ] && echo " ~/.factory/skills/gstack*"
[ -d "$HOME/.kiro/skills" ] && echo " ~/.kiro/skills/gstack*"
[ -d "$HOME/.cursor/skills" ] && echo " ~/.cursor/skills/gstack*"
[ -d "$HOME/.config/opencode/skills" ] && echo " ~/.config/opencode/skills/gstack*"
[ -d "$HOME/.copilot/skills" ] && echo " ~/.copilot/skills/gstack*"
[ -d "$HOME/.gemini/antigravity-cli/skills" ] && echo " ~/.gemini/antigravity-cli/skills/gstack*"
[ -d "$HOME/.gemini/skills" ] && echo " ~/.gemini/skills/gstack*"
[ "$KEEP_STATE" -eq 0 ] && [ -d "$STATE_DIR" ] && echo " $STATE_DIR"
[ -n "$_LEAVE_ROOT" ] && echo " (kept) $_LEAVE_ROOT: state root selected by $RESOLVED_VAR; left in place"
if [ -n "$_GIT_ROOT" ]; then
[ -d "$_GIT_ROOT/.claude/skills/gstack" ] && echo " $_GIT_ROOT/.claude/skills/gstack (project-local)"
if [ "$KEEP_STATE" -eq 0 ]; then
[ -d "$_GIT_ROOT/.gstack" ] && echo " $_GIT_ROOT/.gstack/ (browse state + reports; git-tracked files are kept)"
[ -d "$_GIT_ROOT/.gstack-worktrees" ] && echo " $_GIT_ROOT/.gstack-worktrees/"
fi
[ -d "$_GIT_ROOT/.agents/skills" ] && echo " $_GIT_ROOT/.agents/skills/gstack*"
[ -d "$_GIT_ROOT/.cursor/skills" ] && echo " $_GIT_ROOT/.cursor/skills/gstack*"
fi
# Preview running daemons
if [ -n "$_GIT_ROOT" ] && [ -f "$_GIT_ROOT/.gstack/browse.json" ]; then
_PREVIEW_PID="$(awk -F'[:,]' '/"pid"/ { for(i=1;i<=NF;i++) if($i ~ /"pid"/) { gsub(/[^0-9]/, "", $(i+1)); print $(i+1); exit } }' "$_GIT_ROOT/.gstack/browse.json" 2>/dev/null || true)"
[ -n "$_PREVIEW_PID" ] && kill -0 "$_PREVIEW_PID" 2>/dev/null && echo " browse daemon (PID $_PREVIEW_PID) will be stopped"
fi
printf "\nContinue? [y/N] "
read -r REPLY
case "$REPLY" in
y|Y|yes|YES) ;;
*) echo "Aborted."; exit 0 ;;
esac
_check_project_keep "nothing was removed: a checkout changed while the prompt waited"
fi
REMOVED=()
# ─── Stop running browse daemons ─────────────────────────────
# Browse servers write PID to {project}/.gstack/browse.json.
# Stop any we can find before removing state directories.
stop_browse_daemon() {
local state_file="$1"
if [ ! -f "$state_file" ]; then
return
fi
local pid
pid="$(awk -F'[:,]' '/"pid"/ { for(i=1;i<=NF;i++) if($i ~ /"pid"/) { gsub(/[^0-9]/, "", $(i+1)); print $(i+1); exit } }' "$state_file" 2>/dev/null || true)"
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
kill "$pid" 2>/dev/null || true
# Wait up to 2s for graceful shutdown
local waited=0
while [ "$waited" -lt 4 ] && kill -0 "$pid" 2>/dev/null; do
sleep 0.5
waited=$(( waited + 1 ))
done
if kill -0 "$pid" 2>/dev/null; then
kill -9 "$pid" 2>/dev/null || true
fi
REMOVED+=("browse daemon (PID $pid)")
fi
}
# Stop daemon in current project
if [ -n "$_GIT_ROOT" ] && [ -f "$_GIT_ROOT/.gstack/browse.json" ]; then
stop_browse_daemon "$_GIT_ROOT/.gstack/browse.json"
fi
# Stop daemons tracked in global projects directories (the resolved root is
# only read here, never deleted).
_PROJ_ROOTS=("$STATE_DIR")
[ "$RESOLVED_ROOT" != "$STATE_DIR" ] && _PROJ_ROOTS+=("$RESOLVED_ROOT")
for _PROJ_ROOT in "${_PROJ_ROOTS[@]}"; do
[ -d "$_PROJ_ROOT/projects" ] || continue
while IFS= read -r _BJ; do
stop_browse_daemon "$_BJ"
done < <(find "$_PROJ_ROOT/projects" -name browse.json -path '*/.gstack/*' 2>/dev/null || true)
done
# ─── Remove gstack hooks from Claude Code settings ──────────
# MUST run BEFORE any install-root deletion: SETTINGS_HOOK resolves inside the
# install being removed — in a real global uninstall, running this after
# `rm -rf ~/.claude/skills/gstack` silently no-ops and orphans every hook.
SETTINGS_HOOK="$(dirname "$0")/gstack-settings-hook"
SESSION_UPDATE="$(dirname "$0")/gstack-session-update"
GSTACK_CONFIG="$(dirname "$0")/gstack-config"
if [ -x "$SETTINGS_HOOK" ]; then
"$SETTINGS_HOOK" remove "$SESSION_UPDATE" && REMOVED+=("SessionStart hook") || true
# Cathedral T8 cleanup: also remove plan-tune PreToolUse + PostToolUse hooks.
if "$SETTINGS_HOOK" remove-source --source plan-tune-cathedral | grep -q "removed [1-9]"; then
REMOVED+=("plan-tune cathedral hooks")
fi
# AskUserQuestion error-fallback hook (registered by setup; previously never
# torn down).
if "$SETTINGS_HOOK" remove-source --source auq-error-fallback | grep -q "removed [1-9]"; then
REMOVED+=("AskUserQuestion error-fallback hook")
fi
# Timeline Stop hook (#2553).
if "$SETTINGS_HOOK" remove-source --source gstack-timeline-stop | grep -q "removed [1-9]"; then
REMOVED+=("timeline Stop hook")
fi
# Verification stop hook (opt-in via README; user-registered, ours to sweep).
if "$SETTINGS_HOOK" remove-source --source verify-gate | grep -q "removed [1-9]"; then
REMOVED+=("verification Stop hook")
fi
# Memorable recall bridge (opt-in via bin/gstack-memorable; user-registered,
# ours to sweep). gstack removes only its own hook entry: the vendor's
# consent, if the user granted it, is theirs (`memorable disable|forget`).
if "$SETTINGS_HOOK" remove-source --source gstack-memorable | grep -q "removed [1-9]"; then
REMOVED+=("Memorable UserPromptSubmit hook (Memorable's own consent is unchanged: memorable disable | memorable forget)")
fi
# Identity sweep for untagged strays (Claude Code strips _gstack_source
# tags; pre-v1.67 setups baked worktree paths). Removes every gstack-owned
# hook item, live or dead — the binaries they point at are being deleted.
if "$SETTINGS_HOOK" prune-stale --all | grep -q "removed [1-9]"; then
REMOVED+=("stray gstack hook entries")
fi
fi
# The Memorable consent key must never outlive the hook, kept state or not, and
# not only when the hook manager is present: gstack-config resolves its root
# through GSTACK_STATE_ROOT/GSTACK_HOME, which can be a different directory
# from the STATE_DIR removed below. Only flipped when it is actually on, so no
# config file is created just to say off; a failed flip is named, not hidden.
if [ -x "$GSTACK_CONFIG" ] && [ "$("$GSTACK_CONFIG" get memorable_recall 2>/dev/null)" = "on" ]; then
if "$GSTACK_CONFIG" set memorable_recall off >/dev/null 2>&1; then
REMOVED+=("memorable_recall consent (set off)")
else
echo "WARNING: could not set memorable_recall off; run: $GSTACK_CONFIG set memorable_recall off" >&2
fi
fi
# ─── Remove harness registrations (bin/gstack-harness) ──────
# Each ledger names exactly the files gstack-harness wrote into a harness's own
# skills folder; read before the checkout and the state root go. A QM org pack
# is left alone: `gstack-harness unregister qm --remote` archives it.
if [ -d "$RESOLVED_ROOT/harness" ] && [ -f "$(dirname "$0")/gstack-harness-ledger.sh" ]; then
while IFS= read -r _HARNESS_LINE; do
case "$_HARNESS_LINE" in harness:*) REMOVED+=("$_HARNESS_LINE") ;; ?*) echo "$_HARNESS_LINE" ;; esac
done <<EOF
$(GSTACK_STATE_ROOT="$RESOLVED_ROOT"; . "$(dirname "$0")/gstack-harness-ledger.sh" && gstack_harness_unregister_all)
EOF
fi
# ─── Remove global Claude skills ────────────────────────────
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; }
_SKIPPED_DIRS=()
if [ -d "$CLAUDE_SKILLS/gstack" ] || [ -L "$CLAUDE_SKILLS/gstack" ]; then
# Remove per-skill entries created by setup. Three install shapes exist:
# 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.
for _ENTRY in "$CLAUDE_SKILLS"/*; do
_NAME="$(basename "$_ENTRY")"
[ "$_NAME" = "gstack" ] && continue
if [ -L "$_ENTRY" ]; then
_TARGET="$(readlink "$_ENTRY" 2>/dev/null || true)"
case "$_TARGET" in
gstack/*|*/gstack/*) rm -f "$_ENTRY"; REMOVED+=("claude/$_NAME") ;;
esac
elif [ -d "$_ENTRY" ] && { [ -f "$_ENTRY/SKILL.md" ] || [ -L "$_ENTRY/SKILL.md" ]; }; then
if [ -L "$_ENTRY/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 "$_ENTRY/SKILL.md" 2>/dev/null || true)"
if _in_skill_inventory "$_NAME"; then
case "$_TARGET" in
gstack/*|*/gstack/*) rm -rf "$_ENTRY"; REMOVED+=("claude/$_NAME") ;;
*) _SKIPPED_DIRS+=("$_ENTRY") ;;
esac
else
_SKIPPED_DIRS+=("$_ENTRY")
fi
elif _in_skill_inventory "$_NAME" && grep -q '<!-- AUTO-GENERATED from' "$_ENTRY/SKILL.md" 2>/dev/null; then
# Shape 3: delete ONLY when BOTH gates pass (F8) — the name is in
# gstack's skill inventory AND the SKILL.md carries the existing
# generated banner. ENG-OV10: the banner IS the provenance marker —
# every pre-v1.67 copy already carries it; inventing a new marker
# would refuse to delete legitimate old installs, recreating #2563.
rm -rf "$_ENTRY"
REMOVED+=("claude/$_NAME")
else
# A real dir we cannot prove is gstack-managed (name collision with a
# user's own skill, or a hand-written SKILL.md): NEVER delete — list.
_SKIPPED_DIRS+=("$_ENTRY")
fi
fi
done
rm -rf "$CLAUDE_SKILLS/gstack"
REMOVED+=("~/.claude/skills/gstack")
fi
# ─── Remove project-local Claude skills (--local installs) ──
if [ -n "$_GIT_ROOT" ] && [ -d "$_GIT_ROOT/.claude/skills" ]; then
for _LINK in "$_GIT_ROOT/.claude/skills"/*; do
[ -L "$_LINK" ] || continue
_TARGET="$(readlink "$_LINK" 2>/dev/null || true)"
case "$_TARGET" in
gstack/*|*/gstack/*) rm -f "$_LINK"; REMOVED+=("local claude/$(basename "$_LINK")") ;;
esac
done
if [ -d "$_GIT_ROOT/.claude/skills/gstack" ] || [ -L "$_GIT_ROOT/.claude/skills/gstack" ]; then
rm -rf "$_GIT_ROOT/.claude/skills/gstack"
REMOVED+=("$_GIT_ROOT/.claude/skills/gstack")
fi
fi
# ─── Remove Codex skills ────────────────────────────────────
CODEX_SKILLS="$HOME/.codex/skills"
if [ -d "$CODEX_SKILLS" ]; then
for _ITEM in "$CODEX_SKILLS"/gstack*; do
[ -e "$_ITEM" ] || [ -L "$_ITEM" ] || continue
rm -rf "$_ITEM"
REMOVED+=("codex/$(basename "$_ITEM")")
done
fi
# ─── Remove Factory Droid skills ────────────────────────────
FACTORY_SKILLS="$HOME/.factory/skills"
if [ -d "$FACTORY_SKILLS" ]; then
for _ITEM in "$FACTORY_SKILLS"/gstack*; do
[ -e "$_ITEM" ] || [ -L "$_ITEM" ] || continue
rm -rf "$_ITEM"
REMOVED+=("factory/$(basename "$_ITEM")")
done
fi
# ─── Remove Kiro skills ─────────────────────────────────────
KIRO_SKILLS="$HOME/.kiro/skills"
if [ -d "$KIRO_SKILLS" ]; then
for _ITEM in "$KIRO_SKILLS"/gstack*; do
[ -e "$_ITEM" ] || [ -L "$_ITEM" ] || continue
rm -rf "$_ITEM"
REMOVED+=("kiro/$(basename "$_ITEM")")
done
fi
# ─── Remove Cursor skills ───────────────────────────────────
# Cursor installs are rendered REAL directories, so a bare gstack* glob could
# sweep a user's own dir that merely starts with "gstack" (e.g.
# ~/.cursor/skills/gstack-fork-notes). Provenance gate: a real dir is only
# deleted when its SKILL.md carries the generated banner; anything else is
# listed, never deleted. Symlinks stay ungated — removing a link never
# destroys user content.
_cursor_item_is_gstack_managed() {
# Symlinks and plain files are safe to remove; real dirs need the banner.
if [ -L "$1" ] || [ ! -d "$1" ]; then return 0; fi
grep -q '<!-- AUTO-GENERATED from' "$1/SKILL.md" 2>/dev/null
}
# ─── Remove OpenCode skills (same provenance gate as Cursor) ────────────────
OPENCODE_SKILLS="$HOME/.config/opencode/skills"
if [ -d "$OPENCODE_SKILLS" ]; then
for _ITEM in "$OPENCODE_SKILLS"/gstack*; do
[ -e "$_ITEM" ] || [ -L "$_ITEM" ] || continue
if _cursor_item_is_gstack_managed "$_ITEM"; then
rm -rf "$_ITEM"
REMOVED+=("opencode/$(basename "$_ITEM")")
else
_SKIPPED_DIRS+=("$_ITEM")
fi
done
fi
CURSOR_SKILLS="$HOME/.cursor/skills"
if [ -d "$CURSOR_SKILLS" ]; then
for _ITEM in "$CURSOR_SKILLS"/gstack*; do
[ -e "$_ITEM" ] || [ -L "$_ITEM" ] || continue
if _cursor_item_is_gstack_managed "$_ITEM"; then
rm -rf "$_ITEM"
REMOVED+=("cursor/$(basename "$_ITEM")")
else
_SKIPPED_DIRS+=("$_ITEM")
fi
done
fi
# ─── Remove per-project .agents/ sidecar ─────────────────────
if [ -n "$_GIT_ROOT" ] && [ -d "$_GIT_ROOT/.agents/skills" ]; then
for _ITEM in "$_GIT_ROOT/.agents/skills"/gstack*; do
[ -e "$_ITEM" ] || [ -L "$_ITEM" ] || continue
rm -rf "$_ITEM"
REMOVED+=("agents/$(basename "$_ITEM")")
done
rmdir "$_GIT_ROOT/.agents/skills" 2>/dev/null || true
rmdir "$_GIT_ROOT/.agents" 2>/dev/null || true
fi
# ─── Remove per-project .factory/ sidecar ────────────────────
if [ -n "$_GIT_ROOT" ] && [ -d "$_GIT_ROOT/.factory/skills" ]; then
for _ITEM in "$_GIT_ROOT/.factory/skills"/gstack*; do
[ -e "$_ITEM" ] || [ -L "$_ITEM" ] || continue
rm -rf "$_ITEM"
REMOVED+=("factory/$(basename "$_ITEM")")
done
rmdir "$_GIT_ROOT/.factory/skills" 2>/dev/null || true
rmdir "$_GIT_ROOT/.factory" 2>/dev/null || true
fi
# ─── Remove per-project .cursor/skills/gstack* ──────────────
# Never rmdir .cursor itself — Cursor IDE stores rules and other user config there.
# Same provenance gate as the global cursor block above.
if [ -n "$_GIT_ROOT" ] && [ -d "$_GIT_ROOT/.cursor/skills" ]; then
for _ITEM in "$_GIT_ROOT/.cursor/skills"/gstack*; do
[ -e "$_ITEM" ] || [ -L "$_ITEM" ] || continue
if _cursor_item_is_gstack_managed "$_ITEM"; then
rm -rf "$_ITEM"
REMOVED+=("cursor/$(basename "$_ITEM")")
else
_SKIPPED_DIRS+=("$_ITEM")
fi
done
rmdir "$_GIT_ROOT/.cursor/skills" 2>/dev/null || true
fi
# ─── Remove per-project state ───────────────────────────────
# --keep-state keeps both folders. Otherwise a folder holding git-tracked files
# loses only what git ignores; dirty worktrees were refused above unless
# --force-worktrees was given.
_remove_project_dir() {
local _dir="$_GIT_ROOT/$1" _tracked
[ -d "$_dir" ] || return 0
if ! _tracked="$(git -C "$_GIT_ROOT" ls-files -- "$1" 2>/dev/null)"; then
echo "kept $_dir/: git could not list its tracked files" >&2
return 0
fi
if [ -z "$_tracked" ]; then
if [ -n "$(find "$_dir" -name .git -print -quit 2>/dev/null)" ]; then
echo "kept $_dir/: it holds its own git repository, whose commits may exist nowhere else; check it, then remove it by hand" >&2
return 0
fi
rm -rf "$_dir"
REMOVED+=("$_dir/")
return 0
fi
git -C "$_GIT_ROOT" clean -fdXq -- "$1" >/dev/null 2>&1 || true
REMOVED+=("$_dir/ (git-ignored files)")
echo "kept git-tracked files in $_dir/ (and any untracked file git does not ignore)" >&2
}
if [ -n "$_GIT_ROOT" ] && [ "$KEEP_STATE" -eq 0 ]; then
_remove_project_dir .gstack
if [ -L "$_GIT_ROOT/.gstack-worktrees" ] && [ -d "$_GIT_ROOT/.gstack-worktrees" ]; then
rm -f -- "$_GIT_ROOT/.gstack-worktrees"
REMOVED+=("$_GIT_ROOT/.gstack-worktrees (link only)")
elif [ -d "$_GIT_ROOT/.gstack-worktrees" ]; then
_WTS=()
_WT_REALS=()
while IFS= read -r -d '' _WT; do
[ -n "$_WT" ] || continue
_WTS+=("$_WT")
_WT_REALS+=("$(_real "$_WT")")
done < <(_project_worktrees)
for _WT in ${_WTS[@]+"${_WTS[@]}"}; do
rm -rf "$_WT"
done
for _WT_REAL in ${_WT_REALS[@]+"${_WT_REALS[@]}"}; do
[ -n "$_WT_REAL" ] && [ ! -e "$_WT_REAL" ] && git -C "$_GIT_ROOT" worktree remove --force "$_WT_REAL" >/dev/null 2>&1 || true
done
_remove_project_dir .gstack-worktrees
fi
fi
# ─── Remove global state ────────────────────────────────────
if [ "$KEEP_STATE" -eq 0 ] && [ -d "$STATE_DIR" ]; then
rm -rf "$STATE_DIR"
REMOVED+=("$STATE_DIR")
fi
# ─── Clean up temp files ────────────────────────────────────
for _TMP in /tmp/gstack-latest-version /tmp/gstack-sketch-*.html /tmp/gstack-sketch.png /tmp/gstack-sync-*; do
if [ -e "$_TMP" ]; then
rm -f "$_TMP"
REMOVED+=("$(basename "$_TMP")")
fi
done
# OpenCode slash commands setup wrote (#2629): only files with the managed marker.
if [ -d "$HOME/.config/opencode/commands" ]; then
for _ITEM in "$HOME/.config/opencode/commands"/gstack-*.md; do
[ -f "$_ITEM" ] || continue
if grep -qF '<!-- gstack-managed command' "$_ITEM" 2>/dev/null; then
rm -f "$_ITEM"
REMOVED+=("opencode/commands/$(basename "$_ITEM")")
fi
done
fi
# ─── Remove GitHub Copilot skills ───────────────────────────
# Same provenance gate as Cursor: ~/.copilot/skills is Copilot's personal
# skill namespace, so a real gstack* dir goes only when it carries the banner.
COPILOT_SKILLS="$HOME/.copilot/skills"
if [ -d "$COPILOT_SKILLS" ]; then
for _ITEM in "$COPILOT_SKILLS"/gstack*; do
[ -e "$_ITEM" ] || [ -L "$_ITEM" ] || continue
if _cursor_item_is_gstack_managed "$_ITEM"; then
rm -rf "$_ITEM"
REMOVED+=("copilot/$(basename "$_ITEM")")
else
_SKIPPED_DIRS+=("$_ITEM")
fi
done
fi
# ─── Remove Antigravity CLI skills ──────────────────────────
# Same provenance gate. Only gstack* entries under antigravity-cli/skills go:
# the rest of ~/.gemini belongs to Antigravity and Gemini CLI.
ANTIGRAVITY_SKILLS="$HOME/.gemini/antigravity-cli/skills"
if [ -d "$ANTIGRAVITY_SKILLS" ]; then
for _ITEM in "$ANTIGRAVITY_SKILLS"/gstack*; do
[ -e "$_ITEM" ] || [ -L "$_ITEM" ] || continue
if _cursor_item_is_gstack_managed "$_ITEM"; then
rm -rf "$_ITEM"
REMOVED+=("agy/$(basename "$_ITEM")")
else
_SKIPPED_DIRS+=("$_ITEM")
fi
done
fi
# ─── Remove Gemini CLI skills ───────────────────────────────
# Same provenance gate. ~/.gemini/skills also holds the user's own skills, so
# only gstack-managed gstack* entries go.
GEMINI_SKILLS="$HOME/.gemini/skills"
if [ -d "$GEMINI_SKILLS" ]; then
for _ITEM in "$GEMINI_SKILLS"/gstack*; do
[ -e "$_ITEM" ] || [ -L "$_ITEM" ] || continue
if _cursor_item_is_gstack_managed "$_ITEM"; then
rm -rf "$_ITEM"
REMOVED+=("gemini/$(basename "$_ITEM")")
else
_SKIPPED_DIRS+=("$_ITEM")
fi
done
fi
# ─── Reconcile the install registry ─────────────────────────
# A registry that survives (--keep-state, or a state root selected by an env
# var and left in place) must forget the installs removed above, or the next
# upgrade would refresh — resurrect — them (docs/ADDING_A_HOST.md).
if [ -f "$RESOLVED_ROOT/installs.tsv" ] && [ -f "$(dirname "$0")/gstack-install-registry.sh" ]; then
if ( GSTACK_STATE_ROOT="$RESOLVED_ROOT"; . "$(dirname "$0")/gstack-install-registry.sh" && gstack_install_registry_reconcile >/dev/null ); then
REMOVED+=("registry rows for removed installs")
else
echo "warning: could not update $RESOLVED_ROOT/installs.tsv; remove the rows for uninstalled hosts by hand (or delete the file)" >&2
fi
fi
_report_left_root
# ─── Skipped-entry report ───────────────────────────────────
# Everything any provenance gate refused to delete (Claude shapes 2/3,
# cursor real dirs) — listed once, at the end, so nothing is silent.
if [ ${#_SKIPPED_DIRS[@]} -gt 0 ]; then
echo "left in place (not provably gstack-managed — remove by hand if they are yours):" >&2
for _D in "${_SKIPPED_DIRS[@]}"; do
echo " $_D" >&2
done
fi
# ─── Summary ────────────────────────────────────────────────
if [ ${#REMOVED[@]} -gt 0 ]; then
echo "Removed: ${REMOVED[*]}"
echo "gstack uninstalled."
else
echo "Nothing to remove — gstack is not installed."
fi
exit 0