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>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-31 18:17:57 +00:00
1 parent 5ba425af4b
commit fdd5169a34
7 files changed
+210 -11

No files matched your search

+41 -3
View File
@@ -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 <dir> 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 <install dir> <revision>"
DIR=$1 REV=$2
[ $# -eq 2 ] && [ -n "$1" ] && [ -n "$2" ] || refuse 2 usage "gstack-ff-update <install dir> <revision> | --preflight <install dir>"
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 <<EOF_PF
$(git -C "$_top" --literal-pathspecs ls-files -s -- "$_rel" 2>/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) \
+3 -1
View File
@@ -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
+6 -2
View File
@@ -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"
+20
View File
@@ -1109,6 +1109,26 @@ conflict yourself instead, finish or abort your merge (`git -C '<dir>' merge
--abort`, `git -C '<dir>' checkout -- <file>`); gstack retries at the next
check.
<a id="auto-update-tracked-by-project"></a>
### `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 <install>/bin/gstack-ff-update --preflight <install>`. 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 <install> stash push` puts the install's own files back and keeps
any edit you made in the stash, and the next update proceeds.
<a id="auto-update-autostash-unrecoverable"></a>
### `gstack auto-update: update held (autostash-unrecoverable: <reason>); your local edits are where they were and nothing was installed. Look first: git -C '<dir>' status --short. Fix now (...)` / `autostash-unrecoverable: <reason>`
+27
View File
@@ -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@localhost>|gstack rescue <gstack@localhost>');
});
// 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');
+78 -4
View File
@@ -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<string, string> = {}) =>
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'];
+35 -1
View File
@@ -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(`<a id="${a}"></a>`);
});
});