diff --git a/bin/gstack-uninstall b/bin/gstack-uninstall index 7b35678d4..f88e66e00 100755 --- a/bin/gstack-uninstall +++ b/bin/gstack-uninstall @@ -156,17 +156,39 @@ _project_worktrees() { printf '%s\0' "${_dotgit%/.git}" done } -# 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 | 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 +# Fills _REGISTERED_WORKTREES with the real paths of the worktrees this +# repository registered and _LOCKED_WORKTREES with the locked ones. The list is +# read NUL-separated, so a path holding a newline stays one unquoted path. +_read_registered_worktrees() { + local _field _path="" _locked=0 + _REGISTERED_WORKTREES=() + _LOCKED_WORKTREES=() + while IFS= read -r -d '' _field; do + case "$_field" in + "worktree "*) _path="$(_real "${_field#worktree }")"; _locked=0 ;; + locked|"locked "*) _locked=1 ;; + "") + [ -n "$_path" ] || continue + _REGISTERED_WORKTREES+=("$_path") + [ "$_locked" -eq 0 ] || _LOCKED_WORKTREES+=("$_path") + _path="" + ;; + esac + done < <(_worktree_list git -C "$_GIT_ROOT") } -# git inside a registered checkout, with no config able to run a command. +# "$@" worktree list --porcelain, NUL-separated. git before 2.36 has no -z and +# fails here, so the uninstall refuses instead of reading a list it cannot split. +_worktree_list() { + "$@" worktree list --porcelain -z 2>/dev/null +} +# _in_list VALUE ITEM... — VALUE equals one of the ITEMs. +_in_list() { + local _want="$1" _item + shift + for _item in "$@"; do [ "$_item" = "$_want" ] && return 0; done + return 1 +} +# git inside a checkout, with its hooks and fsmonitor turned off. _wt_git() { local _dir="$1" shift @@ -183,7 +205,7 @@ _worktree_risk() { ""|*" "*) echo "it is not a registered worktree of this repository"; return 0 ;; esac - if ! printf '%s\n' "$_REGISTERED_WORKTREES" | grep -Fxq -- "$_dir"; then + if ! _in_list "$_dir" ${_REGISTERED_WORKTREES[@]+"${_REGISTERED_WORKTREES[@]}"}; 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" @@ -195,20 +217,18 @@ _worktree_risk() { _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" } -# 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. +# Succeeds when checkout $1 is a submodule of the closest checkout under +# .gstack-worktrees that holds it, and that checkout is a registered worktree or +# (for a nested submodule) itself such a submodule: its 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 + local _wt _parent="" _rel _entry + for _wt in ${_CHECKOUTS_ON_DISK[@]+"${_CHECKOUTS_ON_DISK[@]}"}; do case "$1" in "$_wt"/*) [ ${#_wt} -gt ${#_parent} ] && _parent="$_wt" ;; esac - done </dev/null | tr '\0' '\n')" case "$_entry" in 160000\ *) ;; *) return 1 ;; esac @@ -219,15 +239,18 @@ EOF # is kept even with --force-worktrees. _worktree_refusals() { local _wt _risk - if ! git -C "$_GIT_ROOT" worktree list --porcelain >/dev/null 2>&1; then + if ! _worktree_list git -C "$_GIT_ROOT" >/dev/null; 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)" - _LOCKED_WORKTREES="$(_registered_worktrees locked)" + _read_registered_worktrees + _CHECKOUTS_ON_DISK=() + while IFS= read -r -d '' _wt; do + _CHECKOUTS_ON_DISK+=("$(_real "$_wt")") + done < <(_project_worktrees) while IFS= read -r -d '' _wt; do [ -n "$_wt" ] || continue - if [ -n "$_LOCKED_WORKTREES" ] && printf '%s\n' "$_LOCKED_WORKTREES" | grep -Fxq -- "$(_real "$_wt")"; then + if _in_list "$(_real "$_wt")" ${_LOCKED_WORKTREES[@]+"${_LOCKED_WORKTREES[@]}"}; then _risk="it is locked (git worktree lock)" elif [ "$FORCE_WORKTREES" -eq 1 ]; then continue @@ -272,7 +295,190 @@ _check_project_keep() { exit 2 fi } +# ─── Deletion inventory ────────────────────────────────────── +CLAUDE_SKILLS="$HOME/.claude/skills" + +# Skill-name inventory (#2563 gate a): every name setup could have installed — +# each source skill's directory name, its frontmatter name, their gstack- +# prefixed variants, and the alias dirs. Built BEFORE the install root is +# removed. A real directory in ~/.claude/skills is only deletable when its +# name is in this inventory AND its SKILL.md carries the generated banner. +# The seed names below are the alias dirs setup's _install_alias_skill_md +# creates (setup: link_claude_root_skill_alias + the connect-chrome call +# sites) — keep in sync with setup if an alias is added or renamed there. +_INVENTORY=" _gstack-command connect-chrome gstack-connect-chrome " +if [ -d "$GSTACK_DIR" ]; then + for _SRC in "$GSTACK_DIR"/*/; do + [ -f "$_SRC/SKILL.md" ] || continue + _SRC_NAME=${_SRC%/}; _SRC_NAME=${_SRC_NAME##*/} + gstack_skill_md_name "$_SRC/SKILL.md"; _FM_NAME=$GSTACK_SKILL_NAME + for _N in "$_SRC_NAME" "$_FM_NAME"; do + [ -n "$_N" ] || continue + case "$_INVENTORY" in *" $_N "*) ;; *) _INVENTORY="$_INVENTORY$_N gstack-$_N " ;; esac + done + done +fi +_in_skill_inventory() { case "$_INVENTORY" in *" $1 "*) return 0 ;; *) return 1 ;; esac; } + +# Prints what the removal step does with ~/.claude/skills entry $1: unlink, +# delete, keep (listed, not provably gstack-managed), or nothing. Per-skill +# entries created by setup come in three shapes: +# 1. symlink entry (oldest installs) +# 2. real dir + SYMLINKED SKILL.md (standard Unix install) +# 3. real dir + REAL-FILE SKILL.md (Windows copy install, #2563) +# Shape 3 was skipped entirely — gstack-uninstall exited 0 and reported +# success while leaving ~52 gstack-* directories behind on Windows. +_claude_entry_action() { + local _name="${1##*/}" _target + [ "$_name" = "gstack" ] && return 0 + if [ -L "$1" ]; then + _target="$(readlink "$1" 2>/dev/null || true)" + case "$_target" in gstack/*|*/gstack/*) echo unlink ;; esac + elif [ -d "$1" ] && { [ -f "$1/SKILL.md" ] || [ -L "$1/SKILL.md" ]; }; then + if [ -L "$1/SKILL.md" ]; then + # Shape 2: provenance readable from the symlink target itself. + # Gate 1: the name must be in gstack's skill inventory (parity with + # shape 3). Gate 2: the target must contain "gstack" as an ANCHORED + # path segment (gstack/*|*/gstack/*, same pattern as shape 1) — a + # bare *gstack* substring match would wipe a user's own skill whose + # SKILL.md merely lives under e.g. ~/tools/gstack-fork/. + _target="$(readlink "$1/SKILL.md" 2>/dev/null || true)" + if _in_skill_inventory "$_name"; then + case "$_target" in gstack/*|*/gstack/*) echo delete ;; *) echo keep ;; esac + else + echo keep + fi + elif _in_skill_inventory "$_name" && grep -q '\n'); + fs.writeFileSync(path.join(skill, 'SKILL.md'), '\nmy edit\n'); + const r = uninstall(['--force']); + expect(r.status).toBe(2); + expect(r.stderr).toContain(`refusing to delete ${skill}: ${skill} has uncommitted changes`); + expect(fs.readFileSync(path.join(skill, 'SKILL.md'), 'utf-8')).toContain('my edit'); + }); + + test('a repository with unpushed commits under ~/.gstack stops the uninstall', () => { + const notes = pushedRepo(path.join(home, '.gstack', 'projects', 'notes'), 'README.md'); + unpushedCommit(notes); + const r = uninstall(['--force']); + expect(r.status).toBe(2); + expect(r.stderr).toContain(`refusing to delete ${path.join(home, '.gstack')}: ${notes} has commits no remote-tracking branch contains`); + expect(fs.existsSync(path.join(notes, 'notes.md'))).toBe(true); + expect(fs.existsSync(install)).toBe(true); + }); + + test('a locked worktree of the install clone stops the uninstall, even outside the install', () => { + const lane = path.join(tmp, 'lane'); + git(['worktree', 'add', '-q', '--detach', lane, 'HEAD'], install); + git(['worktree', 'lock', lane], install); + const r = uninstall(['--force']); + expect(r.status).toBe(2); + expect(r.stderr).toContain(`refusing to delete ${install}: ${install} registers the locked worktree ${lane}`); + expect(fs.existsSync(path.join(install, '.git'))).toBe(true); + }); + + test('a dirty repo behind a .gstack-worktrees symlink into ~/.gstack stops the uninstall', () => { + const lane = pushedRepo(path.join(home, '.gstack', 'lanes', 'lane1')); + fs.writeFileSync(path.join(lane, 'SKILL.md'), 'my edit\n'); + fs.symlinkSync(path.join(home, '.gstack', 'lanes'), path.join(proj, '.gstack-worktrees')); + const r = uninstall(['--force']); + expect(r.status).toBe(2); + expect(r.stderr).toContain(`refusing to delete ${path.join(home, '.gstack')}: ${lane} has uncommitted changes`); + expect(fs.readFileSync(path.join(lane, 'SKILL.md'), 'utf-8')).toBe('my edit\n'); + }); + + test('a shallow stable-channel install advanced by git fetch origin stable is removed', () => { + fs.rmSync(install, { recursive: true, force: true }); + const origin = path.join(tmp, 'channel-origin.git'); + const seed = pushedRepo(path.join(tmp, 'channel-seed')); + git(['remote', 'set-url', 'origin', origin], seed); + git(['init', '-q', '--bare', '-b', 'main', origin], tmp); + git(['push', '-q', 'origin', 'main'], seed); + git(['clone', '-q', '--single-branch', '--depth', '1', `file://${origin}`, install], tmp); + fs.writeFileSync(path.join(seed, 'SKILL.md'), 'release\n'); + git(['commit', '-q', '-am', 'release'], seed); + git(['push', '-q', 'origin', 'main', 'main:stable'], seed); + git(['fetch', '-q', 'origin', 'stable'], install); + git(['merge', '-q', '--ff-only', 'FETCH_HEAD'], install); + const r = uninstall(['--force']); + expect(r.stderr).toBe(''); + expect(r.status).toBe(0); + expect(fs.existsSync(install)).toBe(false); + }); + + test('a stable-channel install whose remote URL goes through url.insteadOf is removed', () => { + fs.rmSync(install, { recursive: true, force: true }); + const origin = path.join(tmp, 'channel-origin.git'); + const seed = pushedRepo(path.join(tmp, 'channel-seed')); + git(['remote', 'set-url', 'origin', origin], seed); + git(['init', '-q', '--bare', '-b', 'main', origin], tmp); + git(['push', '-q', 'origin', 'main'], seed); + fs.writeFileSync(path.join(tmp, 'gitconfig'), `[url "file://${tmp}/"]\n\tinsteadOf = alias:\n`); + git(['clone', '-q', '--single-branch', '--depth', '1', 'alias:channel-origin.git', install], tmp); + fs.writeFileSync(path.join(seed, 'SKILL.md'), 'release\n'); + git(['commit', '-q', '-am', 'release'], seed); + git(['push', '-q', 'origin', 'main', 'main:stable'], seed); + git(['fetch', '-q', 'origin', 'stable'], install); + git(['merge', '-q', '--ff-only', 'FETCH_HEAD'], install); + const r = uninstall(['--force']); + expect(r.stderr).toBe(''); + expect(r.status).toBe(0); + expect(fs.existsSync(install)).toBe(false); + }); + + test('a local-only commit that git fetch . HEAD wrote into FETCH_HEAD still stops the uninstall', () => { + unpushedCommit(install); + git(['fetch', '-q', '.', 'HEAD'], install); + const r = uninstall(['--force']); + expect(r.status).toBe(2); + expect(r.stderr).toContain(`refusing to delete ${install}: ${install} has commits no remote-tracking branch contains`); + expect(fs.existsSync(path.join(install, 'notes.md'))).toBe(true); + }); + + test('on a git older than 2.36 (no worktree list -z) the uninstall refuses and keeps the install', () => { + const lane = path.join(tmp, 'lane'); + git(['worktree', 'add', '-q', '--detach', lane, 'HEAD'], install); + git(['worktree', 'lock', lane], install); + const shim = path.join(tmp, 'oldgit'); + fs.mkdirSync(shim); + fs.writeFileSync(path.join(shim, 'git'), `#!/bin/bash\ncase " $* " in *" worktree list "*" -z "*) echo "error: unknown switch z" >&2; exit 129 ;; esac\nexec ${JSON.stringify(Bun.which('git'))} "$@"\n`, { mode: 0o755 }); + const r = spawnSync('bash', [UNINSTALL, '--force'], { + cwd: proj, encoding: 'utf-8', timeout: 30_000, + env: { ...gitEnv, PATH: `${shim}:${gitEnv.PATH}`, 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(`refusing to delete ${install}: ${install} has worktrees git cannot list (gstack-uninstall needs git 2.36 or later)`); + expect(fs.existsSync(path.join(install, '.git'))).toBe(true); + }, 30_000); +}); + +describe('locked worktrees are found by NUL-separated paths (GST-2303)', () => { + 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', '-c', 'protocol.file.allow=always', ...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: '' }, + }); + beforeEach(() => { + tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-uninstall-lock-'))); + 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(repo, { 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 })); + + test('a locked worktree whose path holds a newline survives --force --force-worktrees', () => { + const lane = path.join(repo, '.gstack-worktrees', 'run', 'lane\nx'); + git(['worktree', 'add', '-q', '--detach', lane, 'HEAD']); + git(['worktree', 'lock', lane]); + const r = uninstall(['--force', '--force-worktrees']); + expect(r.status).toBe(2); + expect(r.stderr).toContain('it is locked (git worktree lock)'); + expect(fs.existsSync(path.join(lane, 'README.md'))).toBe(true); + }); + + test('a clean submodule nested inside a clean submodule of a clean lane does not stop the uninstall', () => { + const inner = path.join(tmp, 'inner'); + fs.mkdirSync(inner); + git(['init', '-q', '-b', 'main'], inner); + fs.writeFileSync(path.join(inner, 'a.txt'), 'a\n'); + git(['add', 'a.txt'], inner); + git(['commit', '-q', '-m', 'inner'], inner); + const lib = path.join(tmp, 'lib'); + fs.mkdirSync(lib); + git(['init', '-q', '-b', 'main'], lib); + git(['submodule', 'add', '-q', inner, 'deps/inner'], lib); + git(['commit', '-q', '-m', 'lib'], lib); + git(['submodule', 'add', '-q', lib, 'vendor/lib']); + git(['commit', '-q', '-m', 'submodule']); + const lane = path.join(repo, '.gstack-worktrees', 'run', 'lane'); + git(['worktree', 'add', '-q', '--detach', lane, 'HEAD']); + git(['submodule', 'update', '-q', '--init', '--recursive'], lane); + expect(fs.existsSync(path.join(lane, 'vendor', 'lib', 'deps', 'inner', 'a.txt'))).toBe(true); + const r = uninstall(['--force']); + expect(r.stderr).toBe(''); + expect(r.status).toBe(0); + expect(fs.existsSync(lane)).toBe(false); + }); +}); + +describe('the manual removal in docs/uninstall.md checks each folder before deleting it (GST-2303)', () => { + let tmp: string, gitEnv: Record; + const git = (args: string[], cwd: string) => { + 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); + }; + const checkedDelete = (dirs: string[]) => { + const doc = fs.readFileSync(path.join(ROOT, 'docs', 'uninstall.md'), 'utf-8'); + const start = doc.indexOf('# 0. A delete that stops for unsaved git work'); + expect(start).toBeGreaterThan(-1); + const helper = doc.slice(start, doc.indexOf('# 1. Stop browse daemons', start)); + return spawnSync('bash', ['-c', `${helper}\ngstack_rm_checked "$@"`, 'doc', ...dirs], { cwd: tmp, encoding: 'utf-8', timeout: 30_000, env: gitEnv }); + }; + beforeEach(() => { + tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-uninstall-doc-'))); + fs.writeFileSync(path.join(tmp, 'gitconfig'), ''); + gitEnv = { ...process.env, GIT_CONFIG_GLOBAL: path.join(tmp, 'gitconfig'), GIT_CONFIG_NOSYSTEM: '1' } as Record; + }); + afterEach(() => fs.rmSync(tmp, { recursive: true, force: true })); + + test('a folder holding a repo with an unpushed commit is kept, and a plain folder goes', () => { + const install = path.join(tmp, 'install'); + const notes = path.join(install, '.gstack'); + fs.mkdirSync(notes, { recursive: true }); + fs.writeFileSync(path.join(install, 'SKILL.md'), 'test\n'); + git(['init', '-q', '-b', 'main'], notes); + fs.writeFileSync(path.join(notes, 'notes.md'), 'mine\n'); + git(['add', 'notes.md'], notes); + git(['commit', '-q', '-m', 'unpushed notes'], notes); + const plain = path.join(tmp, 'plain'); + fs.mkdirSync(plain); + fs.writeFileSync(path.join(plain, 'SKILL.md'), 'test\n'); + const r = checkedDelete([install, plain]); + expect(r.status).toBe(0); + expect(r.stderr).toContain(`kept ${install}: ${notes} has uncommitted changes, unpushed commits or a locked worktree`); + expect(fs.existsSync(path.join(notes, 'notes.md'))).toBe(true); + expect(fs.existsSync(plain)).toBe(false); + }); + + test('a local-only commit that git fetch . HEAD wrote into FETCH_HEAD keeps its folder', () => { + const repo = path.join(tmp, 'repo'); + fs.mkdirSync(repo); + git(['init', '-q', '-b', 'main'], repo); + fs.writeFileSync(path.join(repo, 'notes.md'), 'mine\n'); + git(['add', 'notes.md'], repo); + git(['commit', '-q', '-m', 'local notes'], repo); + git(['fetch', '-q', '.', 'HEAD'], repo); + const r = checkedDelete([repo]); + expect(r.stderr).toContain(`kept ${repo}: ${repo} has uncommitted changes, unpushed commits or a locked worktree`); + expect(fs.existsSync(path.join(repo, 'notes.md'))).toBe(true); + }); +}); +