From 490bbd520259add6bc18e52d3b9db3d516c6df91 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Mon, 9 Nov 2026 15:42:47 -0800 Subject: [PATCH] Stop gstack-uninstall deleting a repo's committed .gstack files and unsaved worktrees (GST-2197) (#4241) * Stop gstack-uninstall deleting a repo's committed .gstack files and unsaved worktrees (GST-2197) --keep-state now keeps the repo's .gstack/ and .gstack-worktrees/. A plain uninstall keeps git-tracked .gstack files, removes only ignored state, and stops before removing anything when a worktree has uncommitted changes unless --force-worktrees is given. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Keep moved, config-hidden and committed worktree work from gstack-uninstall (GST-2197) The dirty-worktree guard now checks every checkout on disk under .gstack-worktrees/, pins git status flags so config cannot hide new files, refuses a checkout whose commits no branch or tag holds, and keeps .gstack/ when git cannot list its tracked files. 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=security Review-Finding: p1/red-team#4 reader=red-team Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Make gstack-uninstall list worktree checkouts safely and never run their config (GST-2197) Checkouts are listed NUL-separated and through a symlinked .gstack-worktrees, only worktrees this repository registered are opened with git, and those git calls turn off fsmonitor, hooks and optional index writes. Review-Finding: v1/adversarial-native#1 reader=adversarial-native Review-Finding: v1/adversarial-native#5 reader=adversarial-native Review-Finding: v1/adversarial-native#2 reader=adversarial-native Review-Loop-Grant: a-f3b2ff8e662e Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Check a .gstack-worktrees that is itself a git repo before gstack-uninstall deletes it (GST-2197) Review-Finding: fc1/fix-check#1 reader=fix-check Review-Loop-Grant: a-3b80e7929588 Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Document what gstack-uninstall keeps and refuses, and the new --force-worktrees flag (GST-2197) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Stop gstack-uninstall dropping the registration of worktrees it did not delete (GST-2197) Uninstall ran a repo-wide git worktree prune, which unregistered every worktree whose folder was missing at that moment, including ones outside .gstack-worktrees on an unmounted drive, and left their detached commits unreachable. It now unregisters only the gstack checkouts it just removed. Review-Finding: p1/red-team#1 reader=red-team Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Say that gstack-uninstall does not yet check worktrees nested inside another checkout (GST-2197) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Stop gstack-uninstall deleting checkouts through a .gstack-worktrees symlink that points outside the repo (GST-2197) Review-Finding: p1/red-team#1 reader=red-team Review-Finding: p1/red-team#2 reader=red-team Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Make gstack-uninstall remove only the link when .gstack-worktrees is a symlink, as before (GST-2197) Review-Finding: p2/red-team#1 reader=red-team Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Drop release-note wording from the gstack-uninstall docs (GST-2197) 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 | 119 ++++++++- ...stall-keeps-tracked-and-dirty-worktrees.md | 4 + docs/uninstall.md | 4 +- test/uninstall.test.ts | 240 ++++++++++++++++++ 4 files changed, 353 insertions(+), 14 deletions(-) create mode 100644 changelog.d/fix-uninstall-keeps-tracked-and-dirty-worktrees.md diff --git a/bin/gstack-uninstall b/bin/gstack-uninstall index 6e561cf1d..7b0bc209e 100755 --- a/bin/gstack-uninstall +++ b/bin/gstack-uninstall @@ -4,7 +4,17 @@ # Usage: # gstack-uninstall — interactive uninstall (prompts before removing) # gstack-uninstall --force — remove everything without prompting -# gstack-uninstall --keep-state — remove skills but keep all gstack state +# 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 +# +# 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. # # State: only the default root ~/.gstack/ is ever deleted. A state root # selected elsewhere (GSTACK_HOME, GSTACK_STATE_ROOT, GSTACK_STATE_DIR or @@ -33,8 +43,10 @@ # ~/.gstack/ — global state (config, analytics, sessions, projects, # repos, installation-id, browse error logs) # .claude/skills/gstack* — project-local skill install (--local installs) -# .gstack/ — per-project browse state (in current git repo) -# .gstack-worktrees/ — per-project test worktrees (in current git repo) +# .gstack/ — per-project browse state (in current git repo; +# git-tracked files are kept) +# .gstack-worktrees/ — per-project test worktrees (in current git repo; +# dirty ones need --force-worktrees) # .agents/skills/gstack* — Codex/Gemini sidecar (in current git repo) # .cursor/skills/gstack* — project-local Cursor skills (in current git repo) # Running browse daemons — stopped via SIGTERM before cleanup @@ -71,17 +83,19 @@ _GIT_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || true)" # ─── Parse flags ───────────────────────────────────────────── FORCE=0 KEEP_STATE=0 +FORCE_WORKTREES=0 while [ $# -gt 0 ]; do case "$1" in --force) FORCE=1; shift ;; --keep-state) KEEP_STATE=1; shift ;; + --force-worktrees) FORCE_WORKTREES=1; shift ;; -h|--help) sed -n '2,/^[^#]/{ /^#/s/^# \{0,1\}//p; }' "$0" exit 0 ;; *) echo "Unknown option: $1" >&2 - echo "Usage: gstack-uninstall [--force] [--keep-state]" >&2 + echo "Usage: gstack-uninstall [--force] [--keep-state] [--force-worktrees]" >&2 exit 1 ;; esac @@ -126,6 +140,59 @@ if [ "$KEEP_STATE" -eq 0 ] && [ -e "$RESOLVED_ROOT" ]; then _LEAVE_ROOT="$RESOLVED_ROOT" 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. +_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 +} +# Real paths of the worktrees this repository registered. +_registered_worktrees() { + git -C "$_GIT_ROOT" worktree list --porcelain 2>/dev/null | sed -n 's/^worktree //p' | while IFS= read -r _wt; do + _real "$_wt" + done +} +# git inside a registered checkout, with no config able to run a command. +_wt_git() { + local _dir="$1" + shift + git -c core.fsmonitor=false -c core.hooksPath=/dev/null --no-optional-locks -C "$_dir" "$@" +} +# Prints why deleting checkout $1 would lose work; prints nothing when it is safe. +# A checkout this repository did not register is never opened with git, so its +# own config cannot run anything. +_worktree_risk() { + local _dir _status _head + _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; } + [ "$(_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 + _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 + 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 + exit 2 + fi +fi + _report_left_root() { [ -n "$_LEAVE_ROOT" ] || return 0 local _val @@ -151,8 +218,10 @@ if [ "$FORCE" -eq 0 ]; then if [ -n "$_GIT_ROOT" ]; then [ -d "$_GIT_ROOT/.claude/skills/gstack" ] && echo " $_GIT_ROOT/.claude/skills/gstack (project-local)" - [ -d "$_GIT_ROOT/.gstack" ] && echo " $_GIT_ROOT/.gstack/ (browse state + reports)" - [ -d "$_GIT_ROOT/.gstack-worktrees" ] && echo " $_GIT_ROOT/.gstack-worktrees/" + if [ "$KEEP_STATE" -eq 0 ]; then + [ -d "$_GIT_ROOT/.gstack" ] && echo " $_GIT_ROOT/.gstack/ (browse state + reports; git-tracked files are kept)" + [ -d "$_GIT_ROOT/.gstack-worktrees" ] && echo " $_GIT_ROOT/.gstack-worktrees/" + fi [ -d "$_GIT_ROOT/.agents/skills" ] && echo " $_GIT_ROOT/.agents/skills/gstack*" [ -d "$_GIT_ROOT/.cursor/skills" ] && echo " $_GIT_ROOT/.cursor/skills/gstack*" fi @@ -483,14 +552,38 @@ if [ -n "$_GIT_ROOT" ] && [ -d "$_GIT_ROOT/.cursor/skills" ]; then fi # ─── Remove per-project state ─────────────────────────────── -if [ -n "$_GIT_ROOT" ]; then - if [ -d "$_GIT_ROOT/.gstack" ]; then - rm -rf "$_GIT_ROOT/.gstack" - REMOVED+=("$_GIT_ROOT/.gstack/") +# --keep-state keeps both folders. Otherwise a folder holding git-tracked files +# loses only what git ignores; dirty worktrees were refused above unless +# --force-worktrees was given. +_remove_project_dir() { + local _dir="$_GIT_ROOT/$1" _tracked + [ -d "$_dir" ] || return 0 + if ! _tracked="$(git -C "$_GIT_ROOT" ls-files -- "$1" 2>/dev/null)"; then + echo "kept $_dir/: git could not list its tracked files" >&2 + return 0 fi - if [ -d "$_GIT_ROOT/.gstack-worktrees" ]; then - rm -rf "$_GIT_ROOT/.gstack-worktrees" - REMOVED+=("$_GIT_ROOT/.gstack-worktrees/") + if [ -z "$_tracked" ]; then + rm -rf "$_dir" + REMOVED+=("$_dir/") + return 0 + fi + git -C "$_GIT_ROOT" clean -fdXq -- "$1" >/dev/null 2>&1 || true + REMOVED+=("$_dir/ (git-ignored files)") + echo "kept git-tracked files in $_dir/ (and any untracked file git does not ignore)" >&2 +} +if [ -n "$_GIT_ROOT" ] && [ "$KEEP_STATE" -eq 0 ]; then + _remove_project_dir .gstack + if [ -L "$_GIT_ROOT/.gstack-worktrees" ] && [ -d "$_GIT_ROOT/.gstack-worktrees" ]; then + rm -f -- "$_GIT_ROOT/.gstack-worktrees" + REMOVED+=("$_GIT_ROOT/.gstack-worktrees (link only)") + elif [ -d "$_GIT_ROOT/.gstack-worktrees" ]; then + 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 + done < <(_project_worktrees) + _remove_project_dir .gstack-worktrees fi fi diff --git a/changelog.d/fix-uninstall-keeps-tracked-and-dirty-worktrees.md b/changelog.d/fix-uninstall-keeps-tracked-and-dirty-worktrees.md new file mode 100644 index 000000000..a4651fafe --- /dev/null +++ b/changelog.d/fix-uninstall-keeps-tracked-and-dirty-worktrees.md @@ -0,0 +1,4 @@ +--- +important: data-loss +--- +- **`gstack-uninstall` no longer deletes your repo's committed `.gstack/` files or unsaved work in `.gstack-worktrees/`.** Run from inside a repo, uninstall used to delete the whole `.gstack/` folder and every `.gstack-worktrees/` checkout, even with `--keep-state`, so a committed `.gstack/ship-policy.json` showed up as deleted in `git status` and a worktree's uncommitted files were gone. Now `--keep-state` keeps both folders. A plain uninstall still clears gstack's own ignored browse state, but keeps every git-tracked file under `.gstack/` (and says so), and it stops with exit 2 before removing anything when a checkout there has uncommitted changes (even with `status.showUntrackedFiles=no`), holds commits no branch or tag contains, or isn't a worktree this repo registered (as after moving the repo, or a separate clone put there, which uninstall never opens with git), naming the worktree and the fix: commit to a branch or stash, re-run with `--keep-state`, or pass the new `--force-worktrees` to delete them anyway. A symlinked `.gstack-worktrees` is only unlinked, as before, and never followed, so the checkouts it points at are kept. A worktree nested inside another checkout under `.gstack-worktrees/` is not checked yet and is still deleted with its parent, as before. The checkouts it removes also leave `git worktree list`; the registrations of your other worktrees are never touched, even when their folder is missing for now (GSTA-461, GST-2197). diff --git a/docs/uninstall.md b/docs/uninstall.md index c0d044329..9b6be3f83 100644 --- a/docs/uninstall.md +++ b/docs/uninstall.md @@ -8,7 +8,9 @@ If gstack is installed on your machine: ~/.claude/skills/gstack/bin/gstack-uninstall ``` -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. Use `--force` to skip confirmation. +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. ## Option 2: Manual removal (no local repo) diff --git a/test/uninstall.test.ts b/test/uninstall.test.ts index 2afaa641b..6cd3b0788 100644 --- a/test/uninstall.test.ts +++ b/test/uninstall.test.ts @@ -601,3 +601,243 @@ describe('state-root safety: only the default root is deleted (docs/state-root.m expect(fs.lstatSync(path.join(mockHome, '.gstack')).isSymbolicLink()).toBe(true); }); }); + +// Value: protects=a repo's tracked .gstack/ policy files and the uncommitted work in its .gstack-worktrees/ checkouts; +// fails_when=uninstall removes per-project state without checking --keep-state, git-tracked files, or dirty worktrees; +// why_new=GSTA-461 item 9 (data loss under --keep-state); seam=none; no_claim=a real gstack install (temp HOME and state root) +describe('per-project state: tracked files and dirty worktrees survive (GSTA-461 item 9)', () => { + let tmp: string, home: string, repo: string, gitEnv: Record; + const git = (args: string[], cwd = repo) => { + 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); + 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: '' }, + }); + const worktree = path.join('.gstack-worktrees', 'run-1', 'lane-1'); + + beforeEach(() => { + tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-uninstall-proj-'))); + 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; + fs.mkdirSync(path.join(home, '.claude', 'skills', 'gstack'), { recursive: true }); + fs.writeFileSync(path.join(home, '.claude', 'skills', 'gstack', 'SKILL.md'), 'test'); + fs.mkdirSync(path.join(home, '.gstack'), { recursive: true }); + fs.mkdirSync(path.join(repo, '.gstack'), { 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 })); + + function trackPolicyAndAddBrowseState() { + fs.writeFileSync(path.join(repo, '.gstack', 'ship-policy.json'), '{"merge":"direct"}\n'); + git(['add', '.gstack/ship-policy.json']); + git(['commit', '-q', '-m', 'policy']); + fs.writeFileSync(path.join(repo, '.gstack', '.gitignore'), '*\n'); + fs.writeFileSync(path.join(repo, '.gstack', 'browse.json'), '{}\n'); + } + function addWorktree(dirty: boolean) { + git(['worktree', 'add', '-q', '--detach', worktree, 'HEAD']); + if (dirty) fs.writeFileSync(path.join(repo, worktree, 'wip.txt'), 'unsaved work\n'); + } + + test('--keep-state keeps the repo .gstack/ and every .gstack-worktrees/ checkout', () => { + trackPolicyAndAddBrowseState(); + addWorktree(true); + const r = uninstall(['--force', '--keep-state']); + expect(r.status).toBe(0); + expect(r.stdout).not.toContain(`${repo}/.gstack`); + expect(fs.existsSync(path.join(repo, '.gstack', 'browse.json'))).toBe(true); + expect(git(['status', '--short'])).toBe(''); + 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(false); + }); + + test('a plain uninstall keeps git-tracked .gstack files and removes the ignored browse state', () => { + trackPolicyAndAddBrowseState(); + fs.writeFileSync(path.join(repo, '.gstack', 'ship-policy.json'), '{"merge":"queue"}\n'); + const r = uninstall(['--force']); + expect(r.status).toBe(0); + expect(fs.readFileSync(path.join(repo, '.gstack', 'ship-policy.json'), 'utf-8')).toBe('{"merge":"queue"}\n'); + expect(fs.existsSync(path.join(repo, '.gstack', 'browse.json'))).toBe(false); + expect(git(['status', '--short', '--', '.gstack'])).toBe(' M .gstack/ship-policy.json\n'); + expect(r.stderr).toContain('kept git-tracked files'); + }); + + test('a dirty worktree stops a plain uninstall before anything is removed, naming the worktree and the fix', () => { + trackPolicyAndAddBrowseState(); + addWorktree(true); + const r = uninstall(['--force']); + expect(r.status).toBe(2); + expect(r.stderr).toContain(`refusing to delete ${path.join(repo, worktree)}: it has uncommitted changes`); + expect(r.stderr).toMatch(/^fix: .*--keep-state.*--force-worktrees/m); + expect(fs.readFileSync(path.join(repo, worktree, 'wip.txt'), 'utf-8')).toBe('unsaved work\n'); + expect(fs.existsSync(path.join(repo, '.gstack', 'browse.json'))).toBe(true); + expect(fs.existsSync(path.join(home, '.claude', 'skills', 'gstack'))).toBe(true); + expect(fs.existsSync(path.join(home, '.gstack'))).toBe(true); + }); + + test('--force-worktrees removes a dirty worktree and prunes its registration', () => { + addWorktree(true); + const r = uninstall(['--force', '--force-worktrees']); + expect(r.status).toBe(0); + expect(fs.existsSync(path.join(repo, '.gstack-worktrees'))).toBe(false); + expect(git(['worktree', 'list', '--porcelain'])).not.toContain('lane-1'); + }); + + test('a missing worktree outside .gstack-worktrees keeps its registration and its commits', () => { + addWorktree(false); + const away = path.join(tmp, 'usb', 'wt'); + git(['worktree', 'add', '-q', '--detach', away, 'HEAD']); + git(['commit', '-q', '--allow-empty', '-m', 'unique detached work'], away); + const head = git(['rev-parse', 'HEAD'], away).trim(); + fs.renameSync(path.join(tmp, 'usb'), path.join(tmp, 'usb.unmounted')); + const r = uninstall(['--force']); + expect(r.status).toBe(0); + expect(fs.existsSync(path.join(repo, '.gstack-worktrees'))).toBe(false); + const list = git(['worktree', 'list', '--porcelain']); + expect(list).toContain(`worktree ${away}\nHEAD ${head}\n`); + expect(list).not.toContain('lane-1'); + }); + + test('status.showUntrackedFiles=no does not hide a new file in a worktree', () => { + addWorktree(true); + git(['config', 'status.showUntrackedFiles', 'no']); + const r = uninstall(['--force']); + expect(r.status).toBe(2); + expect(r.stderr).toContain(`refusing to delete ${path.join(repo, worktree)}: it has uncommitted changes`); + expect(fs.existsSync(path.join(repo, worktree, 'wip.txt'))).toBe(true); + }); + + test('a dirty worktree still stops the uninstall after the repo is moved', () => { + addWorktree(true); + const moved = path.join(tmp, 'moved'); + fs.renameSync(repo, moved); + repo = moved; + const r = uninstall(['--force']); + expect(r.status).toBe(2); + expect(r.stderr).toContain(`refusing to delete ${path.join(moved, worktree)}: it is not a registered worktree of this repository`); + expect(fs.readFileSync(path.join(moved, worktree, 'wip.txt'), 'utf-8')).toBe('unsaved work\n'); + }); + + test('a checkout name with a newline never turns into a relative delete', () => { + fs.mkdirSync(path.join(repo, 'src'), { recursive: true }); + fs.writeFileSync(path.join(repo, 'src', 'app.txt'), 'app\n'); + git(['add', 'src/app.txt']); + git(['commit', '-q', '-m', 'src']); + fs.mkdirSync(path.join(repo, '.gstack-worktrees', 'x\nsrc', '.git'), { recursive: true }); + const r = uninstall(['--force']); + expect(r.status).toBe(2); + expect(r.stderr).toContain('refusing to delete'); + expect(fs.readFileSync(path.join(repo, 'src', 'app.txt'), 'utf-8')).toBe('app\n'); + }); + + const linkGone = () => { + try { fs.lstatSync(path.join(repo, '.gstack-worktrees')); return false; } catch { return true; } + }; + + test('a symlinked .gstack-worktrees is unlinked, never followed, even when a checkout behind it has unique commits', () => { + const store = path.join(repo, 'worktree-store'); + fs.mkdirSync(store); + fs.symlinkSync(store, path.join(repo, '.gstack-worktrees')); + addWorktree(false); + git(['commit', '-q', '--allow-empty', '-m', 'wip'], path.join(repo, worktree)); + const r = uninstall(['--force']); + expect(r.status).toBe(0); + expect(linkGone()).toBe(true); + expect(fs.existsSync(path.join(store, 'run-1', 'lane-1', 'README.md'))).toBe(true); + expect(git(['worktree', 'list', '--porcelain'])).toContain(`worktree ${path.join(store, 'run-1', 'lane-1')}\n`); + }); + + test('a .gstack-worktrees symlink to a folder inside the repo keeps the worktrees behind it and their ignored files', () => { + fs.appendFileSync(path.join(repo, '.git', 'info', 'exclude'), '.claude/worktrees/\n.env\n'); + const feat = path.join(repo, '.claude', 'worktrees', 'feat'); + git(['worktree', 'add', '-q', '-b', 'feat', feat]); + fs.writeFileSync(path.join(feat, '.env'), 'SECRET=1\n'); + fs.symlinkSync('.claude/worktrees', path.join(repo, '.gstack-worktrees')); + const r = uninstall(['--force']); + expect(r.status).toBe(0); + expect(linkGone()).toBe(true); + expect(fs.readFileSync(path.join(feat, '.env'), 'utf-8')).toBe('SECRET=1\n'); + expect(git(['worktree', 'list', '--porcelain'])).toContain(`worktree ${feat}\n`); + }); + + for (const flags of [['--force'], ['--force', '--force-worktrees']]) { + test(`a .gstack-worktrees symlink to a folder outside the repo is only unlinked by ${flags.join(' ')}`, () => { + const store = path.join(tmp, 'worktree-store'); + fs.mkdirSync(store); + git(['worktree', 'add', '-q', '-b', 'feature', path.join(store, 'feature')]); + fs.symlinkSync(store, path.join(repo, '.gstack-worktrees')); + const r = uninstall(flags); + expect(r.status).toBe(0); + expect(linkGone()).toBe(true); + expect(fs.existsSync(path.join(store, 'feature', 'README.md'))).toBe(true); + expect(git(['worktree', 'list', '--porcelain'])).toContain(`worktree ${path.join(store, 'feature')}\n`); + expect(fs.existsSync(path.join(home, '.claude', 'skills', 'gstack'))).toBe(false); + }); + } + + test('a .gstack-worktrees symlink to a folder holding the main checkout never deletes the repo', () => { + fs.symlinkSync(tmp, path.join(repo, '.gstack-worktrees')); + const r = uninstall(['--force']); + expect(r.status).toBe(0); + expect(linkGone()).toBe(true); + expect(fs.existsSync(path.join(repo, '.git', 'HEAD'))).toBe(true); + expect(fs.readFileSync(path.join(repo, 'README.md'), 'utf-8')).toBe('x\n'); + }); + + test('a repo under .gstack-worktrees never runs its own config commands during the check', () => { + const nested = path.join(repo, '.gstack-worktrees', 'run-1', 'clone'); + fs.mkdirSync(nested, { recursive: true }); + git(['init', '-q', '-b', 'main'], nested); + fs.writeFileSync(path.join(nested, 'a.txt'), 'a\n'); + git(['add', 'a.txt'], nested); + git(['commit', '-q', '-m', 'a'], nested); + const marker = path.join(tmp, 'fsmonitor-ran'); + git(['config', 'core.fsmonitor', `touch '${marker}'; false`], nested); + const r = uninstall(['--force']); + expect(fs.existsSync(marker)).toBe(false); + expect(r.status).toBe(2); + expect(r.stderr).toContain(`refusing to delete ${nested}: it is not a registered worktree of this repository`); + }); + + test('a .gstack-worktrees that is itself a git repo stops the uninstall and keeps its work', () => { + git(['init', '-q', '.gstack-worktrees']); + fs.writeFileSync(path.join(repo, '.gstack-worktrees', 'wip.txt'), 'unsaved work\n'); + const r = uninstall(['--force']); + expect(r.status).toBe(2); + expect(fs.readFileSync(path.join(repo, '.gstack-worktrees', 'wip.txt'), 'utf-8')).toBe('unsaved work\n'); + }); + + test('a clean worktree holding a commit no branch contains stops the uninstall', () => { + addWorktree(false); + git(['commit', '-q', '--allow-empty', '-m', 'wip'], path.join(repo, worktree)); + const r = uninstall(['--force']); + expect(r.status).toBe(2); + expect(r.stderr).toContain(`refusing to delete ${path.join(repo, worktree)}: it has commits no branch or tag contains`); + expect(fs.existsSync(path.join(repo, worktree))).toBe(true); + }); + + 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 }); + fs.writeFileSync(path.join(repo, '.gstack', 'reports', 'qa.md'), '# qa\n'); + addWorktree(false); + const r = uninstall(['--force']); + expect(r.status).toBe(0); + expect(r.stdout).toContain(`${repo}/.gstack/`); + expect(r.stdout).toContain(`${repo}/.gstack-worktrees/`); + expect(fs.existsSync(path.join(repo, '.gstack'))).toBe(false); + expect(fs.existsSync(path.join(repo, '.gstack-worktrees'))).toBe(false); + expect(fs.existsSync(path.join(home, '.gstack'))).toBe(false); + }); +});