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 });