Stop gstack-uninstall deleting unpushed or kept work under any folder it removes (GST-2303) (#4282)

* 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>
This commit is contained in:
Garry Tanandcapy-ai[bot] authored and GitHub committed 2026-11-10 09:34:35 +00:00
1 parent ee3bae4028
commit 6b327148e3
5 files changed
+651 -119

No files matched your search

+239 -104
View File
@@ -156,17 +156,39 @@ _project_worktrees() {
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
# 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")
}
# git inside a registered checkout, with no config able to run a command.
# "$@" 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
@@ -183,7 +205,7 @@ _worktree_risk() {
""|*"
"*) echo "it is not a registered worktree of this repository"; return 0 ;;
esac
if ! printf '%s\n' "$_REGISTERED_WORKTREES" | grep -Fxq -- "$_dir"; then
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"
@@ -195,20 +217,18 @@ _worktree_risk() {
_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.
# 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="" _base _rel _entry
_base="$(_real "$_GIT_ROOT/.gstack-worktrees")"
while IFS= read -r _wt; do
case "$_wt" in "$_base"/*) ;; *) continue ;; esac
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 <<EOF
$_REGISTERED_WORKTREES
EOF
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
@@ -219,15 +239,18 @@ EOF
# is kept even with --force-worktrees.
_worktree_refusals() {
local _wt _risk
if ! git -C "$_GIT_ROOT" worktree list --porcelain >/dev/null 2>&1; then
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
_REGISTERED_WORKTREES="$(_registered_worktrees)"
_LOCKED_WORKTREES="$(_registered_worktrees locked)"
_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 [ -n "$_LOCKED_WORKTREES" ] && printf '%s\n' "$_LOCKED_WORKTREES" | grep -Fxq -- "$(_real "$_wt")"; then
if _in_list "$(_real "$_wt")" ${_LOCKED_WORKTREES[@]+"${_LOCKED_WORKTREES[@]}"}; then
_risk="it is locked (git worktree lock)"
elif [ "$FORCE_WORKTREES" -eq 1 ]; then
continue
@@ -272,7 +295,190 @@ _check_project_keep() {
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
@@ -320,6 +526,7 @@ if [ "$FORCE" -eq 0 ]; then
*) 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=()
@@ -430,77 +637,15 @@ 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
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"
@@ -553,17 +698,7 @@ if [ -d "$KIRO_SKILLS" ]; then
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
}
# 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"
@@ -0,0 +1,6 @@
---
important: data-loss
---
- **`gstack-uninstall` now checks every folder it deletes for unsaved git work, not only the repo you run it from.** Each host's install (global and project-local), the gstack-made Claude per-skill folders and `~/.gstack` are searched for git checkouts before anything is removed. One with uncommitted changes, commits no remote-tracking branch holds, or a locked worktree stops the uninstall with exit 2, naming the checkout. Before, an unpushed `.gstack` repo inside `~/.claude/skills/gstack`, an edited project-local install or a notes repo under `.agents/skills/gstack-*` was deleted without a check (GST-2303).
- **`--keep-state` no longer deletes the state it keeps.** Run from inside the gstack checkout, `--keep-state` skipped the worktree check and then deleted the whole install, taking the kept `.gstack-worktrees` lanes and tracked `.gstack` files with it. An install that holds kept state is now refused instead (GST-2303).
- **A locked worktree whose path contains a newline is kept under `--force --force-worktrees`.** The worktree list is now read NUL-separated, so the lock check sees the real path. A clean submodule nested inside another submodule of a clean lane no longer stops the uninstall as unregistered (GST-2303).
+14
View File
@@ -987,6 +987,20 @@ pick a new `--output`), then copy the recovery file to where you want it.
---
## Uninstall
### `gstack-uninstall: refusing to delete <folder>: <checkout> has uncommitted changes` (or `has commits no remote-tracking branch contains`, `registers the locked worktree <path>`)
`gstack-uninstall` checks every folder it is about to delete (each host's gstack install, global and project-local, the gstack-made Claude per-skill folders, and `~/.gstack`) before it removes anything. A git checkout inside one of them holds work that exists nowhere else, so the uninstall stopped with exit 2 and removed nothing. The variants `is a git checkout git cannot read`, `has a status git cannot read`, `has a history git cannot read` and `has worktrees git cannot list (gstack-uninstall needs git 2.36 or later)` mean git could not prove the checkout is safe to delete; for the last one, upgrade git to 2.36 or later.
Fix: commit and push that checkout's work, or move the checkout out of `<folder>`. Unlock a locked worktree with `git worktree unlock <path>` if you want it gone. Then re-run `gstack-uninstall`. Checkouts under the current repo's `.gstack-worktrees/` have their own check, which `--force-worktrees` relaxes; this one has no override. [docs/uninstall.md](uninstall.md#option-1-run-the-uninstall-script)
### `gstack-uninstall: refusing to delete <folder>: it holds <path>, which this uninstall keeps`
`<folder>` is a gstack install the uninstall would delete, and `<path>` is state this run promised to keep: with `--keep-state`, `~/.gstack`, your state root, or the current repo's `.gstack/` or `.gstack-worktrees/`; without it, a state root selected by `GSTACK_HOME` or a similar variable. The usual cause is running `gstack-uninstall --keep-state` from inside the gstack checkout itself.
Fix: run `gstack-uninstall` from outside `<folder>` (for example `cd ~ && ~/.claude/skills/gstack/bin/gstack-uninstall --keep-state`), or move `<path>` out of `<folder>` first. [docs/uninstall.md](uninstall.md#option-1-run-the-uninstall-script)
## Codex and other env-var hosts
<a id="gstack-no-install-found"></a>
+48 -15
View File
@@ -10,13 +10,45 @@ If gstack is installed on your machine:
This handles skills, symlinks, harness registrations (the files `gstack-harness` wrote into OpenClaw, Hermes, a Dot or another harness, from their ledgers, plus the opt-in [start hook](hosts.md#start-hook), which it turns off through the harness's own CLI), global state (`~/.gstack/`), project-local state, browse daemons, and temp files. Use `--keep-state` to preserve config and analytics; it also keeps the repo's `.gstack/` and `.gstack-worktrees/`. Use `--force` to skip confirmation.
A plain uninstall keeps the git-tracked files under `.gstack/` (such as ship policy) and removes only the ones git ignores. It stops with exit 2, before removing anything, when a checkout under `.gstack-worktrees/` has uncommitted changes, has commits no branch or tag holds, or is not a worktree this repo registered. Pass `--force-worktrees` to delete those checkouts anyway. Every checkout is checked, including a worktree created inside another checkout under `.gstack-worktrees/`. A git submodule inside a clean checkout is deleted with it only when every commit it holds is on a remote-tracking branch; otherwise it stops the uninstall. A locked worktree (`git worktree lock`) always stops the uninstall, even with `--force-worktrees`; unlock it with `git worktree unlock <path>` if you want it gone. If `git worktree list` fails, the uninstall stops too, even with `--force-worktrees`. The check runs again after you confirm, before anything is removed, so a checkout you edit while the confirmation prompt waits is kept: uninstall exits 2 and removes nothing. Run from inside the gstack install itself, a `.gstack/` that holds its own git repository also stops the uninstall, because deleting the install would take it along. A `.gstack/` that is its own git repository is kept, with a message saying so, because its commits may exist nowhere else. When `.gstack-worktrees` is a symlink, uninstall removes only the link and never follows it, so the folder it points at and the worktrees there are kept.
A plain uninstall keeps the git-tracked files under `.gstack/` (such as ship policy) and removes only the ones git ignores. It stops with exit 2, before removing anything, when a checkout under `.gstack-worktrees/` has uncommitted changes, has commits no branch or tag holds, or is not a worktree this repo registered. Pass `--force-worktrees` to delete those checkouts anyway. Every checkout is checked, including a worktree created inside another checkout under `.gstack-worktrees/`. A git submodule inside a clean checkout is deleted with it only when every commit it holds is on a remote-tracking branch; otherwise it stops the uninstall. A locked worktree (`git worktree lock`) always stops the uninstall, even with `--force-worktrees`; unlock it with `git worktree unlock <path>` if you want it gone. If `git worktree list` fails, the uninstall stops too, even with `--force-worktrees`, and so it does on git older than 2.36, which cannot list worktrees with `-z`. The check runs again after you confirm, before anything is removed, so a checkout you edit while the confirmation prompt waits is kept: uninstall exits 2 and removes nothing. Run from inside the gstack install itself, a `.gstack/` that holds its own git repository also stops the uninstall, because deleting the install would take it along. A `.gstack/` that is its own git repository is kept, with a message saying so, because its commits may exist nowhere else. When `.gstack-worktrees` is a symlink, uninstall removes only the link and never follows it, so the folder it points at and the worktrees there are kept.
Before it deletes anything, uninstall also checks every folder it is about to delete: each host's gstack install (global and project-local), the gstack-made Claude per-skill folders, and `~/.gstack`. If any git checkout inside one of them has uncommitted changes, has commits that neither a remote-tracking branch nor a fetch from one of its remotes (`FETCH_HEAD`, where a stable-channel upgrade lands) holds, or has a locked worktree, the uninstall stops with exit 2 and names the checkout. So does a folder that holds something this run keeps: with `--keep-state`, an install that contains `~/.gstack`, your state root, or the current repo's `.gstack/` or `.gstack-worktrees/` (for example, when you run it from inside the gstack checkout) is refused rather than deleted with them. Push or move the work, or run uninstall from outside the install, then re-run it ([troubleshooting](troubleshooting.md#uninstall)).
## Option 2: Manual removal (no local repo)
If you don't have the repo cloned (e.g. you installed via a Claude Code paste and later deleted the clone):
```bash
# 0. A delete that stops for unsaved git work: each folder goes only when no git
# checkout inside it has uncommitted changes, commits that neither a
# remote-tracking branch nor a fetch from one of its remotes holds, or a
# locked worktree. A symlink is only unlinked.
# The FETCH_HEAD commits a fetch from one of checkout $1's remotes wrote.
gstack_remote_fetch_heads() (
cd "$1" || exit 0
git remote | while IFS= read -r n; do git remote get-url --all "$n"; 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 }' - "$(git rev-parse --git-path FETCH_HEAD)" 2>/dev/null
)
gstack_rm_checked() {
local d g r s ok
for d in "$@"; do
if [ -L "$d" ]; then rm -f -- "$d"; continue; fi
[ -d "$d" ] || continue
ok=1
while IFS= read -r -d '' g; do
r=${g%/.git}
s=$(git -C "$r" status --porcelain --untracked-files=all) && [ -z "$s" ] &&
s=$(git -C "$r" rev-list -n 1 --all --not --remotes $(gstack_remote_fetch_heads "$r")) && [ -z "$s" ] &&
git -C "$r" worktree list --porcelain -z >/dev/null 2>&1 &&
! git -C "$r" worktree list --porcelain -z | tr '\0' '\n' | grep -q '^locked' ||
{ echo "kept $d: $r has uncommitted changes, unpushed commits or a locked worktree" >&2; ok=0; break; }
done < <(find "$d" -name .git -prune -print0 2>/dev/null)
[ "$ok" = 1 ] && rm -rf -- "$d"
done
return 0
}
# 1. Stop browse daemons
pkill -f "gstack.*browse" 2>/dev/null || true
@@ -42,27 +74,27 @@ done
rm -rf ~/.claude/skills/_gstack-command ~/.claude/skills/connect-chrome 2>/dev/null
# 3. Remove gstack
rm -rf ~/.claude/skills/gstack
gstack_rm_checked ~/.claude/skills/gstack
# 4. Remove global state
rm -rf ~/.gstack
gstack_rm_checked ~/.gstack
# 5. Remove integrations (skip any you never installed)
rm -rf "${CODEX_HOME:-$HOME/.codex}/skills/gstack"* 2>/dev/null
rm -rf ~/.factory/skills/gstack* 2>/dev/null
rm -rf ~/.kiro/skills/gstack* 2>/dev/null
rm -rf "${OPENCLAW_STATE_DIR:-$HOME/.openclaw}/skills/gstack"* "${OPENCLAW_STATE_DIR:-$HOME/.openclaw}/skills/.gstack-skills" 2>/dev/null
rm -rf "${HERMES_HOME:-$HOME/.hermes}/skills/gstack" 2>/dev/null
rm -rf ~/.cursor/skills/gstack* 2>/dev/null
rm -rf ~/.config/opencode/skills/gstack* 2>/dev/null
rm -rf ~/.gemini/antigravity-cli/skills/gstack* 2>/dev/null
rm -rf ~/.gemini/skills/gstack* 2>/dev/null
gstack_rm_checked "${CODEX_HOME:-$HOME/.codex}/skills/gstack"*
gstack_rm_checked ~/.factory/skills/gstack*
gstack_rm_checked ~/.kiro/skills/gstack*
gstack_rm_checked "${OPENCLAW_STATE_DIR:-$HOME/.openclaw}/skills/gstack"* "${OPENCLAW_STATE_DIR:-$HOME/.openclaw}/skills/.gstack-skills"
gstack_rm_checked "${HERMES_HOME:-$HOME/.hermes}/skills/gstack"
gstack_rm_checked ~/.cursor/skills/gstack*
gstack_rm_checked ~/.config/opencode/skills/gstack*
gstack_rm_checked ~/.gemini/antigravity-cli/skills/gstack*
gstack_rm_checked ~/.gemini/skills/gstack*
# 6. Remove temp files
rm -f /tmp/gstack-* 2>/dev/null
# 7. Per-project cleanup (run from each project root)
rm -rf .claude/skills/gstack .agents/skills/gstack* .factory/skills/gstack* 2>/dev/null
# 7. Per-project cleanup (run from each project root; uses gstack_rm_checked from step 0)
gstack_rm_checked .claude/skills/gstack .agents/skills/gstack* .factory/skills/gstack*
# Browse state: removes only the files git ignores, so tracked files such as
# ship policy, and a .gstack/ that is its own git repo, stay.
git clean -fdXq -- .gstack 2>/dev/null
@@ -84,7 +116,8 @@ done
find .gstack-worktrees -depth -type d -empty -delete 2>/dev/null || true
```
Never `rm -rf .gstack-worktrees` by hand: a checkout there can hold uncommitted
Anything `gstack_rm_checked` prints as kept still holds work: push or move it,
then run that step again. Never `rm -rf .gstack-worktrees` by hand: a checkout there can hold uncommitted
work, commits no branch has, or another worktree nested inside it, and `rm -rf`
deletes all of it. Anything the loop above keeps, check and remove yourself.
+344
View File
@@ -978,6 +978,10 @@ describe('per-project state: tracked files and dirty worktrees survive (GSTA-461
fs.writeFileSync(path.join(install, '.git', 'info', 'exclude'), '.gstack-worktrees/\n.gstack/\n');
git(['add', 'SKILL.md'], install);
git(['commit', '-q', '-m', 'install'], install);
// Like a real git-clone install, every commit is on its origin (GST-2303 refuses unpushed ones).
git(['init', '-q', '--bare', path.join(tmp, 'install-origin.git')], tmp);
git(['remote', 'add', 'origin', path.join(tmp, 'install-origin.git')], install);
git(['push', '-q', 'origin', 'main'], install);
return install;
};
@@ -1106,3 +1110,343 @@ describe('per-project state: tracked files and dirty worktrees survive (GSTA-461
expect(fs.existsSync(path.join(home, '.gstack'))).toBe(false);
});
});
// Value: protects=unpushed commits, uncommitted edits, locked worktrees and --keep-state state that sit inside a gstack
// install root, a project-local install, a Claude per-skill folder or ~/.gstack, whichever repository uninstall runs from;
// fails_when=uninstall deletes a target without checking every git repository beneath it (GST-2303).
describe('every deletion target is checked for unsaved git work before anything is removed (GST-2303)', () => {
let tmp: string, home: string, proj: string, install: string, gitEnv: Record<string, string>;
const git = (args: string[], cwd = proj) => {
const r = spawnSync('git', ['-c', 'user.name=fixture', '-c', 'user.email=fixture@example.com', '-c', 'commit.gpgsign=false', '-c', 'protocol.file.allow=always', ...args], { cwd, encoding: 'utf-8', timeout: 30_000, env: gitEnv });
expect(r.status).toBe(0);
return r.stdout;
};
const uninstall = (args: string[], cwd = proj) => spawnSync('bash', [UNINSTALL, ...args], {
cwd, encoding: 'utf-8', timeout: 30_000,
env: { ...gitEnv, HOME: home, GSTACK_DIR: install, GSTACK_SETTINGS_FILE: path.join(home, 'settings.json'),
GSTACK_HOME: '', GSTACK_STATE_ROOT: path.join(home, '.gstack'), GSTACK_STATE_DIR: '', CLAUDE_PLUGIN_DATA: '' },
});
// A repository with an origin it has pushed everything to, like a real git-clone install.
const pushedRepo = (dir: string, file = 'SKILL.md', body = 'test\n') => {
const origin = path.join(tmp, 'origins', path.relative(tmp, dir).replace(/[\\/]/g, '_') + '.git');
fs.mkdirSync(dir, { recursive: true });
git(['init', '-q', '--bare', origin], tmp);
git(['init', '-q', '-b', 'main'], dir);
fs.writeFileSync(path.join(dir, file), body);
git(['add', file], dir);
git(['commit', '-q', '-m', 'seed'], dir);
git(['remote', 'add', 'origin', origin], dir);
git(['push', '-q', 'origin', 'main'], dir);
return dir;
};
const unpushedCommit = (dir: string) => {
fs.writeFileSync(path.join(dir, 'notes.md'), 'mine\n');
git(['add', 'notes.md'], dir);
git(['commit', '-q', '-m', 'unpushed notes'], dir);
};
beforeEach(() => {
tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-uninstall-targets-')));
home = path.join(tmp, 'home');
proj = path.join(tmp, 'proj');
fs.writeFileSync(path.join(tmp, 'gitconfig'), '');
gitEnv = { ...process.env, GIT_CONFIG_GLOBAL: path.join(tmp, 'gitconfig'), GIT_CONFIG_NOSYSTEM: '1' } as Record<string, string>;
install = pushedRepo(path.join(home, '.claude', 'skills', 'gstack'));
fs.writeFileSync(path.join(install, '.git', 'info', 'exclude'), '.gstack-worktrees/\n.gstack/\n');
fs.mkdirSync(path.join(home, '.gstack'), { recursive: true });
pushedRepo(proj, 'README.md', 'x\n');
});
afterEach(() => fs.rmSync(tmp, { recursive: true, force: true }));
test('hold: a clean, pushed git-clone install is removed', () => {
const r = uninstall(['--force']);
expect(r.stderr).toBe('');
expect(r.status).toBe(0);
expect(fs.existsSync(install)).toBe(false);
});
test('--keep-state run from inside the install refuses instead of deleting its kept .gstack-worktrees lane', () => {
const lane = path.join(install, '.gstack-worktrees', 'run', 'lane');
git(['worktree', 'add', '-q', '--detach', lane, 'HEAD'], install);
const r = uninstall(['--force', '--keep-state'], install);
expect(r.status).toBe(2);
expect(r.stderr).toContain(`refusing to delete ${install}: it holds ${install}/.gstack-worktrees, which this uninstall keeps`);
expect(fs.existsSync(path.join(lane, 'SKILL.md'))).toBe(true);
});
test('--keep-state run from inside the install refuses instead of deleting its tracked .gstack policy', () => {
fs.writeFileSync(path.join(install, '.git', 'info', 'exclude'), '');
fs.mkdirSync(path.join(install, '.gstack'));
fs.writeFileSync(path.join(install, '.gstack', 'ship-policy.json'), '{}\n');
git(['add', '.gstack/ship-policy.json'], install);
git(['commit', '-q', '-m', 'policy'], install);
git(['push', '-q', 'origin', 'main'], install);
const r = uninstall(['--force', '--keep-state'], install);
expect(r.status).toBe(2);
expect(r.stderr).toContain(`it holds ${install}/.gstack, which this uninstall keeps`);
expect(fs.existsSync(path.join(install, '.gstack', 'ship-policy.json'))).toBe(true);
});
test('run from an unrelated project, a .gstack repo with unpushed commits inside the global install stops the uninstall', () => {
const own = path.join(install, '.gstack');
pushedRepo(own, 'policy.md');
unpushedCommit(own);
const r = uninstall(['--force']);
expect(r.status).toBe(2);
expect(r.stderr).toContain(`refusing to delete ${install}: ${own} has commits no remote-tracking branch contains`);
expect(git(['log', '-1', '--format=%s'], own)).toBe('unpushed notes\n');
expect(fs.existsSync(path.join(home, '.gstack'))).toBe(true);
});
test('the install clone\'s own unpushed commit stops the uninstall', () => {
unpushedCommit(install);
const r = uninstall(['--force']);
expect(r.status).toBe(2);
expect(r.stderr).toContain(`refusing to delete ${install}: ${install} has commits no remote-tracking branch contains`);
expect(fs.existsSync(path.join(install, 'notes.md'))).toBe(true);
});
test('a project-local install clone with an uncommitted edit stops the uninstall', () => {
const local = pushedRepo(path.join(proj, '.claude', 'skills', 'gstack'));
fs.writeFileSync(path.join(local, 'SKILL.md'), 'my edit\n');
const r = uninstall(['--force']);
expect(r.status).toBe(2);
expect(r.stderr).toContain(`refusing to delete ${local}: ${local} has uncommitted changes`);
expect(fs.readFileSync(path.join(local, 'SKILL.md'), 'utf-8')).toBe('my edit\n');
expect(fs.existsSync(install)).toBe(true);
});
test('a repository with unpushed commits at .agents/skills/gstack-notes stops the uninstall', () => {
const notes = pushedRepo(path.join(proj, '.agents', 'skills', 'gstack-notes'), 'README.md');
unpushedCommit(notes);
const r = uninstall(['--force']);
expect(r.status).toBe(2);
expect(r.stderr).toContain(`refusing to delete ${notes}: ${notes} has commits no remote-tracking branch contains`);
expect(fs.existsSync(path.join(notes, 'notes.md'))).toBe(true);
});
test('a gstack-generated Claude per-skill folder holding an uncommitted edit stops the uninstall', () => {
fs.mkdirSync(path.join(install, 'review'));
fs.writeFileSync(path.join(install, 'review', 'SKILL.md'), 'name: review\n');
const skill = pushedRepo(path.join(home, '.claude', 'skills', 'review'), 'SKILL.md', '<!-- AUTO-GENERATED from SKILL.md.tmpl -->\n');
fs.writeFileSync(path.join(skill, 'SKILL.md'), '<!-- AUTO-GENERATED from SKILL.md.tmpl -->\nmy edit\n');
const r = uninstall(['--force']);
expect(r.status).toBe(2);
expect(r.stderr).toContain(`refusing to delete ${skill}: ${skill} has uncommitted changes`);
expect(fs.readFileSync(path.join(skill, 'SKILL.md'), 'utf-8')).toContain('my edit');
});
test('a repository with unpushed commits under ~/.gstack stops the uninstall', () => {
const notes = pushedRepo(path.join(home, '.gstack', 'projects', 'notes'), 'README.md');
unpushedCommit(notes);
const r = uninstall(['--force']);
expect(r.status).toBe(2);
expect(r.stderr).toContain(`refusing to delete ${path.join(home, '.gstack')}: ${notes} has commits no remote-tracking branch contains`);
expect(fs.existsSync(path.join(notes, 'notes.md'))).toBe(true);
expect(fs.existsSync(install)).toBe(true);
});
test('a locked worktree of the install clone stops the uninstall, even outside the install', () => {
const lane = path.join(tmp, 'lane');
git(['worktree', 'add', '-q', '--detach', lane, 'HEAD'], install);
git(['worktree', 'lock', lane], install);
const r = uninstall(['--force']);
expect(r.status).toBe(2);
expect(r.stderr).toContain(`refusing to delete ${install}: ${install} registers the locked worktree ${lane}`);
expect(fs.existsSync(path.join(install, '.git'))).toBe(true);
});
test('a dirty repo behind a .gstack-worktrees symlink into ~/.gstack stops the uninstall', () => {
const lane = pushedRepo(path.join(home, '.gstack', 'lanes', 'lane1'));
fs.writeFileSync(path.join(lane, 'SKILL.md'), 'my edit\n');
fs.symlinkSync(path.join(home, '.gstack', 'lanes'), path.join(proj, '.gstack-worktrees'));
const r = uninstall(['--force']);
expect(r.status).toBe(2);
expect(r.stderr).toContain(`refusing to delete ${path.join(home, '.gstack')}: ${lane} has uncommitted changes`);
expect(fs.readFileSync(path.join(lane, 'SKILL.md'), 'utf-8')).toBe('my edit\n');
});
test('a shallow stable-channel install advanced by git fetch origin stable is removed', () => {
fs.rmSync(install, { recursive: true, force: true });
const origin = path.join(tmp, 'channel-origin.git');
const seed = pushedRepo(path.join(tmp, 'channel-seed'));
git(['remote', 'set-url', 'origin', origin], seed);
git(['init', '-q', '--bare', '-b', 'main', origin], tmp);
git(['push', '-q', 'origin', 'main'], seed);
git(['clone', '-q', '--single-branch', '--depth', '1', `file://${origin}`, install], tmp);
fs.writeFileSync(path.join(seed, 'SKILL.md'), 'release\n');
git(['commit', '-q', '-am', 'release'], seed);
git(['push', '-q', 'origin', 'main', 'main:stable'], seed);
git(['fetch', '-q', 'origin', 'stable'], install);
git(['merge', '-q', '--ff-only', 'FETCH_HEAD'], install);
const r = uninstall(['--force']);
expect(r.stderr).toBe('');
expect(r.status).toBe(0);
expect(fs.existsSync(install)).toBe(false);
});
test('a stable-channel install whose remote URL goes through url.insteadOf is removed', () => {
fs.rmSync(install, { recursive: true, force: true });
const origin = path.join(tmp, 'channel-origin.git');
const seed = pushedRepo(path.join(tmp, 'channel-seed'));
git(['remote', 'set-url', 'origin', origin], seed);
git(['init', '-q', '--bare', '-b', 'main', origin], tmp);
git(['push', '-q', 'origin', 'main'], seed);
fs.writeFileSync(path.join(tmp, 'gitconfig'), `[url "file://${tmp}/"]\n\tinsteadOf = alias:\n`);
git(['clone', '-q', '--single-branch', '--depth', '1', 'alias:channel-origin.git', install], tmp);
fs.writeFileSync(path.join(seed, 'SKILL.md'), 'release\n');
git(['commit', '-q', '-am', 'release'], seed);
git(['push', '-q', 'origin', 'main', 'main:stable'], seed);
git(['fetch', '-q', 'origin', 'stable'], install);
git(['merge', '-q', '--ff-only', 'FETCH_HEAD'], install);
const r = uninstall(['--force']);
expect(r.stderr).toBe('');
expect(r.status).toBe(0);
expect(fs.existsSync(install)).toBe(false);
});
test('a local-only commit that git fetch . HEAD wrote into FETCH_HEAD still stops the uninstall', () => {
unpushedCommit(install);
git(['fetch', '-q', '.', 'HEAD'], install);
const r = uninstall(['--force']);
expect(r.status).toBe(2);
expect(r.stderr).toContain(`refusing to delete ${install}: ${install} has commits no remote-tracking branch contains`);
expect(fs.existsSync(path.join(install, 'notes.md'))).toBe(true);
});
test('on a git older than 2.36 (no worktree list -z) the uninstall refuses and keeps the install', () => {
const lane = path.join(tmp, 'lane');
git(['worktree', 'add', '-q', '--detach', lane, 'HEAD'], install);
git(['worktree', 'lock', lane], install);
const shim = path.join(tmp, 'oldgit');
fs.mkdirSync(shim);
fs.writeFileSync(path.join(shim, 'git'), `#!/bin/bash\ncase " $* " in *" worktree list "*" -z "*) echo "error: unknown switch z" >&2; exit 129 ;; esac\nexec ${JSON.stringify(Bun.which('git'))} "$@"\n`, { mode: 0o755 });
const r = spawnSync('bash', [UNINSTALL, '--force'], {
cwd: proj, encoding: 'utf-8', timeout: 30_000,
env: { ...gitEnv, PATH: `${shim}:${gitEnv.PATH}`, HOME: home, GSTACK_DIR: install, GSTACK_SETTINGS_FILE: path.join(home, 'settings.json'),
GSTACK_HOME: '', GSTACK_STATE_ROOT: path.join(home, '.gstack'), GSTACK_STATE_DIR: '', CLAUDE_PLUGIN_DATA: '' },
});
expect(r.status).toBe(2);
expect(r.stderr).toContain(`refusing to delete ${install}: ${install} has worktrees git cannot list (gstack-uninstall needs git 2.36 or later)`);
expect(fs.existsSync(path.join(install, '.git'))).toBe(true);
}, 30_000);
});
describe('locked worktrees are found by NUL-separated paths (GST-2303)', () => {
let tmp: string, home: string, repo: string, gitEnv: Record<string, string>;
const git = (args: string[], cwd = repo) => {
const r = spawnSync('git', ['-c', 'user.name=fixture', '-c', 'user.email=fixture@example.com', '-c', 'commit.gpgsign=false', '-c', 'protocol.file.allow=always', ...args], { cwd, encoding: 'utf-8', timeout: 30_000, env: gitEnv });
expect(r.status).toBe(0);
return r.stdout;
};
const uninstall = (args: string[]) => spawnSync('bash', [UNINSTALL, ...args], {
cwd: repo, encoding: 'utf-8', timeout: 30_000,
env: { ...gitEnv, HOME: home, GSTACK_DIR: path.join(home, '.claude', 'skills', 'gstack'),
GSTACK_SETTINGS_FILE: path.join(home, 'settings.json'),
GSTACK_HOME: '', GSTACK_STATE_ROOT: path.join(home, '.gstack'), GSTACK_STATE_DIR: '', CLAUDE_PLUGIN_DATA: '' },
});
beforeEach(() => {
tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-uninstall-lock-')));
home = path.join(tmp, 'home');
repo = path.join(tmp, 'proj');
fs.writeFileSync(path.join(tmp, 'gitconfig'), '');
gitEnv = { ...process.env, GIT_CONFIG_GLOBAL: path.join(tmp, 'gitconfig'), GIT_CONFIG_NOSYSTEM: '1' } as Record<string, string>;
fs.mkdirSync(path.join(home, '.claude', 'skills', 'gstack'), { recursive: true });
fs.writeFileSync(path.join(home, '.claude', 'skills', 'gstack', 'SKILL.md'), 'test');
fs.mkdirSync(repo, { recursive: true });
git(['init', '-q', '-b', 'main']);
fs.writeFileSync(path.join(repo, '.git', 'info', 'exclude'), '.gstack-worktrees/\n');
fs.writeFileSync(path.join(repo, 'README.md'), 'x\n');
git(['add', 'README.md']);
git(['commit', '-q', '-m', 'seed']);
});
afterEach(() => fs.rmSync(tmp, { recursive: true, force: true }));
test('a locked worktree whose path holds a newline survives --force --force-worktrees', () => {
const lane = path.join(repo, '.gstack-worktrees', 'run', 'lane\nx');
git(['worktree', 'add', '-q', '--detach', lane, 'HEAD']);
git(['worktree', 'lock', lane]);
const r = uninstall(['--force', '--force-worktrees']);
expect(r.status).toBe(2);
expect(r.stderr).toContain('it is locked (git worktree lock)');
expect(fs.existsSync(path.join(lane, 'README.md'))).toBe(true);
});
test('a clean submodule nested inside a clean submodule of a clean lane does not stop the uninstall', () => {
const inner = path.join(tmp, 'inner');
fs.mkdirSync(inner);
git(['init', '-q', '-b', 'main'], inner);
fs.writeFileSync(path.join(inner, 'a.txt'), 'a\n');
git(['add', 'a.txt'], inner);
git(['commit', '-q', '-m', 'inner'], inner);
const lib = path.join(tmp, 'lib');
fs.mkdirSync(lib);
git(['init', '-q', '-b', 'main'], lib);
git(['submodule', 'add', '-q', inner, 'deps/inner'], lib);
git(['commit', '-q', '-m', 'lib'], lib);
git(['submodule', 'add', '-q', lib, 'vendor/lib']);
git(['commit', '-q', '-m', 'submodule']);
const lane = path.join(repo, '.gstack-worktrees', 'run', 'lane');
git(['worktree', 'add', '-q', '--detach', lane, 'HEAD']);
git(['submodule', 'update', '-q', '--init', '--recursive'], lane);
expect(fs.existsSync(path.join(lane, 'vendor', 'lib', 'deps', 'inner', 'a.txt'))).toBe(true);
const r = uninstall(['--force']);
expect(r.stderr).toBe('');
expect(r.status).toBe(0);
expect(fs.existsSync(lane)).toBe(false);
});
});
describe('the manual removal in docs/uninstall.md checks each folder before deleting it (GST-2303)', () => {
let tmp: string, gitEnv: Record<string, string>;
const git = (args: string[], cwd: string) => {
const r = spawnSync('git', ['-c', 'user.name=fixture', '-c', 'user.email=fixture@example.com', '-c', 'commit.gpgsign=false', ...args], { cwd, encoding: 'utf-8', timeout: 30_000, env: gitEnv });
expect(r.status).toBe(0);
};
const checkedDelete = (dirs: string[]) => {
const doc = fs.readFileSync(path.join(ROOT, 'docs', 'uninstall.md'), 'utf-8');
const start = doc.indexOf('# 0. A delete that stops for unsaved git work');
expect(start).toBeGreaterThan(-1);
const helper = doc.slice(start, doc.indexOf('# 1. Stop browse daemons', start));
return spawnSync('bash', ['-c', `${helper}\ngstack_rm_checked "$@"`, 'doc', ...dirs], { cwd: tmp, encoding: 'utf-8', timeout: 30_000, env: gitEnv });
};
beforeEach(() => {
tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-uninstall-doc-')));
fs.writeFileSync(path.join(tmp, 'gitconfig'), '');
gitEnv = { ...process.env, GIT_CONFIG_GLOBAL: path.join(tmp, 'gitconfig'), GIT_CONFIG_NOSYSTEM: '1' } as Record<string, string>;
});
afterEach(() => fs.rmSync(tmp, { recursive: true, force: true }));
test('a folder holding a repo with an unpushed commit is kept, and a plain folder goes', () => {
const install = path.join(tmp, 'install');
const notes = path.join(install, '.gstack');
fs.mkdirSync(notes, { recursive: true });
fs.writeFileSync(path.join(install, 'SKILL.md'), 'test\n');
git(['init', '-q', '-b', 'main'], notes);
fs.writeFileSync(path.join(notes, 'notes.md'), 'mine\n');
git(['add', 'notes.md'], notes);
git(['commit', '-q', '-m', 'unpushed notes'], notes);
const plain = path.join(tmp, 'plain');
fs.mkdirSync(plain);
fs.writeFileSync(path.join(plain, 'SKILL.md'), 'test\n');
const r = checkedDelete([install, plain]);
expect(r.status).toBe(0);
expect(r.stderr).toContain(`kept ${install}: ${notes} has uncommitted changes, unpushed commits or a locked worktree`);
expect(fs.existsSync(path.join(notes, 'notes.md'))).toBe(true);
expect(fs.existsSync(plain)).toBe(false);
});
test('a local-only commit that git fetch . HEAD wrote into FETCH_HEAD keeps its folder', () => {
const repo = path.join(tmp, 'repo');
fs.mkdirSync(repo);
git(['init', '-q', '-b', 'main'], repo);
fs.writeFileSync(path.join(repo, 'notes.md'), 'mine\n');
git(['add', 'notes.md'], repo);
git(['commit', '-q', '-m', 'local notes'], repo);
git(['fetch', '-q', '.', 'HEAD'], repo);
const r = checkedDelete([repo]);
expect(r.stderr).toContain(`kept ${repo}: ${repo} has uncommitted changes, unpushed commits or a locked worktree`);
expect(fs.existsSync(path.join(repo, 'notes.md'))).toBe(true);
});
});