mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-02 15:04:37 +01:00
* 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>
This commit is contained in:
1 parent
7f815411f3
commit
b95de2254e
12 files changed
+344
-15
No files matched your search
@@ -74,6 +74,8 @@ function ff(fx: Fx, rev = 'origin/main', extra: Record<string, string> = {}, bin
|
||||
}
|
||||
const read = (fx: Fx, f: string) => fs.readFileSync(path.join(fx.install, f), 'utf8');
|
||||
const write = (fx: Fx, f: string, body: string) => fs.writeFileSync(path.join(fx.install, f), body);
|
||||
/** Moves mtimes 10 s back so no entry is racily clean against the index (a same-second edit can trip the barrier's recapture). */
|
||||
const backdate = (fx: Fx, ...files: string[]) => { const s = Date.now() / 1000 - 10; for (const f of files) fs.utimesSync(path.join(fx.install, f), s, s); };
|
||||
const rescueRef = (out: string) => out.match(/^rescued [0-9a-f]+ [0-9a-f]+ (refs\/gstack\/rescue\/[0-9a-f]{40}) unmerged=([01])$/m);
|
||||
function makeUnmerged(fx: Fx, file: string) {
|
||||
const blob = (body: string) => execFileSync('git', ['hash-object', '-w', '--stdin'], { cwd: fx.install, input: body, encoding: 'utf8', timeout: 30_000 }).trim();
|
||||
@@ -264,6 +266,124 @@ 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');
|
||||
write(fx, 'VERSION', '9.9.9\n');
|
||||
expect(ff(fx).stdout).toStartWith('version-reverted: VERSION says 9.9.9 but the checkout is at 1.1.0: files changed outside git');
|
||||
});
|
||||
|
||||
// Value: protects=an unmerged VERSION still takes the existing rescue, which the unmerged-files banner's command relies on;
|
||||
// fails_when=the version-reverted preflight fires on conflict markers; why_new=the preflight is new; seam=none
|
||||
test('preflight: an unmerged VERSION is rescued, not refused as version-reverted', () => {
|
||||
const fx = makeFixture();
|
||||
makeUnmerged(fx, 'VERSION');
|
||||
const target = release(fx, { 'notes.txt': 'x\n' });
|
||||
const r = ff(fx, target);
|
||||
expect(r.status, r.stdout + r.stderr).toBe(0);
|
||||
expect(r.stdout).toMatch(/^rescued .* unmerged=1/);
|
||||
});
|
||||
|
||||
// Value: protects=the preflight runs no code from the enclosing project (its core.fsmonitor hook); fails_when=an enclosing-repo git call
|
||||
// drops the fsmonitor override; why_new=the enclosing-project probe is new; seam=none
|
||||
test('preflight: an enclosing project\'s core.fsmonitor hook never runs', () => {
|
||||
const fx = makeFixture();
|
||||
const sentinel = path.join(fx.base, 'fsmonitor-ran');
|
||||
git(fx.base, 'init', '-q', '-b', 'main');
|
||||
fs.writeFileSync(path.join(fx.base, 'hook.sh'), `#!/bin/sh\ntouch '${sentinel}'\n`, { mode: 0o755 });
|
||||
git(fx.base, 'config', 'core.fsmonitor', path.join(fx.base, 'hook.sh'));
|
||||
const r = spawnSync('bash', [FF, '--preflight', fx.install], { encoding: 'utf8', env: isolatedEnv(fx.base), timeout: 60_000 });
|
||||
expect(r.status, r.stdout + r.stderr).toBe(0);
|
||||
expect(fs.existsSync(sentinel)).toBe(false);
|
||||
});
|
||||
|
||||
// Value: protects=the printed repair untracks only the install, even when its name holds glob characters, and prints whole at any path length;
|
||||
// fails_when=the rm drops --literal-pathspecs, the ignore rule is unescaped, or the message is capped short; why_new=review pass 1; seam=none
|
||||
test('preflight: the tracked-by-project repair is literal and printed whole', () => {
|
||||
const fx = makeFixture(`gstack*${'x'.repeat(150)}`);
|
||||
const name = path.basename(fx.install);
|
||||
git(fx.base, 'init', '-q', '-b', 'main');
|
||||
const decoy = `gstack-data${'x'.repeat(150)}`;
|
||||
fs.writeFileSync(path.join(fx.base, decoy), 'keep\n');
|
||||
git(fx.base, 'add', '--', decoy);
|
||||
git(fx.base, 'update-index', '--add', '--', `${name}/VERSION`);
|
||||
const r = spawnSync('bash', [FF, '--preflight', fx.install], { encoding: 'utf8', env: isolatedEnv(fx.base), timeout: 60_000 });
|
||||
expect(r.status, r.stdout + r.stderr).toBe(6);
|
||||
expect(r.stdout.trimEnd()).toEndWith('and re-run');
|
||||
expect(r.stdout).toContain(`add /gstack\\*${'x'.repeat(150)}/ to`);
|
||||
const rm = r.stdout.match(/fix: (git -C .*? rm -r -q --cached -- '[^']*')/)?.[1];
|
||||
expect(rm).toContain('--literal-pathspecs');
|
||||
expect(spawnSync('bash', ['-c', rm!], { encoding: 'utf8', timeout: 30_000 }).status).toBe(0);
|
||||
expect(git(fx.base, 'ls-files').split('\n')).toEqual([decoy]);
|
||||
});
|
||||
|
||||
// Value: protects=a dirty VERSION beside an unmerged file still takes the rescue (stash cannot run there); a VERSION that differs only in mode is no
|
||||
// revert; a held lock wins over the VERSION check; fails_when=the check looks only at VERSION's own stage, compares bytes, or runs before the lock;
|
||||
// why_new=review pass 1; seam=none
|
||||
test('preflight: version-reverted yields to the rescue, to a same-text VERSION, and to a held lock', () => {
|
||||
const fx = makeFixture();
|
||||
makeUnmerged(fx, 'notes.txt');
|
||||
write(fx, 'VERSION', '0.9.0\n');
|
||||
backdate(fx, 'VERSION', 'notes.txt');
|
||||
const mixed = ff(fx, release(fx, { 'other.txt': 'x\n' }));
|
||||
expect(mixed.status, mixed.stdout + mixed.stderr).toBe(0);
|
||||
expect(mixed.stdout).toMatch(/^rescued .* unmerged=1/);
|
||||
|
||||
const fy = makeFixture();
|
||||
fs.chmodSync(path.join(fy.install, 'VERSION'), 0o755);
|
||||
expect(spawnSync('bash', [FF, '--preflight', fy.install], { encoding: 'utf8', env: isolatedEnv(fy.base), timeout: 60_000 }).status).toBe(0);
|
||||
|
||||
const fz = makeFixture();
|
||||
release(fz, { 'other.txt': 'x\n' });
|
||||
write(fz, 'VERSION', '0.9.0\n');
|
||||
const holder = spawn('sleep', ['30']);
|
||||
try {
|
||||
const lock = path.join(fz.install, '.git', 'gstack-ff.lock');
|
||||
fs.mkdirSync(lock);
|
||||
fs.writeFileSync(path.join(lock, 'pid'), `${holder.pid}\n`);
|
||||
expect(ff(fz, 'origin/main', { GSTACK_FF_LOCK_WAIT: '0' }).status).toBe(75);
|
||||
expect(spawnSync('bash', [FF, '--preflight', fz.install], { encoding: 'utf8', env: isolatedEnv(fz.base), timeout: 60_000 }).status).toBe(6);
|
||||
} finally {
|
||||
holder.kill();
|
||||
}
|
||||
});
|
||||
|
||||
// Value: protects=a move interrupted after the tree was written but before HEAD moved still finishes on the next run, not held as
|
||||
// version-reverted; fails_when=the in-lock check ignores the target's VERSION; why_new=review pass 2; seam=none
|
||||
test('an interrupted move (tree at the target, HEAD old) finishes instead of holding', () => {
|
||||
const fx = makeFixture();
|
||||
const target = release(fx, { 'notes.txt': 'top UPSTREAM\nmiddle\nbottom\n' });
|
||||
execFileSync('bash', ['-c', `git archive ${target} | tar -x`], { cwd: fx.install, timeout: 30_000 });
|
||||
expect(read(fx, 'VERSION')).toBe('1.1.0\n');
|
||||
backdate(fx, 'VERSION', 'notes.txt');
|
||||
const r = ff(fx, target);
|
||||
expect(r.status, r.stdout + r.stderr).toBe(0);
|
||||
expect(git(fx.install, 'rev-parse', 'HEAD')).toBe(target);
|
||||
expect(spawnSync('bash', [FF, '--preflight', fx.install], { encoding: 'utf8', env: isolatedEnv(fx.base), timeout: 60_000 }).status).toBe(0);
|
||||
});
|
||||
|
||||
test('refusals: not a fast-forward, an unresolvable target, and a missing checkout', () => {
|
||||
const fx = makeFixture();
|
||||
write(fx, 'VERSION', 'local\n');
|
||||
|
||||
@@ -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}' --literal-pathspecs 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'];
|
||||
|
||||
@@ -166,10 +166,62 @@ 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)} --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']) 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>`);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user