Files
gstack/test/session-update-held-banner.test.ts
Garry Tanandcapy-ai[bot] b95de2254e v1.92.5.0 fix(upgrade): refuse an install whose enclosing project tracks its files (#1022) (#3750)
* 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>

* fix: pre-landing review fixes

Leave an unmerged VERSION to the rescue, keep the enclosing project's
fsmonitor hook from running during the preflight, and word the
version-reverted refusal for a hand edit as well as a project checkout.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs(troubleshooting): name the hand-edited VERSION case

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: review pass fixes for the #1022 preflight

The printed repair untracks the install with --literal-pathspecs and escapes
glob characters in the ignore rule; the message is no longer capped short.
version-reverted compares VERSION's first line with HEAD's, yields to the
rescue when any file is unmerged, and runs inside the install lock on a full
update.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test: the literal-repair decoy matches the install's glob

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* changelog: fragment for the #1022 preflight

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: finish an interrupted move instead of holding it as version-reverted

The in-lock VERSION check accepts a VERSION already at the target's. Adds a
session-update case for the version-reverted hold, and backdates mtimes in
the new ff-update fixtures so the barrier's recapture is not racy.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: name the preflight's best-effort exit (gate-tail lint)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* changelog: say already-reverted installs need the one-time repair

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>
2026-11-01 00:29:06 +00:00

266 lines
16 KiB
TypeScript

/**
* The session-start lines for a held update, a stage that did not finish and
* edits a recovery shelved. Each hold reason has its own line naming the exact
* command and a troubleshooting anchor; the hold prints before the stage line;
* no setup fix prints while the install has unmerged files; every path is
* shell-quoted. The emitted commands are executed here against an install
* whose path holds a space and an apostrophe.
*/
import { afterEach, describe, expect, test } from 'bun:test';
import { execFileSync, spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
const ROOT = path.resolve(import.meta.dir, '..');
const SCRIPT = path.join(ROOT, 'bin', 'gstack-session-update');
const DOC = fs.readFileSync(path.join(ROOT, 'docs', 'troubleshooting.md'), 'utf8');
const bases: string[] = [];
afterEach(() => { for (const b of bases.splice(0)) fs.rmSync(b, { recursive: true, force: true }); });
function git(cwd: string, ...args: string[]): string {
return execFileSync('git', args, { cwd, encoding: 'utf8', timeout: 30_000 }).trim();
}
function makeFixture() {
const base = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-banner-')));
bases.push(base);
const origin = path.join(base, 'origin.git');
const seed = path.join(base, 'seed');
const install = path.join(base, "it's an install");
const state = path.join(base, 'state');
const home = path.join(base, 'home');
fs.mkdirSync(state, { recursive: true });
fs.mkdirSync(path.join(home, '.claude'), { recursive: true });
execFileSync('git', ['init', '-q', '--bare', '-b', 'main', origin], { timeout: 30_000 });
fs.mkdirSync(path.join(seed, 'bin'), { recursive: true });
fs.writeFileSync(path.join(seed, 'VERSION'), '1.0.0\n');
fs.writeFileSync(path.join(seed, 'notes.txt'), 'top\nmiddle\nbottom\n');
fs.writeFileSync(path.join(seed, 'bin', 'gstack-config'),
'#!/usr/bin/env bash\nif [ "$1" = "get" ]; then case "$2" in auto_upgrade) echo true;; skill_prefix) echo false;; *) echo "";; esac; fi\nexit 0\n', { mode: 0o755 });
fs.writeFileSync(path.join(seed, 'bin', 'gstack-patch-names'), '#!/usr/bin/env bash\nexit 0\n', { mode: 0o755 });
fs.copyFileSync(path.join(ROOT, 'bin', 'gstack-ff-update'), path.join(seed, 'bin', 'gstack-ff-update'));
fs.writeFileSync(path.join(seed, 'setup'), '#!/usr/bin/env bash\necho "$*" >> "$SETUP_CALLS"\nexit 0\n', { mode: 0o755 });
git(seed, 'init', '-q', '-b', 'main');
git(seed, 'add', '-A');
git(seed, '-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-q', '-m', 'seed');
git(seed, 'remote', 'add', 'origin', origin);
git(seed, 'push', '-q', 'origin', 'main');
execFileSync('git', ['clone', '-q', origin, install], { timeout: 30_000 });
return { base, seed, install, state, home, calls: path.join(base, 'calls') };
}
type Fx = ReturnType<typeof makeFixture>;
function release(fx: Fx, notes: string, version = '1.1.0'): string {
fs.writeFileSync(path.join(fx.seed, 'notes.txt'), notes);
fs.writeFileSync(path.join(fx.seed, 'VERSION'), `${version}\n`);
git(fx.seed, 'add', '-A');
git(fx.seed, '-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-qm', `release ${version}`);
git(fx.seed, 'push', '-q', 'origin', 'main');
return git(fx.seed, 'rev-parse', 'HEAD');
}
const env = (fx: Fx, extra: Record<string, string> = {}) => ({
PATH: `${path.dirname(process.execPath)}:${process.env.PATH}`, HOME: fx.home, GSTACK_DIR: fx.install, GSTACK_STATE_ROOT: fx.state, SETUP_CALLS: fx.calls, TMPDIR: fx.base, ...extra,
});
/** One session start in the foreground only (the throttle keeps the background check off). */
function banner(fx: Fx): string {
fs.writeFileSync(path.join(fx.state, '.last-session-update'), `${Math.floor(Date.now() / 1000)}\n`);
return spawnSync('bash', [SCRIPT], { encoding: 'utf8', env: env(fx), timeout: 20_000 }).stdout;
}
function pending(fx: Fx, fields: Record<string, string>) {
const all = { stages: '', from: '', failures: '0', next: '0', reason: '', pull: '', ...fields };
fs.writeFileSync(path.join(fx.state, 'session-update-pending'), Object.entries(all).map(([k, v]) => `${k}=${v}\n`).join(''));
}
/** The command after "Fix now (…):", up to the closing `./setup`. */
function fixCommand(line: string): string {
const m = line.match(/Fix now \(in a terminal; Git Bash on Windows\): (.*?&& \.\/setup)/);
expect(m, line).not.toBeNull();
return m![1];
}
function run(fx: Fx, command: string, extra: Record<string, string> = {}) {
return spawnSync('bash', ['-c', command], { cwd: fx.base, encoding: 'utf8', env: env(fx, extra), timeout: 60_000 });
}
async function waitFor(fx: Fx, pattern: RegExp, ms = 30_000): Promise<string> {
const logFile = path.join(fx.state, 'analytics', 'session-update.log');
const deadline = Date.now() + ms;
let content = '';
while (Date.now() < deadline) {
content = fs.existsSync(logFile) ? fs.readFileSync(logFile, 'utf8') : '';
if (pattern.test(content) && !fs.existsSync(path.join(fx.state, '.setup-lock'))) return content;
await new Promise((r) => setTimeout(r, 100));
}
throw new Error(`timed out waiting for ${pattern}; log:\n${content}`);
}
const quoted = (p: string) => `'${p.replace(/'/g, `'\\''`)}'`;
describe.skipIf(process.platform === 'win32')('gstack-session-update: held-update lines', () => {
test('hook-check-unavailable: the hold prints before the stage line, and its command updates the install', () => {
const fx = makeFixture();
const target = release(fx, 'top UPSTREAM\nmiddle\nbottom\n');
fs.writeFileSync(path.join(fx.install, 'notes.txt'), 'top\nmiddle\nbottom LOCAL\n');
pending(fx, { stages: 'setup', reason: 'bun not found on PATH', pull: 'hook-check-unavailable: cannot extract the incoming revision: tar: x' });
const lines = banner(fx).trim().split('\n');
expect(lines).toHaveLength(2);
expect(lines[0]).toStartWith('gstack auto-update: update held (hook-check-unavailable: cannot extract the incoming revision: tar: x)');
expect(lines[0]).toContain('troubleshooting.md#auto-update-hook-check-unavailable');
expect(lines[1]).toContain(`setup did not finish (bun not found on PATH)`);
expect(lines[1]).toContain(`then cd ${quoted(fx.install)} && ./setup`);
const cmd = fixCommand(lines[0]);
expect(cmd).toBe(`cd ${quoted(fx.install)} && git fetch origin && bash bin/gstack-ff-update . '@{upstream}' && ./setup`);
const r = run(fx, cmd);
expect(r.status, r.stdout + r.stderr).toBe(0);
expect(git(fx.install, 'rev-parse', 'HEAD')).toBe(target);
expect(fs.readFileSync(path.join(fx.install, 'notes.txt'), 'utf8')).toBe('top UPSTREAM\nmiddle\nbottom LOCAL\n');
expect(fs.readFileSync(fx.calls, 'utf8')).toBe('\n');
});
test('unmerged files: no stage fix prints, and the command rescues them before it updates', () => {
const fx = makeFixture();
const target = release(fx, 'top UPSTREAM\nmiddle\nbottom\n');
const blob = (body: string) => execFileSync('git', ['hash-object', '-w', '--stdin'], { cwd: fx.install, input: body, encoding: 'utf8', timeout: 30_000 }).trim();
const stages = [blob('top\nmiddle\nbottom\n'), blob('top OURS\nmiddle\nbottom\n'), blob('top THEIRS\nmiddle\nbottom\n')];
git(fx.install, 'update-index', '--force-remove', 'notes.txt');
execFileSync('git', ['update-index', '--index-info'], { cwd: fx.install, timeout: 30_000, input: stages.map((s, i) => `100644 ${s} ${i + 1}\tnotes.txt\n`).join('') });
fs.writeFileSync(path.join(fx.install, 'notes.txt'), '<<<<<<< ours\ntop OURS\n=======\ntop THEIRS\n>>>>>>> theirs\n');
pending(fx, { stages: 'setup', reason: './setup exited 1', pull: '' });
const out = banner(fx);
const lines = out.trim().split('\n');
expect(lines).toHaveLength(1);
expect(out).not.toContain('did not finish');
expect(lines[0]).toContain('update held (unmerged-files: the install has unmerged files (first: notes.txt))');
expect(lines[0]).toContain('troubleshooting.md#auto-update-unmerged-files');
const r = run(fx, fixCommand(lines[0]));
expect(r.status, r.stdout + r.stderr).toBe(0);
expect(git(fx.install, 'ls-files', '-u')).toBe('');
expect(git(fx.install, 'rev-parse', 'HEAD')).toBe(target);
const ref = git(fx.install, 'for-each-ref', '--format=%(refname)', 'refs/gstack/rescue');
expect(git(fx.install, 'show', `${ref}:notes.txt`)).toContain('<<<<<<< ours');
});
test('autostash-unrecoverable: inspection first, then the command once the cause is gone', () => {
const fx = makeFixture();
const target = release(fx, 'top UPSTREAM\nmiddle\nbottom\n');
pending(fx, { pull: 'autostash-unrecoverable: git refused to update x: untracked new.txt would be overwritten' });
const out = banner(fx).trim();
expect(out).toContain(`Look first: git -C ${quoted(fx.install)} status --short.`);
expect(out).toContain('troubleshooting.md#auto-update-autostash-unrecoverable');
expect(run(fx, `git -C ${quoted(fx.install)} status --short`).status).toBe(0);
const r = run(fx, fixCommand(out));
expect(r.status, r.stdout + r.stderr).toBe(0);
expect(git(fx.install, 'rev-parse', 'HEAD')).toBe(target);
});
test('bun-too-old: Bun is upgraded first, then the same command', () => {
const fx = makeFixture();
const target = release(fx, 'top UPSTREAM\nmiddle\nbottom\n');
pending(fx, { pull: 'bun-too-old: found Bun 1.3.2 at /x/bun; gstack 1.1.0 needs 1.3.3 or newer' });
const out = banner(fx).trim();
const m = out.match(/Fix now \(in a terminal; Git Bash on Windows\): (bun upgrade && .*&& \.\/setup)$/);
expect(m, out).not.toBeNull();
const stub = path.join(fx.base, 'bun-stub');
fs.mkdirSync(stub);
fs.writeFileSync(path.join(stub, 'bun'), `#!/usr/bin/env bash\necho "$*" >> "${fx.base}/bun-calls"\n`, { mode: 0o755 });
const r = run(fx, m![1], { PATH: `${stub}:${process.env.PATH}` });
expect(r.status, r.stdout + r.stderr).toBe(0);
expect(fs.readFileSync(path.join(fx.base, 'bun-calls'), 'utf8')).toBe('upgrade\n');
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)} --literal-pathspecs 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);
});
// Value: protects=a VERSION behind HEAD with no enclosing project holds (not rescued) and its preflight prints the stash fix;
// fails_when=session-update's exit-6 arm drops version-reverted; why_new=review pass 2; seam=none
test('version-reverted: the update holds with HEAD unchanged; the preflight prints the stash fix', async () => {
const fx = makeFixture();
const old = git(fx.install, 'rev-parse', 'HEAD');
release(fx, 'top UPSTREAM\nmiddle\nbottom\n');
fs.writeFileSync(path.join(fx.install, 'VERSION'), '0.0.1\n');
spawnSync('bash', [SCRIPT], { encoding: 'utf8', env: env(fx), timeout: 20_000 });
await waitFor(fx, /Z HELD version-reverted: /);
expect(git(fx.install, 'rev-parse', 'HEAD')).toBe(old);
expect(git(fx.install, 'for-each-ref', 'refs/gstack/rescue')).toBe('');
const line = banner(fx).trim();
expect(line).toStartWith('gstack auto-update: update held (version-reverted: VERSION says 0.0.1 but the checkout is at 1.0.0');
const r = run(fx, line.match(/The full fix: (bash .*?)\. https:/)![1]);
expect(r.status).toBe(6);
expect(r.stdout).toContain(`git -C ${quoted(fx.install)} stash push`);
});
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', 'auto-update-tracked-by-project']) expect(anchors).toContain(a);
for (const a of anchors) expect(DOC, a).toContain(`<a id="${a}"></a>`);
});
});
describe.skipIf(process.platform === 'win32')('gstack-session-update: shelved edits are never silent', () => {
test('background rescue, then a daily line naming the files and the ref; its commands inspect, export only the delta, and delete', async () => {
const fx = makeFixture();
fs.writeFileSync(path.join(fx.install, 'notes.txt'), 'top LOCAL\nmiddle\nbottom\n');
const target = release(fx, 'top UPSTREAM\nmiddle\nbottom\n');
spawnSync('bash', [SCRIPT], { encoding: 'utf8', env: env(fx), timeout: 20_000 });
const log = await waitFor(fx, /Z UPDATED /);
const ref = log.match(/kept_ref=(refs\/gstack\/rescue\/[0-9a-f]{40})/)![1];
expect(git(fx.install, 'rev-parse', 'HEAD')).toBe(target);
// Background → foreground: the next session start names it.
const first = banner(fx).trim();
expect(first).toContain(`your local edits in the install (notes.txt) conflicted with an update, so they were saved in ${ref}`);
expect(first).toContain('troubleshooting.md#auto-update-rescued-edits');
// Once a day: the next session start the same day is quiet.
expect(banner(fx)).toBe('');
const show = first.match(/Inspect \(in a terminal; Git Bash on Windows\): (git -C .*? show --stat '[^']+')\./)![1];
expect(run(fx, show).stdout).toContain('notes.txt');
const exp = first.match(/not in the install: (git -C .*? diff '[^']+\^' '[^']+' > ~\/gstack-local-edits\.patch)/)![1];
expect(run(fx, exp).status).toBe(0);
const patch = fs.readFileSync(path.join(fx.home, 'gstack-local-edits.patch'), 'utf8');
expect(patch).toContain('+top LOCAL');
expect([...patch.matchAll(/^diff --git a\/(\S+)/gm)].map((m) => m[1])).toEqual(['notes.txt']);
// The next day it repeats.
const notes = path.join(fx.state, 'session-update-rescues');
fs.writeFileSync(notes, fs.readFileSync(notes, 'utf8').replace(/ \d{4}-\d{2}-\d{2}$/m, ' 2000-01-01'));
expect(banner(fx)).toContain(ref);
const del = first.match(/Delete when done: (git -C .*? update-ref -d '[^']+')\./)![1];
expect(run(fx, del).status).toBe(0);
expect(git(fx.install, 'for-each-ref', 'refs/gstack/rescue')).toBe('');
expect(banner(fx)).toBe('');
expect(fs.existsSync(notes)).toBe(false);
}, 60_000);
});