From 6c23638d6dfd78d01191c37249de1d4785cdc9ed Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Mon, 9 Nov 2026 23:15:05 -0800 Subject: [PATCH] Stop gstack-uninstall deleting nested worktrees, locked worktrees and a .gstack repo (GST-2268) (#4267) * Stop gstack-uninstall deleting nested worktrees, locked worktrees and a .gstack repo (GST-2268) The worktree check now walks every checkout under .gstack-worktrees/, nested ones included, and keeps a submodule of a clean lane covered by that lane's status. A locked worktree stops the uninstall even with --force-worktrees. A .gstack/ that holds its own git repository is kept with a message. The check runs again right before .gstack-worktrees/ is deleted, so work saved while the prompt waited is kept (exit 2). The manual steps in docs/uninstall.md no longer rm -rf the per-project folders. test/uninstall.test.ts is already excluded from the Windows subset by scripts/lib/windows-curation.ts (it spawns a bin/ shebang script). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Keep a lane's submodule when it holds commits no remote-tracking branch has (GST-2268) The submodule exemption trusted the lane's status, which only compares the submodule's HEAD to the recorded gitlink. Commits on the submodule's own branches or stash live in the lane's git dir and were deleted with exit 0. Uninstall now refuses such a submodule, and matches the gitlink by exact path instead of a prefix or glob pathspec. Review-Finding: s11/outside-voice#1 reader=outside-voice Review-Finding: s11/adversarial-native#1 reader=adversarial-native Review-Finding: p1/specialist-batched#1 reader=testing Review-Finding: p1/red-team#3 reader=red-team Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Explain the submodule rule in the uninstall docs (GST-2268) docs/uninstall.md now says a submodule inside a clean lane goes only when its commits are on a remote-tracking branch, and the changelog entry drops a mid-sentence colon. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Make every gstack-uninstall keep check run before anything is deleted (GST-2268) The post-prompt worktree check now runs right after confirmation, before the install root or anything else is removed, so running uninstall from inside the gstack install no longer deletes a worktree edited during the prompt. A .gstack/ holding its own git repository inside the install being removed now stops the uninstall. A failing git worktree list stops it too, even with --force-worktrees. The manual cleanup in docs/uninstall.md now matches worktree paths by prefix, so it never removes a worktree outside the project whose path merely contains it. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Also stop gstack-uninstall when a .gstack repo sits inside a Cursor, OpenCode, Copilot, Antigravity or Gemini install (GST-2268) Review-Finding: p1/specialist-batched#1 reader=red-team Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Split a run-on sentence in the uninstall release note (GST-2268) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- bin/gstack-uninstall | 151 ++++++++-- ...ll-deleting-nested-and-locked-worktrees.md | 4 + docs/uninstall.md | 28 +- test/uninstall.test.ts | 267 +++++++++++++++++- 4 files changed, 417 insertions(+), 33 deletions(-) create mode 100644 changelog.d/stop-uninstall-deleting-nested-and-locked-worktrees.md diff --git a/bin/gstack-uninstall b/bin/gstack-uninstall index 7b0bc209e..7b35678d4 100755 --- a/bin/gstack-uninstall +++ b/bin/gstack-uninstall @@ -7,14 +7,19 @@ # gstack-uninstall --keep-state — remove skills but keep all gstack state, # including the repo's .gstack/ and .gstack-worktrees/ # gstack-uninstall --force-worktrees — also delete .gstack-worktrees/ checkouts -# that have uncommitted changes +# that have uncommitted changes (never a +# locked worktree) # # Per-project state: git-tracked files under .gstack/ (ship policy and the -# like) are never deleted; only the files git ignores go. A checkout under -# .gstack-worktrees/ with uncommitted changes, commits no branch or tag holds, -# a status git cannot read, or no registration as this repository's worktree -# stops the uninstall (exit 2, before anything is removed) unless -# --force-worktrees is given. +# like) are never deleted; only the files git ignores go, and a .gstack/ that +# holds its own git repository is kept. A checkout under .gstack-worktrees/ +# (nested ones included) with uncommitted changes, commits no branch or tag +# holds, a status git cannot read, or no registration as this repository's +# worktree stops the uninstall (exit 2, before anything is removed) unless +# --force-worktrees is given; a locked worktree, or a worktree list git cannot +# read, stops it either way. The check runs again after the prompt, still before +# anything is removed. Run from inside the install being removed, a .gstack/ +# holding its own git repository also stops it. # # State: only the default root ~/.gstack/ is ever deleted. A state root # selected elsewhere (GSTACK_HOME, GSTACK_STATE_ROOT, GSTACK_STATE_DIR or @@ -141,16 +146,23 @@ if [ "$KEEP_STATE" -eq 0 ] && [ -e "$RESOLVED_ROOT" ]; then fi fi # ─── Per-project worktree safety ───────────────────────────── -# Every checkout root on disk under .gstack-worktrees/ (a folder holding a .git), -# registered or not, NUL-separated so no name can split into a relative path. -# A symlinked .gstack-worktrees is never followed: uninstall removes only the link. +# Every checkout on disk under .gstack-worktrees/ (a folder holding a .git), +# registered or not, including checkouts nested inside another one, +# NUL-separated so no name can split into a relative path. Symlinks are never +# followed; a symlinked .gstack-worktrees is only unlinked. _project_worktrees() { [ -d "$_GIT_ROOT/.gstack-worktrees" ] || return 0 - find "$_GIT_ROOT/.gstack-worktrees" -mindepth 0 -type d -exec sh -c 'test -e "$1/.git"' sh {} \; -print0 -prune 2>/dev/null + find "$_GIT_ROOT/.gstack-worktrees" -name .git -prune -print0 2>/dev/null | while IFS= read -r -d '' _dotgit; do + printf '%s\0' "${_dotgit%/.git}" + done } -# Real paths of the worktrees this repository registered. +# 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 | sed -n 's/^worktree //p' | while IFS= read -r _wt; do + 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 } @@ -162,36 +174,105 @@ _wt_git() { } # Prints why deleting checkout $1 would lose work; prints nothing when it is safe. # A checkout this repository did not register is never opened with git, so its -# own config cannot run anything. +# own config cannot run anything; a submodule of a registered lane is the one +# exception, read with rev-list under _wt_git. _worktree_risk() { - local _dir _status _head + local _dir _status _head _local _dir="$(_real "$1")" case "$_dir" in ""|*" "*) echo "it is not a registered worktree of this repository"; return 0 ;; esac - printf '%s\n' "$_REGISTERED_WORKTREES" | grep -Fxq -- "$_dir" || { echo "it is not a registered worktree of this repository"; return 0; } + if ! printf '%s\n' "$_REGISTERED_WORKTREES" | grep -Fxq -- "$_dir"; then + _is_registered_submodule "$_dir" || { echo "it is not a registered worktree of this repository"; return 0; } + _local="$(_wt_git "$_dir" rev-list -n 1 --all --not --remotes 2>/dev/null)" || { echo "git cannot read its history"; return 0; } + [ -z "$_local" ] || echo "it is a submodule with commits no remote-tracking branch contains" + return 0 + fi [ "$(_real "$(_wt_git "$1" rev-parse --show-toplevel 2>/dev/null)")" = "$_dir" ] || { echo "git cannot read it as a checkout"; return 0; } _status="$(_wt_git "$1" status --porcelain --untracked-files=all --ignore-submodules=none 2>/dev/null)" || { echo "git cannot read its status"; return 0; } [ -z "$_status" ] || { echo "it has uncommitted changes"; return 0; } _head="$(_wt_git "$1" rev-parse -q --verify HEAD 2>/dev/null)" || return 0 [ -n "$(git -C "$_GIT_ROOT" for-each-ref --contains "$_head" --count=1 --format=x 2>/dev/null)" ] || echo "it has commits no branch or tag contains" } -if [ "$KEEP_STATE" -eq 0 ] && [ "$FORCE_WORKTREES" -eq 0 ] && [ -n "$_GIT_ROOT" ] && [ -d "$_GIT_ROOT/.gstack-worktrees" ] && [ ! -L "$_GIT_ROOT/.gstack-worktrees" ]; then - _DIRTY_WORKTREES=0 +# Succeeds when checkout $1 is a submodule of the registered worktree under +# .gstack-worktrees that holds it: that worktree's index records exactly this +# path as a gitlink, so its status reports the submodule's changes. Commits only +# the submodule's own refs hold are checked by the caller. +_is_registered_submodule() { + local _wt _parent="" _base _rel _entry + _base="$(_real "$_GIT_ROOT/.gstack-worktrees")" + while IFS= read -r _wt; do + case "$_wt" in "$_base"/*) ;; *) continue ;; esac + case "$1" in "$_wt"/*) [ ${#_wt} -gt ${#_parent} ] && _parent="$_wt" ;; esac + done </dev/null | tr '\0' '\n')" + case "$_entry" in 160000\ *) ;; *) return 1 ;; esac + [ "${_entry#* }" = "$_rel" ] +} +# Prints one "refusing to delete" line per checkout under .gstack-worktrees/ +# that must be kept; prints nothing when every one can go. A locked worktree +# is kept even with --force-worktrees. +_worktree_refusals() { + local _wt _risk + if ! git -C "$_GIT_ROOT" worktree list --porcelain >/dev/null 2>&1; then + echo "gstack-uninstall: refusing to delete $_GIT_ROOT/.gstack-worktrees/: git cannot list this repository's worktrees" + return 0 + fi _REGISTERED_WORKTREES="$(_registered_worktrees)" - while IFS= read -r -d '' _WT; do - [ -n "$_WT" ] || continue - _WT_RISK="$(_worktree_risk "$_WT")" - [ -n "$_WT_RISK" ] || continue - echo "gstack-uninstall: refusing to delete $_WT: $_WT_RISK" >&2 - _DIRTY_WORKTREES=1 + _LOCKED_WORKTREES="$(_registered_worktrees locked)" + while IFS= read -r -d '' _wt; do + [ -n "$_wt" ] || continue + if [ -n "$_LOCKED_WORKTREES" ] && printf '%s\n' "$_LOCKED_WORKTREES" | grep -Fxq -- "$(_real "$_wt")"; then + _risk="it is locked (git worktree lock)" + elif [ "$FORCE_WORKTREES" -eq 1 ]; then + continue + else + _risk="$(_worktree_risk "$_wt")" + fi + [ -z "$_risk" ] || echo "gstack-uninstall: refusing to delete $_wt: $_risk" done < <(_project_worktrees) - if [ "$_DIRTY_WORKTREES" -eq 1 ]; then - echo "fix: commit the work to a branch or stash it, re-run with --keep-state to keep every worktree, or re-run with --force-worktrees to delete them anyway" >&2 +} +_WORKTREES_FIX="fix: commit the work to a branch or stash it, unlock a locked worktree with git worktree unlock , re-run with --keep-state to keep every worktree, or re-run with --force-worktrees to delete the unlocked ones anyway" +# Succeeds when the current repository sits inside a gstack install this +# uninstall deletes, so nothing in it can be kept by keeping one folder. +_git_root_in_install() { + local _install _root + _root="$(_real "$_GIT_ROOT")" + for _install in "$HOME/.claude/skills/gstack" "$HOME"/.codex/skills/gstack* "$HOME"/.factory/skills/gstack* "$HOME"/.kiro/skills/gstack* \ + "$HOME"/.cursor/skills/gstack* "$HOME"/.config/opencode/skills/gstack* "$HOME"/.copilot/skills/gstack* \ + "$HOME"/.gemini/antigravity-cli/skills/gstack* "$HOME"/.gemini/skills/gstack*; do + [ -d "$_install" ] || continue + _is_at_or_above "$(_real "$_install")" "$_root" && return 0 + done + return 1 +} +# Every keep decision for per-project state, made before anything is removed: +# exits 2 when a checkout must be kept, or when the repository's .gstack/ holds +# its own git repository inside the install being deleted. $1, when set, is +# printed before the fix line. +_check_project_keep() { + [ -n "$_GIT_ROOT" ] || return 0 + if [ "$KEEP_STATE" -eq 0 ] && [ -d "$_GIT_ROOT/.gstack-worktrees" ] && [ ! -L "$_GIT_ROOT/.gstack-worktrees" ]; then + _REFUSALS="$(_worktree_refusals)" + if [ -n "$_REFUSALS" ]; then + printf '%s\n' "$_REFUSALS" >&2 + [ -z "${1:-}" ] || echo "$1" >&2 + echo "$_WORKTREES_FIX" >&2 + exit 2 + fi + fi + if [ -d "$_GIT_ROOT/.gstack" ] && [ ! -L "$_GIT_ROOT/.gstack" ] && [ -n "$(find "$_GIT_ROOT/.gstack" -name .git -print -quit 2>/dev/null)" ] && _git_root_in_install; then + echo "gstack-uninstall: refusing to uninstall: $_GIT_ROOT/.gstack/ holds its own git repository, and it sits inside the gstack install being removed" >&2 + echo "fix: move $_GIT_ROOT/.gstack/ out of the install (or push its commits), then re-run gstack-uninstall" >&2 exit 2 fi -fi +} +_check_project_keep _report_left_root() { [ -n "$_LEAVE_ROOT" ] || return 0 @@ -238,6 +319,7 @@ if [ "$FORCE" -eq 0 ]; then y|Y|yes|YES) ;; *) echo "Aborted."; exit 0 ;; esac + _check_project_keep "nothing was removed: a checkout changed while the prompt waited" fi REMOVED=() @@ -563,6 +645,10 @@ _remove_project_dir() { return 0 fi if [ -z "$_tracked" ]; then + if [ -n "$(find "$_dir" -name .git -print -quit 2>/dev/null)" ]; then + echo "kept $_dir/: it holds its own git repository, whose commits may exist nowhere else; check it, then remove it by hand" >&2 + return 0 + fi rm -rf "$_dir" REMOVED+=("$_dir/") return 0 @@ -577,12 +663,19 @@ if [ -n "$_GIT_ROOT" ] && [ "$KEEP_STATE" -eq 0 ]; then rm -f -- "$_GIT_ROOT/.gstack-worktrees" REMOVED+=("$_GIT_ROOT/.gstack-worktrees (link only)") elif [ -d "$_GIT_ROOT/.gstack-worktrees" ]; then + _WTS=() + _WT_REALS=() while IFS= read -r -d '' _WT; do [ -n "$_WT" ] || continue - _WT_REAL="$(_real "$_WT")" - rm -rf "$_WT" - [ -n "$_WT_REAL" ] && [ ! -e "$_WT_REAL" ] && git -C "$_GIT_ROOT" worktree remove --force "$_WT_REAL" >/dev/null 2>&1 || true + _WTS+=("$_WT") + _WT_REALS+=("$(_real "$_WT")") done < <(_project_worktrees) + for _WT in ${_WTS[@]+"${_WTS[@]}"}; do + rm -rf "$_WT" + done + for _WT_REAL in ${_WT_REALS[@]+"${_WT_REALS[@]}"}; do + [ -n "$_WT_REAL" ] && [ ! -e "$_WT_REAL" ] && git -C "$_GIT_ROOT" worktree remove --force "$_WT_REAL" >/dev/null 2>&1 || true + done _remove_project_dir .gstack-worktrees fi fi diff --git a/changelog.d/stop-uninstall-deleting-nested-and-locked-worktrees.md b/changelog.d/stop-uninstall-deleting-nested-and-locked-worktrees.md new file mode 100644 index 000000000..c57105869 --- /dev/null +++ b/changelog.d/stop-uninstall-deleting-nested-and-locked-worktrees.md @@ -0,0 +1,4 @@ +--- +important: data-loss +--- +- **`gstack-uninstall` no longer deletes a worktree nested inside another checkout, a locked worktree, a `.gstack/` that is its own git repo, or a worktree you edit while it asks to continue.** Since #4241 a plain uninstall stops when a `.gstack-worktrees/` checkout holds unsaved work, but it only looked at the top-level checkouts, so a worktree created inside a lane (as gstack does when it runs from inside one) was deleted with its uncommitted edits and exit 0. Now every checkout on disk is checked, nested ones included; a submodule inside a clean lane goes with it only when every commit it holds is on a remote-tracking branch, and otherwise stops the uninstall. A locked worktree (`git worktree lock`) stops the uninstall even with `--force-worktrees`, and the fix line says to `git worktree unlock` it. A `.gstack/` that is its own git repository, and that the project tracks nothing in, is kept with a message saying why, where before it was deleted with any commits it held. The worktree check now runs a second time after you confirm, before anything is removed, so work saved while the confirmation prompt waited is kept. Uninstall exits 2 and removes nothing, even when run from inside the gstack install. A `git worktree list` that fails stops the uninstall instead of letting `--force-worktrees` delete blind. The manual steps in docs/uninstall.md no longer tell you to `rm -rf .gstack .gstack-worktrees`; they remove only what git ignores and only worktrees that pass the same checks (GST-2268). diff --git a/docs/uninstall.md b/docs/uninstall.md index 9b6be3f83..3d62af1d2 100644 --- a/docs/uninstall.md +++ b/docs/uninstall.md @@ -10,7 +10,7 @@ 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. 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. It checks only the top-level checkouts: a worktree created inside another checkout under `.gstack-worktrees/` is not checked and is deleted with its parent, so commit or stash its work before you uninstall. +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 ` 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. ## Option 2: Manual removal (no local repo) @@ -62,10 +62,32 @@ rm -rf ~/.gemini/skills/gstack* 2>/dev/null rm -f /tmp/gstack-* 2>/dev/null # 7. Per-project cleanup (run from each project root) -rm -rf .gstack .gstack-worktrees .claude/skills/gstack 2>/dev/null -rm -rf .agents/skills/gstack* .factory/skills/gstack* 2>/dev/null +rm -rf .claude/skills/gstack .agents/skills/gstack* .factory/skills/gstack* 2>/dev/null +# 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 +# Worktrees, deepest first: each goes only when it has no uncommitted changes, +# no commits a branch or tag lacks, and no checkout inside it. git refuses a +# locked one. Anything printed as kept still holds work: save it, then re-run. +root=$(git rev-parse --show-toplevel) +git worktree list --porcelain | sed -n 's/^worktree //p' | sort -r | +while IFS= read -r wt; do + case "$wt" in "$root/.gstack-worktrees/"*) ;; *) continue ;; esac + if [ -z "$(git -C "$wt" status --porcelain --untracked-files=all)" ] && + [ -z "$(git -C "$wt" log --oneline -1 HEAD --not --branches --tags)" ] && + [ -z "$(find "$wt" -mindepth 2 -name .git -print -quit)" ]; then + git worktree remove "$wt" || echo "kept $wt" + else + echo "kept $wt: it holds work or another checkout" + fi +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 +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. + Manual removal leaves gstack's hook entries behind in `~/.claude/settings.json` (the uninstall script removes all of them for you, including entries whose `_gstack_source` tag was stripped). Edit that file and delete every hook whose diff --git a/test/uninstall.test.ts b/test/uninstall.test.ts index 6cd3b0788..c17f6f22e 100644 --- a/test/uninstall.test.ts +++ b/test/uninstall.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; -import { spawnSync } from 'child_process'; +import { spawn, spawnSync } from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; @@ -827,6 +827,271 @@ describe('per-project state: tracked files and dirty worktrees survive (GSTA-461 expect(fs.existsSync(path.join(repo, worktree))).toBe(true); }); + test('a symlink inside .gstack-worktrees to a repo outside is only unlinked by --force --force-worktrees', () => { + const outside = path.join(tmp, 'outside'); + fs.mkdirSync(outside); + git(['init', '-q', '-b', 'main'], outside); + fs.writeFileSync(path.join(outside, 'keep.txt'), 'keep\n'); + fs.mkdirSync(path.join(repo, '.gstack-worktrees', 'run-1'), { recursive: true }); + fs.symlinkSync(outside, path.join(repo, '.gstack-worktrees', 'run-1', 'lane')); + const r = uninstall(['--force', '--force-worktrees']); + expect(r.status).toBe(0); + expect(fs.existsSync(path.join(repo, '.gstack-worktrees'))).toBe(false); + expect(fs.readFileSync(path.join(outside, 'keep.txt'), 'utf-8')).toBe('keep\n'); + expect(fs.existsSync(path.join(outside, '.git', 'HEAD'))).toBe(true); + }); + + test('a dirty worktree nested inside a clean lane stops the uninstall and keeps its edit', () => { + addWorktree(false); + const inner = path.join(repo, worktree, '.gstack-worktrees', 'run-2', 'inner'); + git(['worktree', 'add', '-q', '--detach', inner, 'HEAD'], path.join(repo, worktree)); + fs.writeFileSync(path.join(inner, 'README.md'), 'unsaved edit\n'); + const r = uninstall(['--force']); + expect(r.status).toBe(2); + expect(r.stderr).toContain(`refusing to delete ${inner}: it has uncommitted changes`); + expect(fs.readFileSync(path.join(inner, 'README.md'), 'utf-8')).toBe('unsaved edit\n'); + expect(fs.existsSync(path.join(home, '.claude', 'skills', 'gstack'))).toBe(true); + }); + + test('a clean worktree nested inside a clean lane is removed with its registration', () => { + addWorktree(false); + const inner = path.join(repo, worktree, '.gstack-worktrees', 'run-2', 'inner'); + git(['worktree', 'add', '-q', '--detach', inner, 'HEAD'], path.join(repo, worktree)); + const r = uninstall(['--force']); + expect(r.status).toBe(0); + expect(fs.existsSync(path.join(repo, '.gstack-worktrees'))).toBe(false); + expect(git(['worktree', 'list', '--porcelain'])).not.toContain('.gstack-worktrees'); + }); + + test('an initialized submodule inside a clean lane does not stop the uninstall', () => { + const lib = path.join(tmp, 'lib'); + fs.mkdirSync(lib); + git(['init', '-q', '-b', 'main'], lib); + fs.writeFileSync(path.join(lib, 'lib.txt'), 'lib\n'); + git(['add', 'lib.txt'], lib); + git(['commit', '-q', '-m', 'lib'], lib); + git(['-c', 'protocol.file.allow=always', 'submodule', 'add', '-q', lib, 'vendor/lib']); + git(['commit', '-q', '-m', 'submodule']); + addWorktree(false); + git(['-c', 'protocol.file.allow=always', 'submodule', 'update', '-q', '--init'], path.join(repo, worktree)); + expect(fs.existsSync(path.join(repo, worktree, 'vendor', 'lib', '.git'))).toBe(true); + const r = uninstall(['--force']); + expect(r.stderr).toBe(''); + expect(r.status).toBe(0); + expect(fs.existsSync(path.join(repo, '.gstack-worktrees'))).toBe(false); + }); + + test('a submodule inside a clean lane with a commit no remote-tracking branch holds stops the uninstall', () => { + const lib = path.join(tmp, 'lib'); + fs.mkdirSync(lib); + git(['init', '-q', '-b', 'main'], lib); + fs.writeFileSync(path.join(lib, 'lib.txt'), 'lib\n'); + git(['add', 'lib.txt'], lib); + git(['commit', '-q', '-m', 'lib'], lib); + git(['-c', 'protocol.file.allow=always', 'submodule', 'add', '-q', lib, 'vendor/lib']); + git(['commit', '-q', '-m', 'submodule']); + addWorktree(false); + git(['-c', 'protocol.file.allow=always', 'submodule', 'update', '-q', '--init'], path.join(repo, worktree)); + const sub = path.join(repo, worktree, 'vendor', 'lib'); + const recorded = git(['rev-parse', 'HEAD'], sub).trim(); + git(['switch', '-q', '-c', 'wip'], sub); + fs.writeFileSync(path.join(sub, 'wip.txt'), 'local only\n'); + git(['add', 'wip.txt'], sub); + git(['commit', '-q', '-m', 'wip'], sub); + const wip = git(['rev-parse', 'HEAD'], sub).trim(); + git(['switch', '-q', '--detach', recorded], sub); + expect(git(['status', '--porcelain', '--ignore-submodules=none'], path.join(repo, worktree))).toBe(''); + const r = uninstall(['--force']); + expect(r.status).toBe(2); + expect(r.stderr).toContain(`refusing to delete ${sub}: it is a submodule with commits no remote-tracking branch contains`); + expect(git(['cat-file', '-t', wip], sub).trim()).toBe('commit'); + }); + + for (const flags of [['--force'], ['--force', '--force-worktrees']]) { + test(`a locked worktree stops ${flags.join(' ')} and is kept with its registration`, () => { + addWorktree(false); + git(['worktree', 'lock', '--reason', 'on a USB disk', worktree]); + const r = uninstall(flags); + expect(r.status).toBe(2); + expect(r.stderr).toContain(`refusing to delete ${path.join(repo, worktree)}: it is locked`); + expect(r.stderr).toMatch(/^fix: .*git worktree unlock/m); + expect(fs.existsSync(path.join(repo, worktree, 'README.md'))).toBe(true); + expect(git(['worktree', 'list', '--porcelain'])).toContain(`worktree ${path.join(repo, worktree)}\n`); + }); + } + + test('a .gstack that is its own git repo is kept, with the reason, when the project tracks nothing there', () => { + git(['init', '-q', '-b', 'main'], path.join(repo, '.gstack')); + fs.writeFileSync(path.join(repo, '.gstack', 'notes.md'), 'mine\n'); + git(['add', 'notes.md'], path.join(repo, '.gstack')); + git(['commit', '-q', '-m', 'unpushed notes'], path.join(repo, '.gstack')); + const r = uninstall(['--force']); + expect(r.status).toBe(0); + expect(r.stderr).toContain(`kept ${repo}/.gstack/: it holds its own git repository`); + expect(git(['log', '--format=%s'], path.join(repo, '.gstack'))).toBe('unpushed notes\n'); + expect(fs.existsSync(path.join(home, '.gstack'))).toBe(false); + }); + + test('a worktree edited while the prompt waits is kept: the check runs again before deleting', async () => { + addWorktree(false); + const child = spawn('bash', [UNINSTALL], { + cwd: repo, timeout: 30_000, stdio: ['pipe', 'pipe', 'pipe'], + 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: '' }, + }); + let stdout = '', stderr = ''; + child.stderr.on('data', (d) => { stderr += d; }); + child.stdout.on('data', (d) => { + stdout += d; + if (stdout.includes('Continue? [y/N]') && !child.stdin.writableEnded) { + fs.writeFileSync(path.join(repo, worktree, 'wip.txt'), 'typed during the prompt\n'); + child.stdin.end('y\n'); + } + }); + const status = await new Promise((resolve) => child.on('close', resolve)); + expect(status).toBe(2); + expect(stderr).toContain(`refusing to delete ${path.join(repo, worktree)}: it has uncommitted changes`); + expect(stderr).toContain('nothing was removed: a checkout changed while the prompt waited'); + expect(fs.existsSync(path.join(home, '.claude', 'skills', 'gstack'))).toBe(true); + expect(fs.readFileSync(path.join(repo, worktree, 'wip.txt'), 'utf-8')).toBe('typed during the prompt\n'); + }); + + const promptRun = (cwd: string, onPrompt: () => void) => new Promise<{ status: number | null; stderr: string }>((resolve) => { + const child = spawn('bash', [UNINSTALL], { + cwd, timeout: 30_000, stdio: ['pipe', 'pipe', 'pipe'], + 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: '' }, + }); + let stdout = '', stderr = ''; + child.stderr.on('data', (d) => { stderr += d; }); + child.stdout.on('data', (d) => { + stdout += d; + if (stdout.includes('Continue? [y/N]') && !child.stdin.writableEnded) { onPrompt(); child.stdin.end('y\n'); } + }); + child.on('close', (status) => resolve({ status, stderr })); + }); + const makeInstallRepo = () => { + const install = path.join(home, '.claude', 'skills', 'gstack'); + git(['init', '-q', '-b', 'main'], install); + fs.writeFileSync(path.join(install, '.git', 'info', 'exclude'), '.gstack-worktrees/\n.gstack/\n'); + git(['add', 'SKILL.md'], install); + git(['commit', '-q', '-m', 'install'], install); + return install; + }; + + test('run from inside the install, a worktree edited during the prompt stops the uninstall before anything is removed', async () => { + const install = makeInstallRepo(); + const lane = path.join(install, '.gstack-worktrees', 'run-1', 'lane'); + git(['worktree', 'add', '-q', '--detach', lane, 'HEAD'], install); + const r = await promptRun(install, () => fs.writeFileSync(path.join(lane, 'wip.txt'), 'typed during the prompt\n')); + expect(r.status).toBe(2); + expect(r.stderr).toContain(`refusing to delete ${lane}: it has uncommitted changes`); + expect(fs.readFileSync(path.join(lane, 'wip.txt'), 'utf-8')).toBe('typed during the prompt\n'); + expect(fs.existsSync(path.join(install, 'SKILL.md'))).toBe(true); + expect(fs.existsSync(path.join(home, '.gstack'))).toBe(true); + }); + + test('run from inside the install, a .gstack that is its own repo stops the uninstall before anything is removed', () => { + const install = makeInstallRepo(); + const own = path.join(install, '.gstack'); + fs.mkdirSync(own); + git(['init', '-q', '-b', 'main'], own); + fs.writeFileSync(path.join(own, 'notes.md'), 'mine\n'); + git(['add', 'notes.md'], own); + git(['commit', '-q', '-m', 'unpushed notes'], own); + const r = spawnSync('bash', [UNINSTALL, '--force'], { + cwd: install, 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: '' }, + }); + expect(r.status).toBe(2); + expect(r.stderr).toContain(`${install}/.gstack/ holds its own git repository`); + expect(git(['log', '--format=%s'], own)).toBe('unpushed notes\n'); + expect(fs.existsSync(path.join(home, '.gstack'))).toBe(true); + }); + + test('run from inside a Cursor install, a .gstack that is its own repo stops the uninstall before anything is removed', () => { + const install = path.join(home, '.cursor', 'skills', 'gstack'); + fs.mkdirSync(install, { recursive: true }); + fs.writeFileSync(path.join(install, 'SKILL.md'), '\n'); + git(['init', '-q', '-b', 'main'], install); + fs.writeFileSync(path.join(install, '.git', 'info', 'exclude'), '.gstack/\n'); + git(['add', 'SKILL.md'], install); + git(['commit', '-q', '-m', 'install'], install); + const own = path.join(install, '.gstack'); + fs.mkdirSync(own); + git(['init', '-q', '-b', 'main'], own); + fs.writeFileSync(path.join(own, 'notes.md'), 'mine\n'); + git(['add', 'notes.md'], own); + git(['commit', '-q', '-m', 'unpushed notes'], own); + const r = spawnSync('bash', [UNINSTALL, '--force'], { + cwd: install, 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: '' }, + }); + expect(r.status).toBe(2); + expect(r.stderr).toContain(`${install}/.gstack/ holds its own git repository`); + expect(git(['log', '--format=%s'], own)).toBe('unpushed notes\n'); + expect(fs.existsSync(path.join(install, 'SKILL.md'))).toBe(true); + }); + + test('a failing git worktree list stops --force --force-worktrees and keeps the checkouts', () => { + addWorktree(true); + const realGit = spawnSync('bash', ['-c', 'command -v git'], { encoding: 'utf-8', timeout: 30_000 }).stdout.trim(); + const shim = path.join(tmp, 'shim'); + fs.mkdirSync(shim); + fs.writeFileSync(path.join(shim, 'git'), `#!/bin/sh\ncase " $* " in *" worktree list "*) echo "fatal: broken" >&2; exit 128 ;; esac\nexec '${realGit}' "$@"\n`, { mode: 0o755 }); + const r = spawnSync('bash', [UNINSTALL, '--force', '--force-worktrees'], { + cwd: repo, encoding: 'utf-8', timeout: 30_000, + env: { ...gitEnv, PATH: `${shim}:${process.env.PATH}`, 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: '' }, + }); + expect(r.status).toBe(2); + expect(r.stderr).toContain("git cannot list this repository's worktrees"); + expect(fs.readFileSync(path.join(repo, worktree, 'wip.txt'), 'utf-8')).toBe('unsaved work\n'); + expect(fs.existsSync(path.join(home, '.claude', 'skills', 'gstack'))).toBe(true); + }); + + test('the manual cleanup in docs/uninstall.md never touches a worktree outside the project whose path contains it', () => { + const doc = fs.readFileSync(path.join(ROOT, 'docs', 'uninstall.md'), 'utf-8'); + const start = doc.indexOf('# 7. Per-project cleanup'); + const block = doc.slice(start, doc.indexOf('\n```', start)); + const external = path.join(tmp, 'mirror') + path.join(repo, '.gstack-worktrees', 'run-1', 'lane'); + git(['worktree', 'add', '-q', '--detach', external, 'HEAD']); + fs.appendFileSync(path.join(repo, '.git', 'info', 'exclude'), 'personal.txt\n'); + fs.writeFileSync(path.join(external, 'personal.txt'), 'mine\n'); + const r = spawnSync('bash', ['-c', block], { cwd: repo, encoding: 'utf-8', timeout: 30_000, env: gitEnv }); + expect(r.status).toBe(0); + expect(fs.readFileSync(path.join(external, 'personal.txt'), 'utf-8')).toBe('mine\n'); + expect(git(['worktree', 'list', '--porcelain'])).toContain(`worktree ${external}\n`); + }); + + test('the manual per-project cleanup in docs/uninstall.md keeps dirty and nested worktrees and tracked policy', () => { + const doc = fs.readFileSync(path.join(ROOT, 'docs', 'uninstall.md'), 'utf-8'); + const start = doc.indexOf('# 7. Per-project cleanup'); + expect(start).toBeGreaterThan(-1); + const block = doc.slice(start, doc.indexOf('\n```', start)); + trackPolicyAndAddBrowseState(); + addWorktree(true); + const lane = path.join(repo, '.gstack-worktrees', 'run-1', 'lane-2'); + git(['worktree', 'add', '-q', '--detach', lane, 'HEAD']); + const inner = path.join(lane, '.gstack-worktrees', 'run-2', 'inner'); + git(['worktree', 'add', '-q', '--detach', inner, 'HEAD'], lane); + fs.writeFileSync(path.join(inner, 'README.md'), 'unsaved edit\n'); + const clean = path.join(repo, '.gstack-worktrees', 'run-3', 'lane'); + git(['worktree', 'add', '-q', '--detach', clean, 'HEAD']); + const r = spawnSync('bash', ['-c', block], { cwd: repo, encoding: 'utf-8', timeout: 30_000, env: gitEnv }); + expect(r.status).toBe(0); + expect(fs.readFileSync(path.join(repo, worktree, 'wip.txt'), 'utf-8')).toBe('unsaved work\n'); + expect(fs.readFileSync(path.join(inner, 'README.md'), 'utf-8')).toBe('unsaved edit\n'); + expect(fs.existsSync(clean)).toBe(false); + expect(fs.existsSync(path.join(repo, '.gstack', 'ship-policy.json'))).toBe(true); + expect(fs.existsSync(path.join(repo, '.gstack', 'browse.json'))).toBe(false); + }); + test('hold: a plain uninstall still removes untracked .gstack state and clean worktrees entirely', () => { fs.writeFileSync(path.join(repo, '.gstack', 'browse.json'), '{}\n'); fs.mkdirSync(path.join(repo, '.gstack', 'reports'), { recursive: true });