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>
This commit is contained in:
Garry Tanandcapy-ai[bot] authored and GitHub committed 2026-11-10 07:15:05 +00:00
1 parent 4685fa9233
commit 6c23638d6d
4 files changed
+417 -33

No files matched your search

+122 -29
View File
@@ -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 <<EOF
$_REGISTERED_WORKTREES
EOF
[ -n "$_parent" ] || return 1
_rel="${1#"$_parent"/}"
_entry="$(_wt_git "$_parent" ls-files --stage -z -- ":(literal)$_rel" 2>/dev/null | tr '\0' '\n')"
case "$_entry" in 160000\ *) ;; *) return 1 ;; esac
[ "${_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 <path>, 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
@@ -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).
+25 -3
View File
@@ -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 <path>` if you want it gone. If `git worktree list` fails, the uninstall stops too, even with `--force-worktrees`. The check runs again after you confirm, before anything is removed, so a checkout you edit while the confirmation prompt waits is kept: uninstall exits 2 and removes nothing. Run from inside the gstack install itself, a `.gstack/` that holds its own git repository also stops the uninstall, because deleting the install would take it along. A `.gstack/` that is its own git repository is kept, with a message saying so, because its commits may exist nowhere else. When `.gstack-worktrees` is a symlink, uninstall removes only the link and never follows it, so the folder it points at and the worktrees there are kept.
## 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
+266 -1
View File
@@ -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<number | null>((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'), '<!-- AUTO-GENERATED from SKILL.md.tmpl -->\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 });