mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-11 03:00:26 +01:00
* 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>
789 lines
37 KiB
Bash
Executable File
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
|