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(``);
});
});