mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-10 18:50:18 +01:00
Stop gstack-uninstall deleting unpushed or kept work under any folder it removes (GST-2303) (#4282)
* Stop gstack-uninstall deleting unpushed or kept work under any folder it removes (GST-2303) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Keep repos behind a symlinked .gstack-worktrees, remove stable-channel installs, and read worktrees on git before 2.36 in gstack-uninstall (GST-2303) Review-Finding: p1/red-team#1 reader=red-team Review-Finding: p1/red-team#2 reader=red-team Review-Finding: p1/red-team#3 reader=red-team Review-Finding: p1/specialist-batched#1 reader=testing Review-Finding: p1/specialist-batched#2 reader=testing Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Count a FETCH_HEAD commit as pushed only when it came from one of the checkout's remotes in gstack-uninstall (GST-2303) Review-Finding: p2/specialist-batched#1 reader=security Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Read remote URLs with git remote get-url so url.insteadOf rewrites match FETCH_HEAD in gstack-uninstall (GST-2303) Review-Finding: fc/fix-check#1 reader=fix-check Review-Finding: fc/fix-check#2 reader=fix-check Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Refuse to uninstall on git older than 2.36 instead of reading worktrees without -z (GST-2303) Review-Finding: v11/outside-voice#1 reader=outside-voice Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * List the git 2.36 uninstall refusal in troubleshooting (GST-2303) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
ee3bae4028
commit
6b327148e3
5 files changed
+651
-119
No files matched your search
@@ -987,6 +987,20 @@ pick a new `--output`), then copy the recovery file to where you want it.
|
||||
|
||||
---
|
||||
|
||||
## Uninstall
|
||||
|
||||
### `gstack-uninstall: refusing to delete <folder>: <checkout> has uncommitted changes` (or `has commits no remote-tracking branch contains`, `registers the locked worktree <path>`)
|
||||
|
||||
`gstack-uninstall` checks every folder it is about to delete (each host's gstack install, global and project-local, the gstack-made Claude per-skill folders, and `~/.gstack`) before it removes anything. A git checkout inside one of them holds work that exists nowhere else, so the uninstall stopped with exit 2 and removed nothing. The variants `is a git checkout git cannot read`, `has a status git cannot read`, `has a history git cannot read` and `has worktrees git cannot list (gstack-uninstall needs git 2.36 or later)` mean git could not prove the checkout is safe to delete; for the last one, upgrade git to 2.36 or later.
|
||||
|
||||
Fix: commit and push that checkout's work, or move the checkout out of `<folder>`. Unlock a locked worktree with `git worktree unlock <path>` if you want it gone. Then re-run `gstack-uninstall`. Checkouts under the current repo's `.gstack-worktrees/` have their own check, which `--force-worktrees` relaxes; this one has no override. [docs/uninstall.md](uninstall.md#option-1-run-the-uninstall-script)
|
||||
|
||||
### `gstack-uninstall: refusing to delete <folder>: it holds <path>, which this uninstall keeps`
|
||||
|
||||
`<folder>` is a gstack install the uninstall would delete, and `<path>` is state this run promised to keep: with `--keep-state`, `~/.gstack`, your state root, or the current repo's `.gstack/` or `.gstack-worktrees/`; without it, a state root selected by `GSTACK_HOME` or a similar variable. The usual cause is running `gstack-uninstall --keep-state` from inside the gstack checkout itself.
|
||||
|
||||
Fix: run `gstack-uninstall` from outside `<folder>` (for example `cd ~ && ~/.claude/skills/gstack/bin/gstack-uninstall --keep-state`), or move `<path>` out of `<folder>` first. [docs/uninstall.md](uninstall.md#option-1-run-the-uninstall-script)
|
||||
|
||||
## Codex and other env-var hosts
|
||||
|
||||
<a id="gstack-no-install-found"></a>
|
||||
|
||||
+48
-15
@@ -10,13 +10,45 @@ If gstack is installed on your machine:
|
||||
|
||||
This handles skills, symlinks, harness registrations (the files `gstack-harness` wrote into OpenClaw, Hermes, a Dot or another harness, from their ledgers, plus the opt-in [start hook](hosts.md#start-hook), which it turns off through the harness's own CLI), global state (`~/.gstack/`), project-local state, browse daemons, and temp files. Use `--keep-state` to preserve config and analytics; it also keeps the repo's `.gstack/` and `.gstack-worktrees/`. Use `--force` to skip confirmation.
|
||||
|
||||
A plain uninstall keeps the git-tracked files under `.gstack/` (such as ship policy) and removes only the ones git ignores. It stops with exit 2, before removing anything, when a checkout under `.gstack-worktrees/` has uncommitted changes, has commits no branch or tag holds, or is not a worktree this repo registered. Pass `--force-worktrees` to delete those checkouts anyway. Every checkout is checked, including a worktree created inside another checkout under `.gstack-worktrees/`. A git submodule inside a clean checkout is deleted with it only when every commit it holds is on a remote-tracking branch; otherwise it stops the uninstall. A locked worktree (`git worktree lock`) always stops the uninstall, even with `--force-worktrees`; unlock it with `git worktree unlock <path>` if you want it gone. If `git worktree list` fails, the uninstall stops too, even with `--force-worktrees`. The check runs again after you confirm, before anything is removed, so a checkout you edit while the confirmation prompt waits is kept: uninstall exits 2 and removes nothing. Run from inside the gstack install itself, a `.gstack/` that holds its own git repository also stops the uninstall, because deleting the install would take it along. A `.gstack/` that is its own git repository is kept, with a message saying so, because its commits may exist nowhere else. When `.gstack-worktrees` is a symlink, uninstall removes only the link and never follows it, so the folder it points at and the worktrees there are kept.
|
||||
A plain uninstall keeps the git-tracked files under `.gstack/` (such as ship policy) and removes only the ones git ignores. It stops with exit 2, before removing anything, when a checkout under `.gstack-worktrees/` has uncommitted changes, has commits no branch or tag holds, or is not a worktree this repo registered. Pass `--force-worktrees` to delete those checkouts anyway. Every checkout is checked, including a worktree created inside another checkout under `.gstack-worktrees/`. A git submodule inside a clean checkout is deleted with it only when every commit it holds is on a remote-tracking branch; otherwise it stops the uninstall. A locked worktree (`git worktree lock`) always stops the uninstall, even with `--force-worktrees`; unlock it with `git worktree unlock <path>` if you want it gone. If `git worktree list` fails, the uninstall stops too, even with `--force-worktrees`, and so it does on git older than 2.36, which cannot list worktrees with `-z`. The check runs again after you confirm, before anything is removed, so a checkout you edit while the confirmation prompt waits is kept: uninstall exits 2 and removes nothing. Run from inside the gstack install itself, a `.gstack/` that holds its own git repository also stops the uninstall, because deleting the install would take it along. A `.gstack/` that is its own git repository is kept, with a message saying so, because its commits may exist nowhere else. When `.gstack-worktrees` is a symlink, uninstall removes only the link and never follows it, so the folder it points at and the worktrees there are kept.
|
||||
|
||||
Before it deletes anything, uninstall also checks every folder it is about to delete: each host's gstack install (global and project-local), the gstack-made Claude per-skill folders, and `~/.gstack`. If any git checkout inside one of them has uncommitted changes, has commits that neither a remote-tracking branch nor a fetch from one of its remotes (`FETCH_HEAD`, where a stable-channel upgrade lands) holds, or has a locked worktree, the uninstall stops with exit 2 and names the checkout. So does a folder that holds something this run keeps: with `--keep-state`, an install that contains `~/.gstack`, your state root, or the current repo's `.gstack/` or `.gstack-worktrees/` (for example, when you run it from inside the gstack checkout) is refused rather than deleted with them. Push or move the work, or run uninstall from outside the install, then re-run it ([troubleshooting](troubleshooting.md#uninstall)).
|
||||
|
||||
## Option 2: Manual removal (no local repo)
|
||||
|
||||
If you don't have the repo cloned (e.g. you installed via a Claude Code paste and later deleted the clone):
|
||||
|
||||
```bash
|
||||
# 0. A delete that stops for unsaved git work: each folder goes only when no git
|
||||
# checkout inside it has uncommitted changes, commits that neither a
|
||||
# remote-tracking branch nor a fetch from one of its remotes holds, or a
|
||||
# locked worktree. A symlink is only unlinked.
|
||||
# The FETCH_HEAD commits a fetch from one of checkout $1's remotes wrote.
|
||||
gstack_remote_fetch_heads() (
|
||||
cd "$1" || exit 0
|
||||
git remote | while IFS= read -r n; do git remote get-url --all "$n"; done |
|
||||
sed -E 's#^([a-z+]+://)?[^@/]+@#\1#; s#/+$##; s#\.git$##' |
|
||||
awk 'NR == FNR { u[$0]; next } { n = split($0, a, " of "); if (n > 1 && (a[n] in u)) print $1 }' - "$(git rev-parse --git-path FETCH_HEAD)" 2>/dev/null
|
||||
)
|
||||
gstack_rm_checked() {
|
||||
local d g r s ok
|
||||
for d in "$@"; do
|
||||
if [ -L "$d" ]; then rm -f -- "$d"; continue; fi
|
||||
[ -d "$d" ] || continue
|
||||
ok=1
|
||||
while IFS= read -r -d '' g; do
|
||||
r=${g%/.git}
|
||||
s=$(git -C "$r" status --porcelain --untracked-files=all) && [ -z "$s" ] &&
|
||||
s=$(git -C "$r" rev-list -n 1 --all --not --remotes $(gstack_remote_fetch_heads "$r")) && [ -z "$s" ] &&
|
||||
git -C "$r" worktree list --porcelain -z >/dev/null 2>&1 &&
|
||||
! git -C "$r" worktree list --porcelain -z | tr '\0' '\n' | grep -q '^locked' ||
|
||||
{ echo "kept $d: $r has uncommitted changes, unpushed commits or a locked worktree" >&2; ok=0; break; }
|
||||
done < <(find "$d" -name .git -prune -print0 2>/dev/null)
|
||||
[ "$ok" = 1 ] && rm -rf -- "$d"
|
||||
done
|
||||
return 0
|
||||
}
|
||||
|
||||
# 1. Stop browse daemons
|
||||
pkill -f "gstack.*browse" 2>/dev/null || true
|
||||
|
||||
@@ -42,27 +74,27 @@ done
|
||||
rm -rf ~/.claude/skills/_gstack-command ~/.claude/skills/connect-chrome 2>/dev/null
|
||||
|
||||
# 3. Remove gstack
|
||||
rm -rf ~/.claude/skills/gstack
|
||||
gstack_rm_checked ~/.claude/skills/gstack
|
||||
|
||||
# 4. Remove global state
|
||||
rm -rf ~/.gstack
|
||||
gstack_rm_checked ~/.gstack
|
||||
|
||||
# 5. Remove integrations (skip any you never installed)
|
||||
rm -rf "${CODEX_HOME:-$HOME/.codex}/skills/gstack"* 2>/dev/null
|
||||
rm -rf ~/.factory/skills/gstack* 2>/dev/null
|
||||
rm -rf ~/.kiro/skills/gstack* 2>/dev/null
|
||||
rm -rf "${OPENCLAW_STATE_DIR:-$HOME/.openclaw}/skills/gstack"* "${OPENCLAW_STATE_DIR:-$HOME/.openclaw}/skills/.gstack-skills" 2>/dev/null
|
||||
rm -rf "${HERMES_HOME:-$HOME/.hermes}/skills/gstack" 2>/dev/null
|
||||
rm -rf ~/.cursor/skills/gstack* 2>/dev/null
|
||||
rm -rf ~/.config/opencode/skills/gstack* 2>/dev/null
|
||||
rm -rf ~/.gemini/antigravity-cli/skills/gstack* 2>/dev/null
|
||||
rm -rf ~/.gemini/skills/gstack* 2>/dev/null
|
||||
gstack_rm_checked "${CODEX_HOME:-$HOME/.codex}/skills/gstack"*
|
||||
gstack_rm_checked ~/.factory/skills/gstack*
|
||||
gstack_rm_checked ~/.kiro/skills/gstack*
|
||||
gstack_rm_checked "${OPENCLAW_STATE_DIR:-$HOME/.openclaw}/skills/gstack"* "${OPENCLAW_STATE_DIR:-$HOME/.openclaw}/skills/.gstack-skills"
|
||||
gstack_rm_checked "${HERMES_HOME:-$HOME/.hermes}/skills/gstack"
|
||||
gstack_rm_checked ~/.cursor/skills/gstack*
|
||||
gstack_rm_checked ~/.config/opencode/skills/gstack*
|
||||
gstack_rm_checked ~/.gemini/antigravity-cli/skills/gstack*
|
||||
gstack_rm_checked ~/.gemini/skills/gstack*
|
||||
|
||||
# 6. Remove temp files
|
||||
rm -f /tmp/gstack-* 2>/dev/null
|
||||
|
||||
# 7. Per-project cleanup (run from each project root)
|
||||
rm -rf .claude/skills/gstack .agents/skills/gstack* .factory/skills/gstack* 2>/dev/null
|
||||
# 7. Per-project cleanup (run from each project root; uses gstack_rm_checked from step 0)
|
||||
gstack_rm_checked .claude/skills/gstack .agents/skills/gstack* .factory/skills/gstack*
|
||||
# Browse state: removes only the files git ignores, so tracked files such as
|
||||
# ship policy, and a .gstack/ that is its own git repo, stay.
|
||||
git clean -fdXq -- .gstack 2>/dev/null
|
||||
@@ -84,7 +116,8 @@ done
|
||||
find .gstack-worktrees -depth -type d -empty -delete 2>/dev/null || true
|
||||
```
|
||||
|
||||
Never `rm -rf .gstack-worktrees` by hand: a checkout there can hold uncommitted
|
||||
Anything `gstack_rm_checked` prints as kept still holds work: push or move it,
|
||||
then run that step again. Never `rm -rf .gstack-worktrees` by hand: a checkout there can hold uncommitted
|
||||
work, commits no branch has, or another worktree nested inside it, and `rm -rf`
|
||||
deletes all of it. Anything the loop above keeps, check and remove yourself.
|
||||
|
||||
|
||||
Reference in new issue
Block a user