From fdd5169a34215b4839112fd88f54b5bdf751ee0d Mon Sep 17 00:00:00 2001 From: garrytan Date: Sat, 31 Oct 2026 18:17:57 +0000 Subject: [PATCH] fix(upgrade): refuse an install whose enclosing project tracks its files (#1022) A project that commits gstack's files as plain blobs puts the old ones back on every checkout of its own, so VERSION froze behind HEAD while gstack-upgrade reported the install current. bin/gstack-ff-update, the one move the auto-updater and /gstack-upgrade share, now refuses with exit 6 (nothing changed) when the enclosing repository tracks non-gitlink paths under the install, or when VERSION differs from HEAD (eol-normalized). gstack-upgrade runs the same checks through --preflight before any fetch; the auto-updater holds the update with a banner and a troubleshooting anchor. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- bin/gstack-ff-update | 44 ++++++++++++- bin/gstack-session-update | 4 +- bin/gstack-upgrade | 8 ++- docs/troubleshooting.md | 20 ++++++ test/gstack-ff-update.test.ts | 27 ++++++++ test/gstack-upgrade-bin.test.ts | 82 +++++++++++++++++++++++-- test/session-update-held-banner.test.ts | 36 ++++++++++- 7 files changed, 210 insertions(+), 11 deletions(-) diff --git a/bin/gstack-ff-update b/bin/gstack-ff-update index 877f27672..79b527a93 100755 --- a/bin/gstack-ff-update +++ b/bin/gstack-ff-update @@ -36,7 +36,14 @@ # exit 3 autostash-unrecoverable: (edits kept where they are) # exit 4 not-fast-forward: # exit 5 index-unreadable: +# exit 6 tracked-by-project: / version-reverted: (#1022, the preflight) # exit 75 ff-busy: (another update holds this install's lock) +# +# gstack-ff-update --preflight runs only the exit-6 checks (exit 0 or 6); +# bin/gstack-upgrade calls it before any fetch. A project that tracks the +# install's files as plain blobs puts old ones back on every checkout of its +# own, so VERSION freezes behind HEAD while updates seem to succeed; a +# submodule gitlink is the supported shape and passes. # A rescue also prints the inspect, export and delete commands on stderr. set +e @@ -49,15 +56,46 @@ ff_flatten() { printf '%s' "$1" | tr '\n\r\t' ' ' | sed "s/${_ff_esc}\[[0-9;?]*[ -\/]*[@-~]//g" | tr -d '\000-\037\177' | head -c "${2:-200}" } ff_quote() { printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"; } -refuse() { printf '%s: %s\n' "$2" "$(ff_flatten "$3" 300)"; exit "$1"; } +refuse() { printf '%s: %s\n' "$2" "$(ff_flatten "$3" "${4:-300}")"; exit "$1"; } -[ $# -eq 2 ] && [ -n "$1" ] && [ -n "$2" ] || refuse 2 usage "gstack-ff-update " -DIR=$1 REV=$2 +[ $# -eq 2 ] && [ -n "$1" ] && [ -n "$2" ] || refuse 2 usage "gstack-ff-update | --preflight " +PREFLIGHT=0 +if [ "$1" = --preflight ]; then PREFLIGHT=1 DIR=$2 REV=HEAD; else DIR=$1 REV=$2; fi cd -- "$DIR" 2>/dev/null || refuse 2 not-a-checkout "cannot enter $DIR" DIR=$(pwd -P) g() { git -c core.fsmonitor=false -c core.splitIndex=false "$@"; } [ "$(g rev-parse --is-inside-work-tree 2>/dev/null)" = true ] && [ -z "$(g rev-parse --show-prefix 2>/dev/null)" ] \ || refuse 2 not-a-checkout "$DIR is not the top of a git checkout" + +# ── Preflight (#1022) ── +# The repository enclosing the install's parent, probed with the caller's git +# environment cleared; any git error there means not tracked. +_PF=$( + unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_COMMON_DIR GIT_OBJECT_DIRECTORY + cd -- "$DIR/.." 2>/dev/null || exit 0 + _top=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0 + _rel="$(git rev-parse --show-prefix 2>/dev/null)${DIR##*/}" || exit 0 + _n=0 + while IFS= read -r _l; do + case "$_l" in (''|'160000 '*) ;; (*) _n=$((_n + 1)) ;; esac + done </dev/null) +EOF_PF + [ "$_n" -gt 0 ] && printf '%s\n%s\n%s\n' "$_top" "$_rel" "$_n" +) +if [ -n "$_PF" ]; then + _top=$(printf '%s\n' "$_PF" | sed -n 1p) _rel=$(printf '%s\n' "$_PF" | sed -n 2p) _n=$(printf '%s\n' "$_PF" | sed -n 3p) + refuse 6 tracked-by-project "$_top tracks $_n files of the gstack install at $_rel, so every checkout there puts old gstack files back (VERSION frozen, #1022); nothing was changed; fix: git -C $(ff_quote "$_top") rm -r -q --cached -- $(ff_quote "$_rel"), add /$_rel/ to $(ff_quote "$_top/.gitignore") and commit both on every branch you check out (or vendor gstack as a git submodule), then git -C $(ff_quote "$DIR") stash push -m 'files reverted by the project' (keeps every edit) and re-run" 900 +fi +if g cat-file -e HEAD:VERSION 2>/dev/null; then + g diff --quiet HEAD -- VERSION 2>/dev/null + if [ $? -eq 1 ]; then + _have=$(head -1 VERSION 2>/dev/null | tr -d '\r') _want=$(g show HEAD:VERSION 2>/dev/null | head -1 | tr -d '\r') + refuse 6 version-reverted "VERSION says ${_have:-nothing} but the checkout is at $_want: something outside git put old gstack files back (an enclosing project's checkout, #1022); nothing was changed; fix: git -C $(ff_quote "$DIR") stash push -m 'files reverted by the project' (keeps every edit), then re-run" 900 + fi +fi +[ "$PREFLIGHT" -eq 1 ] && exit 0 + TARGET=$(g rev-parse -q --verify "$REV^{commit}" 2>/dev/null) || refuse 2 target-unresolvable "$REV does not name a commit in $DIR" COMMON=$(g rev-parse --path-format=absolute --git-common-dir 2>/dev/null) \ || COMMON=$(cd -- "$(g rev-parse --git-common-dir 2>/dev/null)" 2>/dev/null && pwd -P) \ diff --git a/bin/gstack-session-update b/bin/gstack-session-update index 4722cf0eb..60ac42c99 100755 --- a/bin/gstack-session-update +++ b/bin/gstack-session-update @@ -246,6 +246,8 @@ case "$_P_PULL" in echo "gstack auto-update: update held ($_P_PULL); nothing was installed or changed, and your current hooks keep running. gstack retries at the next check. Fix now ($_WHERE): $_FF_FIX (setup checks every hook it registers). https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#auto-update-hook-check-unavailable" ;; unmerged-files*) echo "gstack auto-update: update held ($_P_PULL); an earlier merge, rebase or stash pop left conflicts in the install, and an update would bury them. Fix now ($_WHERE): $_FF_FIX (saves every edit, the unmerged files included, in a rescue ref before it updates). https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#auto-update-unmerged-files" ;; + tracked-by-project*|version-reverted*) + echo "gstack auto-update: update held ($_P_PULL); nothing was installed or changed. gstack retries at the next check. The full fix: bash $_Q/bin/gstack-ff-update --preflight $_Q. https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#auto-update-tracked-by-project" ;; autostash-unrecoverable*) echo "gstack auto-update: update held ($_P_PULL); your local edits are where they were and nothing was installed. Look first: git -C $_Q status --short. Fix now ($_WHERE): $_FF_FIX (once the cause above is gone). https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#auto-update-autostash-unrecoverable" ;; *) @@ -500,7 +502,7 @@ CALLER_PID=$(gstack_harness_pid "$PPID") log_entry "AUTOSTASH_CONFLICT_RECOVERED tree_reset=1 kept_stash=${4##*/} kept_ref=$4 $5" rescue_note "$4" "${5#unmerged=}" ;; 0:*) ;; - 3:*) HELD=$FF_OUT PULL_EXIT=0 ;; + 3:*|6:*) HELD=$FF_OUT PULL_EXIT=0 ;; 75:*) log_entry "SKIP ff_busy $(ff_flatten "$FF_OUT" 200)"; rm -f "$PULL_ERR_FILE" 2>/dev/null; exit 0 ;; *) printf '%s\n' "${FF_OUT:-bin/gstack-ff-update exited $PULL_EXIT}" > "${PULL_ERR_FILE:-/dev/null}" ;; esac diff --git a/bin/gstack-upgrade b/bin/gstack-upgrade index c658f175b..1849b1b02 100755 --- a/bin/gstack-upgrade +++ b/bin/gstack-upgrade @@ -21,7 +21,9 @@ # is not a gstack checkout, HEAD is detached or not on the remote default # branch (a pin), any tracked byte is dirty, a local commit diverged, the # incoming release's Bun floor holds, or another updater holds the setup -# lock; never stashes, never resets. An unreachable remote is +# lock; never stashes, never resets. Before any fetch it also refuses when +# bin/gstack-ff-update --preflight does: an enclosing project tracks the +# install's files, or VERSION was put back behind HEAD (#1022). An unreachable remote is # UPGRADE_FETCH_FAILED (exit 3). While another live session holds a workflow # lease on this installation (bin/gstack-lease live) it refuses with # UPGRADE_BUSY (exit 3); GSTACK_UPGRADE_FORCE=1 overrides. Prints @@ -50,7 +52,7 @@ case "${1:-}" in --check) CHECK=1 ;; --ff-only-no-reinstall) REINSTALL=0 ;; '') ;; - -h|--help) sed -n '2,38p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + -h|--help) sed -n '2,41p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; *) echo "gstack-upgrade: unknown argument '$1'" >&2; exit 2 ;; esac @@ -71,6 +73,8 @@ bounded() { if command -v timeout >/dev/null 2>&1; then timeout "${GSTACK_UPGRAD TOPLEVEL=$(git -C "$ROOT" rev-parse --show-toplevel 2>/dev/null) [ -f "$ROOT/VERSION" ] && [ -f "$ROOT/setup" ] && [ -n "$TOPLEVEL" ] && gstack_path_same "$TOPLEVEL" "$ROOT" \ || refuse "$ROOT is not a gstack checkout" "run gstack-upgrade from a git checkout of https://github.com/garrytan/gstack" +PF=$(bash "$ROOT/bin/gstack-ff-update" --preflight "$ROOT" 2>/dev/null) +[ $? -eq 6 ] && { echo "gstack-upgrade: $PF (UPGRADE_REFUSED)" >&2; exit 3; } export GIT_TERMINAL_PROMPT=0 HOST=$(git -C "$ROOT" remote get-url origin 2>/dev/null); HOST="${HOST#*://}"; HOST="${HOST#*@}"; HOST="${HOST%%[/:]*}" R="user ran gstack-upgrade" diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index a8c51edff..26dc3760c 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1109,6 +1109,26 @@ conflict yourself instead, finish or abort your merge (`git -C '' merge --abort`, `git -C '' checkout -- `); gstack retries at the next check. + +### `gstack auto-update: update held (tracked-by-project: ...)` / `(version-reverted: ...)` / `gstack-upgrade: tracked-by-project: ... (UPGRADE_REFUSED)` + +**Meaning.** Your gstack install sits inside another git project, and that +project has committed gstack's files as ordinary files. Every time you switch +branches or check out files in that project, git puts its old copies of +gstack's files back, so VERSION and other files fall behind the install's own +HEAD while updates seem to succeed (#1022). `version-reverted` is the same +symptom seen from the install: its VERSION disagrees with HEAD. Neither +`gstack-upgrade`, `/gstack-upgrade` nor the auto-updater moves the install +until it is fixed. A project that holds gstack as a git submodule is fine. + +**Fix.** Print the exact commands with +`bash /bin/gstack-ff-update --preflight `. They do three +things: untrack the install in the project (`git rm -r --cached`), add it to +the project's `.gitignore`, and commit both on every branch you check out +(or vendor gstack as a submodule instead). Then +`git -C stash push` puts the install's own files back and keeps +any edit you made in the stash, and the next update proceeds. + ### `gstack auto-update: update held (autostash-unrecoverable: ); your local edits are where they were and nothing was installed. Look first: git -C '' status --short. Fix now (...)` / `autostash-unrecoverable: ` diff --git a/test/gstack-ff-update.test.ts b/test/gstack-ff-update.test.ts index d6910abf4..78a6e1bbc 100644 --- a/test/gstack-ff-update.test.ts +++ b/test/gstack-ff-update.test.ts @@ -264,6 +264,33 @@ describe.skipIf(process.platform === 'win32')('gstack-ff-update: moving the tree expect(git(fx.install, 'log', '-1', '--format=%an <%ae>|%cn <%ce>', ref)).toBe('gstack rescue |gstack rescue '); }); + // Value: protects=the one move every upgrade path shares refuses (exit 6, nothing changed) when an enclosing project tracks the install or + // VERSION was put back behind HEAD, even with nothing to move (#1022); fails_when=the preflight is skipped, runs after "already", or counts a gitlink; + // why_new=no case had an enclosing project; seam=none + test('preflight: a tracking project or a reverted VERSION refuses with exit 6 and changes nothing; a gitlink and --preflight on a clean install pass', () => { + const fx = makeFixture(); + const old = git(fx.install, 'rev-parse', 'HEAD'); + expect(spawnSync('bash', [FF, '--preflight', fx.install], { encoding: 'utf8', env: isolatedEnv(fx.base), timeout: 60_000 }).status).toBe(0); + git(fx.base, 'init', '-q', '-b', 'main'); + git(fx.base, 'update-index', '--add', '--cacheinfo', `160000,${old},install`); + expect(spawnSync('bash', [FF, '--preflight', fx.install], { encoding: 'utf8', env: isolatedEnv(fx.base), timeout: 60_000 }).status).toBe(0); + git(fx.base, 'update-index', '--force-remove', 'install'); + git(fx.base, 'update-index', '--add', '--', 'install/VERSION', 'install/notes.txt'); + release(fx, { 'notes.txt': 'top UPSTREAM\nmiddle\nbottom\n' }); + const tracked = ff(fx); + expect(tracked.status, tracked.stdout + tracked.stderr).toBe(6); + expect(tracked.stdout).toStartWith(`tracked-by-project: ${fx.base} tracks 2 files of the gstack install at install,`); + expect(tracked.stdout.trim().split('\n')).toHaveLength(1); + expect(git(fx.install, 'rev-parse', 'HEAD')).toBe(old); + fs.rmSync(path.join(fx.base, '.git'), { recursive: true, force: true }); + expect(ff(fx).status).toBe(0); + write(fx, 'VERSION', '1.0.0\n'); + const reverted = ff(fx); + expect(reverted.status).toBe(6); + expect(reverted.stdout).toStartWith('version-reverted: VERSION says 1.0.0 but the checkout is at 1.1.0'); + expect(read(fx, 'VERSION')).toBe('1.0.0\n'); + }); + test('refusals: not a fast-forward, an unresolvable target, and a missing checkout', () => { const fx = makeFixture(); write(fx, 'VERSION', 'local\n'); diff --git a/test/gstack-upgrade-bin.test.ts b/test/gstack-upgrade-bin.test.ts index b116d315f..42e7f58c4 100644 --- a/test/gstack-upgrade-bin.test.ts +++ b/test/gstack-upgrade-bin.test.ts @@ -19,7 +19,7 @@ const REPO = path.resolve(import.meta.dir, '..'); const DEPS = [ 'bin/gstack-upgrade', 'bin/gstack-bun-version.sh', 'bin/gstack-paths', 'bin/gstack-state-root.sh', 'bin/gstack-egress-lib.sh', 'bin/gstack-egress-receipt', 'lib/egress-receipt.ts', 'lib/state-root.ts', - 'bin/gstack-lease', 'bin/gstack-harness-pid.sh', 'bin/gstack-install-verdict.sh', + 'bin/gstack-lease', 'bin/gstack-harness-pid.sh', 'bin/gstack-install-verdict.sh', 'bin/gstack-ff-update', ]; const hosts: CapyHost[] = []; const children: ChildProcess[] = []; @@ -40,7 +40,7 @@ const write = (file: string, text: string, mode = 0o644) => { fs.writeFileSync(file, text, { mode }); }; -function fixture() { +function fixture(opts: { parent?: 'files' | 'gitlink' } = {}) { const host = capyHost({ systemBun: '1.4.2' }); hosts.push(host); const git = (args: string[], cwd: string) => { @@ -68,8 +68,18 @@ function fixture() { const a = git(['rev-parse', 'HEAD'], seed); git(['init', '-q', '--bare', '-b', 'main', bare], host.base); git(['push', '-q', bare, 'main'], seed); - const root = path.join(host.base, 'gstack'); + const project = path.join(host.base, 'project'); + const root = opts.parent ? path.join(project, '.claude/skills/gstack') : path.join(host.base, 'gstack'); + fs.mkdirSync(path.dirname(root), { recursive: true }); git(['clone', '-q', '--single-branch', '--depth', '1', `file://${bare}`, root], host.base); + if (opts.parent) { + // #1022: a project that committed the install's files as plain blobs (or, + // the supported shape, as a submodule gitlink) beside the nested .git. + git(['init', '-q', '-b', 'main'], project); + if (opts.parent === 'files') git(['update-index', '--add', '--', ...git(['ls-files'], root).split('\n').map(f => `.claude/skills/gstack/${f}`)], project); + else git(['update-index', '--add', '--cacheinfo', `160000,${a},.claude/skills/gstack`], project); + git(['commit', '-qm', 'vendor gstack'], project); + } write(path.join(seed, 'VERSION'), '1.0.1\n'); git(['commit', '-qam', 'B'], seed); const b = git(['rev-parse', 'HEAD'], seed); @@ -78,7 +88,7 @@ function fixture() { const run = (args: string[] = [], extra: Record = {}) => spawnSync('bash', [path.join(root, 'bin/gstack-upgrade'), ...args], { cwd: host.home, encoding: 'utf8', env: { ...env, ...extra }, timeout: 60_000 }); return { - host, root, bare, a, b, run, git: (args: string[]) => git(args, root), + host, root, bare, a, b, run, project, git: (args: string[]) => git(args, root), parentGit: (args: string[]) => git(args, project), calls: () => fs.existsSync(log) ? fs.readFileSync(log, 'utf8').trim().split('\n').filter(Boolean) : [], }; } @@ -396,6 +406,70 @@ describe('gstack-upgrade', () => { expect(f.calls()).toEqual(['setup --refresh-registered', `doctor --check --require runtime,pins --project ${fs.realpathSync(project)}`]); }); + // Value: protects=an install whose enclosing project tracks its files is refused before it moves, so a project checkout cannot revert VERSION behind an upgraded HEAD (#1022); + // fails_when=the enclosing-project check is dropped or matches a submodule gitlink; why_new=no case had an enclosing repository; seam=none + test('a project that tracks the install\'s files refuses --check and default with UPGRADE_REFUSED naming the untrack fix (#1022)', () => { + const f = fixture({ parent: 'files' }); + const reflog = f.git(['reflog']); + for (const args of [['--check'], []]) { + const r = f.run(args); + expect(r.status, r.stdout + r.stderr).toBe(3); + expect(r.stderr).toContain('(UPGRADE_REFUSED)'); + expect(r.stderr).toContain(`${f.project} tracks`); + expect(r.stderr).toContain(`git -C '${f.project}' rm -r -q --cached -- '.claude/skills/gstack'`); + expect(r.stderr.trim().split('\n').filter(l => l.startsWith('gstack-upgrade: '))).toHaveLength(1); + expect(f.git(['rev-parse', 'HEAD'])).toBe(f.a); + expect(f.git(['reflog'])).toBe(reflog); + } + expect(f.calls()).toEqual([]); + f.parentGit(['rm', '-r', '-q', '--cached', '--', '.claude/skills/gstack']); + f.git(['stash', 'push', '-m', 'files reverted by the project']); + const fixed = f.run(); + expect(fixed.status, fixed.stdout + fixed.stderr).toBe(0); + expect(fixed.stdout).toContain('gstack-upgrade: upgraded 1.0.0 -> 1.0.1'); + }); + + // Value: protects=the supported vendoring shape (a submodule gitlink) still upgrades; fails_when=the #1022 check counts a gitlink as tracked files; + // why_new=negative control for the case above; seam=none + test('a project that holds the install as a submodule gitlink upgrades normally', () => { + const f = fixture({ parent: 'gitlink' }); + const r = f.run(); + expect(r.status, r.stdout + r.stderr).toBe(0); + expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b); + }); + + // Value: protects=a working-tree VERSION that disagrees with HEAD is never reported current (the frozen VERSION of #1022); + // fails_when=the current path trusts the VERSION file without comparing it to HEAD; why_new=every current case had a clean tree; seam=none + test('a VERSION reverted behind HEAD is not current: --check and default refuse naming both versions (#1022)', () => { + const f = fixture(); + expect(f.run(['--ff-only-no-reinstall']).status).toBe(0); + fs.writeFileSync(path.join(f.root, 'VERSION'), '1.0.0\n'); + for (const args of [['--check'], []]) { + const r = f.run(args); + expect(r.status, r.stdout + r.stderr).toBe(3); + expect(r.stdout).not.toContain('gstack-upgrade: current'); + expect(r.stderr).toContain('VERSION says 1.0.0 but the checkout is at 1.0.1'); + expect(r.stderr).toContain('(UPGRADE_REFUSED)'); + } + expect(f.calls()).toEqual([]); + f.git(['stash', 'push', '-q', '-m', 'files reverted by the project']); + const fixed = f.run(['--check']); + expect(fixed.status, fixed.stdout + fixed.stderr).toBe(0); + expect(fixed.stdout).toContain('gstack-upgrade: current 1.0.1'); + }); + + // Value: protects=a Windows checkout with core.autocrlf (VERSION written CRLF) is not mistaken for a reverted VERSION; + // fails_when=the VERSION check compares raw bytes instead of git's eol-normalized diff; why_new=the check is new; seam=none + test('a CRLF VERSION under core.autocrlf=true is current, not reverted', () => { + const f = fixture(); + expect(f.run(['--ff-only-no-reinstall']).status).toBe(0); + f.git(['config', 'core.autocrlf', 'true']); + fs.writeFileSync(path.join(f.root, 'VERSION'), '1.0.1\r\n'); + const r = f.run(['--check']); + expect(r.status, r.stdout + r.stderr).toBe(0); + expect(r.stdout).toContain('gstack-upgrade: current 1.0.1'); + }); + test('every bin a trailer or fix line names exists in the repo', () => { const sources = ['bin/gstack-capy-install', 'bin/gstack-doctor-check.sh', 'bin/gstack-skill-end', 'bin/gstack-upgrade', 'docs/capy-install.md', 'capy/skill/gstack/SKILL.md', 'capy/skill/gstack/scripts/install.sh']; diff --git a/test/session-update-held-banner.test.ts b/test/session-update-held-banner.test.ts index c33fe8e4f..58432a144 100644 --- a/test/session-update-held-banner.test.ts +++ b/test/session-update-held-banner.test.ts @@ -166,10 +166,44 @@ describe.skipIf(process.platform === 'win32')('gstack-session-update: held-updat expect(git(fx.install, 'rev-parse', 'HEAD')).toBe(target); }); + // Value: protects=the auto-updater holds, instead of rescuing and re-updating forever, an install whose enclosing project tracks its files (#1022); + // fails_when=gstack-ff-update's exit 6 is not a hold, or the banner's command does not print the full fix; why_new=no case had an enclosing project; seam=none + test('tracked-by-project: the update holds with HEAD unchanged; the banner names the preflight, which prints the full fix', async () => { + const fx = makeFixture(); + const old = git(fx.install, 'rev-parse', 'HEAD'); + release(fx, 'top UPSTREAM\nmiddle\nbottom\n'); + git(fx.base, 'init', '-q', '-b', 'main'); + git(fx.base, 'update-index', '--add', '--', ...git(fx.install, 'ls-files').split('\n').map((f) => `it's an install/${f}`)); + spawnSync('bash', [SCRIPT], { encoding: 'utf8', env: env(fx), timeout: 20_000 }); + await waitFor(fx, /Z HELD tracked-by-project: /); + expect(git(fx.install, 'rev-parse', 'HEAD')).toBe(old); + expect(git(fx.install, 'for-each-ref', 'refs/gstack/rescue')).toBe(''); + const lines = banner(fx).trim().split('\n'); + expect(lines).toHaveLength(1); + expect(lines[0]).toStartWith(`gstack auto-update: update held (tracked-by-project: ${fx.base} tracks`); + expect(lines[0]).toContain('troubleshooting.md#auto-update-tracked-by-project'); + const cmd = lines[0].match(/The full fix: (bash .*?)\. https:/)![1]; + const r = run(fx, cmd); + expect(r.status).toBe(6); + expect(r.stdout).toContain(`git -C ${quoted(fx.base)} rm -r -q --cached -- ${quoted("it's an install")}`); + }); + + // Value: protects=a project holding the install as a submodule gitlink keeps auto-updating; fails_when=the #1022 check counts a gitlink; + // why_new=negative control for the case above; seam=none + test('a gitlink in the enclosing project is not a hold: the update lands', async () => { + const fx = makeFixture(); + const target = release(fx, 'top UPSTREAM\nmiddle\nbottom\n'); + git(fx.base, 'init', '-q', '-b', 'main'); + git(fx.base, 'update-index', '--add', '--cacheinfo', `160000,${git(fx.install, 'rev-parse', 'HEAD')},it's an install`); + spawnSync('bash', [SCRIPT], { encoding: 'utf8', env: env(fx), timeout: 20_000 }); + await waitFor(fx, /Z UPDATED /); + expect(git(fx.install, 'rev-parse', 'HEAD')).toBe(target); + }); + test('every anchor the lines link to exists', () => { const src = fs.readFileSync(SCRIPT, 'utf8'); const anchors = [...src.matchAll(/troubleshooting\.md#([a-z0-9-]+)/g)].map((m) => m[1]!); - for (const a of ['auto-update-unmerged-files', 'auto-update-autostash-unrecoverable', 'auto-update-rescued-edits']) expect(anchors).toContain(a); + for (const a of ['auto-update-unmerged-files', 'auto-update-autostash-unrecoverable', 'auto-update-rescued-edits', 'auto-update-tracked-by-project']) expect(anchors).toContain(a); for (const a of anchors) expect(DOC, a).toContain(``); }); });