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) (#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:
1 parent
ee3bae4028
commit
6b327148e3
5 files changed
+651
-119
No files matched your search
+239
-104
@@ -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).
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user