mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-10 18:50:18 +01:00
* Stop gstack-uninstall deleting unpushed or kept work under any folder it removes (GST-2303) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Keep repos behind a symlinked .gstack-worktrees, remove stable-channel installs, and read worktrees on git before 2.36 in gstack-uninstall (GST-2303) Review-Finding: p1/red-team#1 reader=red-team Review-Finding: p1/red-team#2 reader=red-team Review-Finding: p1/red-team#3 reader=red-team Review-Finding: p1/specialist-batched#1 reader=testing Review-Finding: p1/specialist-batched#2 reader=testing Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Count a FETCH_HEAD commit as pushed only when it came from one of the checkout's remotes in gstack-uninstall (GST-2303) Review-Finding: p2/specialist-batched#1 reader=security Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Read remote URLs with git remote get-url so url.insteadOf rewrites match FETCH_HEAD in gstack-uninstall (GST-2303) Review-Finding: fc/fix-check#1 reader=fix-check Review-Finding: fc/fix-check#2 reader=fix-check Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Refuse to uninstall on git older than 2.36 instead of reading worktrees without -z (GST-2303) Review-Finding: v11/outside-voice#1 reader=outside-voice Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * List the git 2.36 uninstall refusal in troubleshooting (GST-2303) 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>
924 lines
44 KiB
Bash
Executable File
924 lines
44 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
|
|
}
|
|
# 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 <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
|
|
}
|
|
# ─── 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 '<!-- AUTO-GENERATED from' "$1/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.
|
|
echo delete
|
|
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.
|
|
echo keep
|
|
fi
|
|
fi
|
|
}
|
|
|
|
# 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. OpenCode, Copilot, Antigravity and Gemini share it.
|
|
_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
|
|
}
|
|
|
|
# Every real directory the removal steps below delete recursively,
|
|
# NUL-separated. A symlink is only unlinked, so it is not listed.
|
|
_deletion_targets() {
|
|
local _item
|
|
if [ -d "$CLAUDE_SKILLS/gstack" ] || [ -L "$CLAUDE_SKILLS/gstack" ]; then
|
|
printf '%s\0' "$CLAUDE_SKILLS/gstack"
|
|
for _item in "$CLAUDE_SKILLS"/*; do
|
|
[ "$(_claude_entry_action "$_item")" = delete ] && printf '%s\0' "$_item"
|
|
done
|
|
fi
|
|
for _item in "$HOME"/.codex/skills/gstack* "$HOME"/.factory/skills/gstack* "$HOME"/.kiro/skills/gstack*; do
|
|
printf '%s\0' "$_item"
|
|
done
|
|
for _item in "$HOME"/.config/opencode/skills/gstack* "$HOME"/.cursor/skills/gstack* "$HOME"/.copilot/skills/gstack* \
|
|
"$HOME"/.gemini/antigravity-cli/skills/gstack* "$HOME"/.gemini/skills/gstack*; do
|
|
_cursor_item_is_gstack_managed "$_item" && printf '%s\0' "$_item"
|
|
done
|
|
if [ -n "$_GIT_ROOT" ]; then
|
|
printf '%s\0' "$_GIT_ROOT/.claude/skills/gstack"
|
|
for _item in "$_GIT_ROOT"/.agents/skills/gstack* "$_GIT_ROOT"/.factory/skills/gstack*; do
|
|
printf '%s\0' "$_item"
|
|
done
|
|
for _item in "$_GIT_ROOT"/.cursor/skills/gstack*; do
|
|
_cursor_item_is_gstack_managed "$_item" && printf '%s\0' "$_item"
|
|
done
|
|
fi
|
|
[ "$KEEP_STATE" -eq 1 ] || printf '%s\0' "$STATE_DIR"
|
|
}
|
|
# Prints the FETCH_HEAD commits of checkout $1 that a fetch from one of its
|
|
# remotes wrote. Remote URLs come from git remote get-url (url.insteadOf
|
|
# applied) and are normalized the way git writes FETCH_HEAD (credentials
|
|
# dropped, trailing "/" and one ".git" stripped); a fetch from "."
|
|
# or any other source counts for nothing.
|
|
_remote_fetch_heads() (
|
|
cd "$1" || exit 0
|
|
_wt_git . remote 2>/dev/null | while IFS= read -r _name; do _wt_git . remote get-url --all "$_name" 2>/dev/null; done |
|
|
sed -E 's#^([a-z+]+://)?[^@/]+@#\1#; s#/+$##; s#\.git$##' |
|
|
awk 'NR == FNR { u[$0]; next } { n = split($0, a, " of "); if (n > 1 && (a[n] in u)) print $1 }' - "$(_wt_git . rev-parse --git-path FETCH_HEAD 2>/dev/null)" 2>/dev/null
|
|
)
|
|
# Prints why deleting git checkout $1, found inside deletion target $2, would
|
|
# lose work: uncommitted changes, commits neither a remote-tracking branch nor a
|
|
# fetch from one of its remotes holds (a stable-channel upgrade fetches into
|
|
# FETCH_HEAD only), or a
|
|
# locked worktree inside $2 or registered in the repository $1's own .git holds.
|
|
_repo_risk() {
|
|
local _out _field _wt _target_real
|
|
[ "$(_real "$(_wt_git "$1" rev-parse --show-toplevel 2>/dev/null)")" = "$(_real "$1")" ] || { echo "is a git checkout git cannot read"; return 0; }
|
|
_out="$(_wt_git "$1" status --porcelain --untracked-files=all --ignore-submodules=none 2>/dev/null)" || { echo "has a status git cannot read"; return 0; }
|
|
[ -z "$_out" ] || { echo "has uncommitted changes"; return 0; }
|
|
_out="$(_wt_git "$1" rev-list -n 1 --all --not --remotes $(_remote_fetch_heads "$1") 2>/dev/null)" || { echo "has a history git cannot read"; return 0; }
|
|
[ -z "$_out" ] || { echo "has commits no remote-tracking branch contains"; return 0; }
|
|
_worktree_list _wt_git "$1" >/dev/null || { echo "has worktrees git cannot list (gstack-uninstall needs git 2.36 or later)"; return 0; }
|
|
_target_real="$(_real "$2")"
|
|
while IFS= read -r -d '' _field; do
|
|
case "$_field" in
|
|
"worktree "*) _wt="${_field#worktree }" ;;
|
|
locked|"locked "*)
|
|
if [ -d "$1/.git" ] || _is_at_or_above "$_target_real" "$(_real "$_wt")"; then
|
|
echo "registers the locked worktree $_wt"
|
|
return 0
|
|
fi
|
|
;;
|
|
esac
|
|
done < <(_worktree_list _wt_git "$1")
|
|
}
|
|
# Exits 2, before anything is removed, when a deletion target holds state this
|
|
# run keeps (--keep-state, or a state root left in place) or a git checkout
|
|
# whose work would be lost. Checkouts under this repository's .gstack-worktrees/
|
|
# are left to _check_project_keep, which honors --force-worktrees. $1, when
|
|
# set, is printed before the fix line.
|
|
_check_deletion_targets() {
|
|
local _target _treal _kept _dotgit _repo _lanes="" _risk _refusals=""
|
|
local -a _keeps=()
|
|
[ -z "$_LEAVE_ROOT" ] || _keeps+=("$_LEAVE_ROOT")
|
|
if [ "$KEEP_STATE" -eq 1 ]; then
|
|
_keeps+=("$STATE_DIR")
|
|
[ -z "$RESOLVED_ROOT" ] || _keeps+=("$RESOLVED_ROOT")
|
|
[ -z "$_GIT_ROOT" ] || _keeps+=("$_GIT_ROOT/.gstack" "$_GIT_ROOT/.gstack-worktrees")
|
|
fi
|
|
if [ -n "$_GIT_ROOT" ] && [ "$KEEP_STATE" -eq 0 ] && [ -d "$_GIT_ROOT/.gstack-worktrees" ] && [ ! -L "$_GIT_ROOT/.gstack-worktrees" ]; then
|
|
_lanes="$(_real "$_GIT_ROOT/.gstack-worktrees")"
|
|
fi
|
|
while IFS= read -r -d '' _target; do
|
|
[ -d "$_target" ] && [ ! -L "$_target" ] || continue
|
|
_treal="$(_real "$_target")"
|
|
for _kept in ${_keeps[@]+"${_keeps[@]}"}; do
|
|
[ -e "$_kept" ] && _is_at_or_above "$_treal" "$(_real "$_kept")" || continue
|
|
_refusals="$_refusals""gstack-uninstall: refusing to delete $_target: it holds $_kept, which this uninstall keeps"$'\n'
|
|
done
|
|
while IFS= read -r -d '' _dotgit; do
|
|
_repo="${_dotgit%/.git}"
|
|
[ -n "$_lanes" ] && _is_at_or_above "$_lanes" "$(_real "$_repo")" && continue
|
|
_risk="$(_repo_risk "$_repo" "$_target")"
|
|
[ -z "$_risk" ] || _refusals="$_refusals""gstack-uninstall: refusing to delete $_target: $_repo $_risk"$'\n'
|
|
done < <(find "$_target" -name .git -prune -print0 2>/dev/null)
|
|
done < <(_deletion_targets)
|
|
[ -n "$_refusals" ] || return 0
|
|
printf '%s' "$_refusals" >&2
|
|
[ -z "${1:-}" ] || echo "$1" >&2
|
|
echo "fix: commit and push that work or move it out of the folder being deleted, unlock a locked worktree with git worktree unlock <path>, or run gstack-uninstall from outside an install it deletes; then re-run gstack-uninstall" >&2
|
|
exit 2
|
|
}
|
|
_check_project_keep
|
|
_check_deletion_targets
|
|
|
|
_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"
|
|
_check_deletion_targets "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 ────────────────────────────
|
|
_SKIPPED_DIRS=()
|
|
if [ -d "$CLAUDE_SKILLS/gstack" ] || [ -L "$CLAUDE_SKILLS/gstack" ]; then
|
|
for _ENTRY in "$CLAUDE_SKILLS"/*; do
|
|
_NAME="$(basename "$_ENTRY")"
|
|
case "$(_claude_entry_action "$_ENTRY")" in
|
|
unlink) rm -f "$_ENTRY"; REMOVED+=("claude/$_NAME") ;;
|
|
delete) rm -rf "$_ENTRY"; REMOVED+=("claude/$_NAME") ;;
|
|
keep) _SKIPPED_DIRS+=("$_ENTRY") ;;
|
|
esac
|
|
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 ───────────────────────────────────
|
|
# Provenance gate: _cursor_item_is_gstack_managed, with the deletion inventory.
|
|
|
|
# ─── 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
|