From be5c7b13f5fe0b9695e63dc5c6985d4f1c50281f Mon Sep 17 00:00:00 2001 From: garrytan Date: Thu, 29 Oct 2026 04:28:50 +0000 Subject: [PATCH 1/3] test(free): build git fixtures once per file and copy them per test test/helpers/git-fixture-template.ts builds a template once, copies it per test, rebinds every path-bearing file and fails on a leaked template path. Adopted by release-cut, train-backstop, restamp-mentions, pregate-user-policy and held-install-recovery; restamp-fixture's origin turns off auto-maintenance. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- test/git-fixture-template.test.ts | 97 +++++++++++++++++++++++++ test/held-install-recovery.test.ts | 33 ++++++++- test/helpers/git-fixture-template.ts | 105 +++++++++++++++++++++++++++ test/helpers/pregate-fixture.ts | 36 +++++++-- test/helpers/restamp-fixture.ts | 40 ++++++++-- test/pregate-user-policy.test.ts | 8 +- test/release-cut.test.ts | 59 ++++++++++----- test/restamp-mentions.test.ts | 14 +++- test/train-backstop.test.ts | 22 +++++- 9 files changed, 371 insertions(+), 43 deletions(-) create mode 100644 test/git-fixture-template.test.ts create mode 100644 test/helpers/git-fixture-template.ts diff --git a/test/git-fixture-template.test.ts b/test/git-fixture-template.test.ts new file mode 100644 index 000000000..76375466c --- /dev/null +++ b/test/git-fixture-template.test.ts @@ -0,0 +1,97 @@ +/** + * test/helpers/git-fixture-template.ts: copies made from one template are + * independent repositories (a push in one never reaches the other or the + * template), and a copy whose rebind misses a path that names the template + * root, in any of its spellings, throws instead of reaching a test. + */ +import { afterAll, afterEach, describe, expect, test } from 'bun:test'; +import { execFileSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { gitFixtureTemplate, pathSpellings, rebindFile, rebindGitDir, templateReferences } from './helpers/git-fixture-template'; + +const roots: string[] = []; +afterEach(() => { for (const r of roots.splice(0)) fs.rmSync(r, { recursive: true, force: true }); }); + +function git(cwd: string, ...args: string[]): string { + return execFileSync('git', ['-c', 'user.name=t', '-c', 'user.email=t@t', '-c', 'commit.gpgsign=false', ...args], { cwd, encoding: 'utf8', timeout: 30_000 }).trim(); +} + +function build(root: string): { head: string } { + const origin = path.join(root, 'origin.git'); + const work = path.join(root, 'work'); + execFileSync('git', ['init', '-q', '--bare', '-b', 'main', origin], { timeout: 30_000 }); + fs.mkdirSync(work); + git(work, 'init', '-q', '-b', 'main'); + git(work, 'remote', 'add', 'origin', origin); + fs.writeFileSync(path.join(work, 'a.txt'), 'base\n'); + git(work, 'add', '-A'); + git(work, 'commit', '-q', '-m', 'base'); + git(work, 'push', '-q', 'origin', 'main'); + return { head: git(work, 'rev-parse', 'HEAD') }; +} + +const template = gitFixtureTemplate({ prefix: 'gstack-fixture-template-', build, rebind: (copy, tpl) => rebindGitDir(path.join(copy, 'work', '.git'), tpl, copy) }); +const leaky = gitFixtureTemplate({ prefix: 'gstack-fixture-template-leak-', build, rebind: () => {} }); +afterAll(() => { template.dispose(); leaky.dispose(); }); + +function copy() { + const c = template.copy(); + roots.push(c.root); + return { ...c, work: path.join(c.root, 'work'), origin: path.join(c.root, 'origin.git') }; +} +function pushChange(c: ReturnType, text: string): string { + fs.writeFileSync(path.join(c.work, 'a.txt'), text); + git(c.work, 'commit', '-q', '-am', text); + git(c.work, 'push', '-q', 'origin', 'main'); + return git(c.work, 'rev-parse', 'HEAD'); +} + +describe('git fixture template', () => { + test('two copies from one template mutated independently stay isolated from each other and from the template', () => { + const a = copy(); + const b = copy(); + expect(git(a.work, 'remote', 'get-url', 'origin')).toBe(a.origin); + expect(git(b.work, 'remote', 'get-url', 'origin')).toBe(b.origin); + const fromA = pushChange(a, 'from a\n'); + expect(git(a.origin, 'rev-parse', 'main')).toBe(fromA); + expect(git(b.origin, 'rev-parse', 'main')).toBe(b.meta.head); + const fromB = pushChange(b, 'from b\n'); + expect(fromB).not.toBe(fromA); + expect(git(a.origin, 'rev-parse', 'main')).toBe(fromA); + expect(git(b.origin, 'rev-parse', 'main')).toBe(fromB); + expect(fs.readFileSync(path.join(a.work, 'a.txt'), 'utf8')).toBe('from a\n'); + const c = copy(); + expect(git(c.origin, 'rev-parse', 'main')).toBe(c.meta.head); + expect(fs.readFileSync(path.join(c.work, 'a.txt'), 'utf8')).toBe('base\n'); + }); + + test('a copy whose rebind leaves the remote URL on the template throws and names the file', () => { + expect(() => leaky.copy()).toThrow(/still references its template [^\n]*\n.*config/); + }); + + test('native, forward-slash, backslash-escaped and Git Bash spellings are each rewritten, and each one left behind is found', () => { + const to = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-fixture-spellings-')); + roots.push(to); + const from = 'C:\\Users\\RUNNER~1\\AppData\\Local\\Temp\\gstack-fixture-tpl-AbC123'; + const spellings = pathSpellings(from); + expect(spellings).toEqual([ + 'C:\\\\Users\\\\RUNNER~1\\\\AppData\\\\Local\\\\Temp\\\\gstack-fixture-tpl-AbC123', + from, + 'C:/Users/RUNNER~1/AppData/Local/Temp/gstack-fixture-tpl-AbC123', + '/c/Users/RUNNER~1/AppData/Local/Temp/gstack-fixture-tpl-AbC123', + ]); + const file = path.join(to, 'config'); + fs.writeFileSync(file, spellings.map(s => 'url = ' + s + '/origin.git\n').join('')); + expect(templateReferences(to, from)).toEqual([file]); + rebindFile(file, from, to); + expect(fs.readFileSync(file, 'utf8')).toBe(spellings.map((_, i) => 'url = ' + (pathSpellings(to)[i] ?? to) + '/origin.git\n').join('')); + expect(templateReferences(to, from)).toEqual([]); + for (const s of spellings) { + fs.writeFileSync(file, 'url = ' + s + '\n'); + expect(templateReferences(to, from)).toEqual([file]); + } + expect(templateReferences(to, from, { origin: from + '\\origin.git' })).toEqual([file, '(template meta)']); + }); +}); diff --git a/test/held-install-recovery.test.ts b/test/held-install-recovery.test.ts index 3604370e2..5e16f97be 100644 --- a/test/held-install-recovery.test.ts +++ b/test/held-install-recovery.test.ts @@ -8,11 +8,12 @@ * already-unmerged), all in prefix mode; the PowerShell forms run under * powershell.exe in .github/workflows/windows-setup-e2e.yml. */ -import { afterEach, describe, expect, test } from 'bun:test'; +import { afterAll, afterEach, describe, expect, test } from 'bun:test'; import { execFileSync, spawn, spawnSync } from 'node:child_process'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; +import { gitFixtureTemplate } from './helpers/git-fixture-template'; const ROOT = path.resolve(import.meta.dir, '..'); const DOC = fs.readFileSync(path.join(ROOT, 'docs', 'troubleshooting.md'), 'utf8'); @@ -37,8 +38,33 @@ const CONFLICT_LINE = 'THROTTLE_SECONDS=3600 # 1 hour'; * bin/gstack-ff-update (an install that predates it), with VERSION 1.91.101.0. */ function makeFixture(source = ROOT) { - const base = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-held-'))); + if (source !== ROOT) { + const base = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-held-'))); + bases.push(base); + const { src, ...fx } = buildFixture(base, source); + expect(src).toContain(CONFLICT_LINE); + return { base, ...fx }; + } + const { root: base, meta: { src, ...fx } } = rootTemplate.copy(); bases.push(base); + expect(src).toContain(CONFLICT_LINE); + return { base, origin: path.join(base, 'origin.git'), ...fx }; +} + +/** makeFixture's origin.git for this checkout, built once per file; the per-test source checkouts build their own. */ +const rootTemplate = gitFixtureTemplate({ + prefix: 'gstack-held-', + realpath: true, + build: (base) => { + const { origin, ...meta } = buildFixture(base, ROOT); + fs.rmSync(path.join(base, 'work'), { recursive: true, force: true }); + return meta; + }, + rebind: () => {}, +}); +afterAll(() => rootTemplate.dispose()); + +function buildFixture(base: string, source: string) { const origin = path.join(base, 'origin.git'); const work = path.join(base, 'work'); execFileSync('git', ['clone', '-q', '--shared', source, work], { timeout: 60_000 }); @@ -53,7 +79,6 @@ function makeFixture(source = ROOT) { const held = git(work, 'rev-parse', 'HEAD'); fs.copyFileSync(path.join(ROOT, 'bin', 'gstack-ff-update'), path.join(work, 'bin', 'gstack-ff-update')); const src = fs.readFileSync(path.join(work, EDITED), 'utf8'); - expect(src).toContain(CONFLICT_LINE); fs.writeFileSync(path.join(work, EDITED), src.replace(CONFLICT_LINE, 'THROTTLE_SECONDS=3600 # 1 hour (upstream)')); fs.writeFileSync(path.join(work, 'VERSION'), '1.91.102.0\n'); git(work, 'add', '-A'); @@ -61,7 +86,7 @@ function makeFixture(source = ROOT) { const fixed = git(work, 'rev-parse', 'HEAD'); execFileSync('git', ['init', '-q', '--bare', '-b', 'main', origin], { timeout: 60_000 }); git(work, 'push', '-q', origin, 'HEAD:refs/heads/main'); - return { base, origin, held, fixed }; + return { origin, held, fixed, src }; } type Fx = ReturnType; diff --git a/test/helpers/git-fixture-template.ts b/test/helpers/git-fixture-template.ts new file mode 100644 index 000000000..a646747ab --- /dev/null +++ b/test/helpers/git-fixture-template.ts @@ -0,0 +1,105 @@ +/** + * Build a git fixture once per test file and hand each test its own copy. + * + * build(root) runs once, on the first copy(), into a template directory under + * os.tmpdir(). Every copy() makes a fresh sibling directory, copies the + * template into it with fs.cpSync (symlinks kept verbatim) and calls + * rebind(copyRoot, templateRoot), which must rewrite every path-bearing file + * the fixture wrote: remote URLs and pushurls in .git/config, reflog messages, + * FETCH_HEAD, objects/info/alternates, state roots, install records. The copy + * is then scanned for any remaining reference to the template root (native, + * forward-slash and backslash-escaped spellings, plus the realpath and Git + * Bash forms where they differ); a hit throws, so a test can never push into, + * fetch from or read state out of the shared template. dispose() removes the + * template; call it from afterAll. + */ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +export interface GitFixtureCopy { root: string; meta: T } +export interface GitFixtureTemplate { copy: (prefix?: string) => GitFixtureCopy; dispose: () => void } + +/** The spellings a path takes inside files git and gstack write, escaped form first so a rewrite never splits it. */ +export function pathSpellings(root: string): string[] { + const forms = (p: string) => { + const forward = p.replace(/\\/g, '/'); + const drive = /^([A-Za-z]):\//.exec(forward); + return [p.replace(/\\/g, '\\\\'), p, forward, ...(drive ? ['/' + drive[1]!.toLowerCase() + '/' + forward.slice(3)] : [])]; + }; + let real = root; + try { real = fs.realpathSync.native(root); } catch {} + return [...new Set([...forms(root), ...forms(real)])]; +} + +/** Rewrite every spelling of from to the same spelling of to in a text file, when the file exists. */ +export function rebindFile(file: string, from: string, to: string): void { + if (!fs.existsSync(file)) return; + const targets = pathSpellings(to); + let text = fs.readFileSync(file, 'utf8'); + const before = text; + pathSpellings(from).forEach((spelling, i) => { text = text.split(spelling).join(targets[i] ?? to); }); + if (text !== before) fs.writeFileSync(file, text); +} + +/** Rebind the path-bearing files git writes in a git directory: config, FETCH_HEAD, alternates and every reflog. */ +export function rebindGitDir(gitDir: string, from: string, to: string): void { + for (const rel of ['config', 'FETCH_HEAD', path.join('objects', 'info', 'alternates')]) rebindFile(path.join(gitDir, rel), from, to); + const logs = path.join(gitDir, 'logs'); + if (!fs.existsSync(logs)) return; + for (const entry of fs.readdirSync(logs, { recursive: true, withFileTypes: true })) { + if (entry.isFile()) rebindFile(path.join(entry.parentPath, entry.name), from, to); + } +} + +/** Files and symlinks under dir (and the template's meta value) that still name templateRoot; zlib-compressed object storage is skipped. */ +export function templateReferences(dir: string, templateRoot: string, meta?: unknown): string[] { + const needles = pathSpellings(templateRoot).map(s => Buffer.from(s)); + const names = (buf: Buffer) => needles.some(n => buf.includes(n)); + const hits: string[] = []; + const walk = (d: string) => { + for (const entry of fs.readdirSync(d, { withFileTypes: true })) { + const p = path.join(d, entry.name); + if (entry.isSymbolicLink()) { if (names(Buffer.from(fs.readlinkSync(p)))) hits.push(p); } + else if (entry.isDirectory()) { if (!(path.basename(d) === 'objects' && /^(pack|[0-9a-f]{2})$/.test(entry.name))) walk(p); } + else if (entry.isFile() && names(fs.readFileSync(p))) hits.push(p); + } + }; + walk(dir); + if (meta !== undefined && names(Buffer.from(JSON.stringify(meta)))) hits.push('(template meta)'); + return hits; +} + +export function gitFixtureTemplate(opts: { + prefix: string; + build: (root: string) => T; + rebind: (copyRoot: string, templateRoot: string) => void; + realpath?: boolean; +}): GitFixtureTemplate { + let template: GitFixtureCopy | null = null; + const mkroot = (prefix: string) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + return opts.realpath ? fs.realpathSync(dir) : dir; + }; + return { + copy(prefix = opts.prefix) { + if (!template) { + const root = mkroot(opts.prefix + 'tpl-'); + template = { root, meta: opts.build(root) }; + } + const { root: from, meta } = template; + const root = mkroot(prefix); + if (pathSpellings(root).some(s => pathSpellings(from).some(t => s.startsWith(t)))) throw new Error('git fixture copy ' + root + ' lies under its template ' + from); + fs.cpSync(from, root, { recursive: true, verbatimSymlinks: true }); + opts.rebind(root, from); + const leaks = templateReferences(root, from, meta); + if (leaks.length) fs.rmSync(root, { recursive: true, force: true }); + if (leaks.length) throw new Error('git fixture copy still references its template ' + from + ' (rebind missed them):\n' + leaks.join('\n')); + return { root, meta }; + }, + dispose() { + if (template) fs.rmSync(template.root, { recursive: true, force: true }); + template = null; + }, + }; +} diff --git a/test/helpers/pregate-fixture.ts b/test/helpers/pregate-fixture.ts index 94d0b3ad3..baef6855e 100644 --- a/test/helpers/pregate-fixture.ts +++ b/test/helpers/pregate-fixture.ts @@ -14,6 +14,7 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { runScriptSync } from '../../lib/script-invocation'; +import { gitFixtureTemplate, rebindGitDir, type GitFixtureTemplate } from './git-fixture-template'; import { gitArgvIn } from './scratch-repo'; export const ROOT = path.resolve(import.meta.dir, '..', '..'); @@ -80,20 +81,46 @@ jobs: run: node --test \${{ matrix.files }} `; -export function makeFixtureRepo(opts: { registry?: Record | null; pregate?: Record | null; prefix?: string; baseFiles?: Record } = {}): FixtureRepo { +export type FixtureOptions = { registry?: Record | null; pregate?: Record | null; prefix?: string; baseFiles?: Record }; + +export function makeFixtureRepo(opts: FixtureOptions = {}): FixtureRepo { const root = fs.mkdtempSync(path.join(os.tmpdir(), opts.prefix ?? 'gstack-pregate-fx-')); + seedFixtureRepo(root, opts); + return fixtureRepoAt(root); +} + +/** A template of makeFixtureRepo(opts), built once and copied per test (helpers/git-fixture-template.ts). */ +export function fixtureTemplate(opts: FixtureOptions = {}): GitFixtureTemplate { + return gitFixtureTemplate({ + prefix: opts.prefix ?? 'gstack-pregate-fx-', + build: root => { seedFixtureRepo(root, opts); return null; }, + rebind: (copy, tpl) => rebindGitDir(path.join(copy, 'work', '.git'), tpl, copy), + }); +} + +/** A fresh copy of a fixtureTemplate as its own FixtureRepo (cleanup() removes it like any other). */ +export function cloneFixture(template: GitFixtureTemplate, prefix?: string): FixtureRepo { + return fixtureRepoAt(template.copy(prefix).root); +} + +function fixtureRepoAt(root: string): FixtureRepo { const origin = path.join(root, 'origin.git'); const work = path.join(root, 'work'); const home = path.join(root, 'home'); - fs.mkdirSync(home, { recursive: true }); - fs.mkdirSync(work, { recursive: true }); const env = { GSTACK_HOME: home, GSTACK_STATE_DIR: home, HOME: home, GIT_CONFIG_GLOBAL: path.join(root, 'gitconfig'), GIT_CONFIG_NOSYSTEM: '1' }; - fs.writeFileSync(env.GIT_CONFIG_GLOBAL, '[user]\n\tname = t\n\temail = t@test\n[commit]\n\tgpgsign = false\n[init]\n\tdefaultBranch = main\n'); const git = (...args: string[]): string => { const r = gitArgvIn(work, args, 30_000, { ...process.env, ...env }); if (r.status !== 0) throw new Error(`git ${args.join(' ')} failed: status=${r.status} signal=${r.signal} error=${r.error?.message ?? ''} stderr=${r.stderr?.toString() ?? ''}`); return r.stdout?.toString().trim() ?? ''; }; + return { root, work, origin, home, git, env }; +} + +function seedFixtureRepo(root: string, opts: FixtureOptions): void { + const { origin, work, home, env, git } = fixtureRepoAt(root); + fs.mkdirSync(home, { recursive: true }); + fs.mkdirSync(work, { recursive: true }); + fs.writeFileSync(env.GIT_CONFIG_GLOBAL, '[user]\n\tname = t\n\temail = t@test\n[commit]\n\tgpgsign = false\n[init]\n\tdefaultBranch = main\n'); spawnSync('git', ['init', '-q', '--bare', origin], { timeout: 30_000, env: { ...process.env, ...env } }); git('init', '-q', '-b', 'main'); for (const dir of ['src', 'lib', 'bin', 'test', 'test/golden', 'test/nightly', 'generated', 'docs', 'scripts', '.gstack', '.github/workflows']) fs.mkdirSync(path.join(work, dir), { recursive: true }); @@ -130,7 +157,6 @@ export function makeFixtureRepo(opts: { registry?: Record | nul git('push', '-q', 'origin', 'main'); git('remote', 'set-head', 'origin', 'main'); git('checkout', '-q', '-b', 'feature'); - return { root, work, origin, home, git, env }; } export function write(work: string, rel: string, text: string): void { diff --git a/test/helpers/restamp-fixture.ts b/test/helpers/restamp-fixture.ts index 2b6366c99..6ef5b188f 100644 --- a/test/helpers/restamp-fixture.ts +++ b/test/helpers/restamp-fixture.ts @@ -11,6 +11,7 @@ import { spawnSync } from 'node:child_process'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; +import { gitFixtureTemplate, rebindGitDir, type GitFixtureTemplate } from './git-fixture-template'; import { gitArgvIn } from './scratch-repo'; import { mergeChangelogs } from '../../lib/changelog-check'; import { runScriptSync } from '../../lib/script-invocation'; @@ -32,21 +33,51 @@ export const BASE_POLICY = { stamp_sensitive_gates: ['digest'], }; -export function makeFixtureRepo(opts: { policy?: Record | null; version?: string; prefix?: string } = {}): FixtureRepo { +export type FixtureOptions = { policy?: Record | null; version?: string; prefix?: string }; + +export function makeFixtureRepo(opts: FixtureOptions = {}): FixtureRepo { const root = fs.mkdtempSync(path.join(os.tmpdir(), opts.prefix ?? 'gstack-restamp-fx-')); + seedFixtureRepo(root, opts); + return fixtureRepoAt(root); +} + +/** The FixtureRepo handle for a root seedFixtureRepo filled, directly or through a template copy. */ +export function fixtureRepoAt(root: string): FixtureRepo { const origin = path.join(root, 'origin.git'); const work = path.join(root, 'work'); const home = path.join(root, 'home'); - fs.mkdirSync(home, { recursive: true }); - fs.mkdirSync(work, { recursive: true }); const env = { GSTACK_HOME: home, GSTACK_STATE_DIR: home, HOME: home, GIT_CONFIG_GLOBAL: path.join(root, 'gitconfig'), GIT_CONFIG_NOSYSTEM: '1' }; - fs.writeFileSync(env.GIT_CONFIG_GLOBAL, '[user]\n\tname = t\n\temail = t@test\n[commit]\n\tgpgsign = false\n[init]\n\tdefaultBranch = main\n'); const git = (...args: string[]): string => { const r = gitArgvIn(work, args, 30_000, { ...process.env, ...env }); if (r.status !== 0) throw new Error(`git ${args.join(' ')} failed: ${r.stderr?.toString()}`); return r.stdout?.toString().trim() ?? ''; }; + return { root, work, origin, home, git, env }; +} + +/** A template of makeFixtureRepo(opts), built once and copied per test (helpers/git-fixture-template.ts). */ +export function fixtureTemplate(opts: FixtureOptions = {}): GitFixtureTemplate { + return gitFixtureTemplate({ + prefix: opts.prefix ?? 'gstack-restamp-fx-', + build: root => { seedFixtureRepo(root, opts); return null; }, + rebind: (copy, tpl) => rebindGitDir(path.join(copy, 'work', '.git'), tpl, copy), + }); +} + +/** A fresh copy of a fixtureTemplate as its own FixtureRepo (cleanup() removes it like any other). */ +export function cloneFixture(template: GitFixtureTemplate, prefix?: string): FixtureRepo { + return fixtureRepoAt(template.copy(prefix).root); +} + +/** makeFixtureRepo's repository, written into an existing root. */ +export function seedFixtureRepo(root: string, opts: FixtureOptions): void { + const { origin, work, home, env, git } = fixtureRepoAt(root); + fs.mkdirSync(home, { recursive: true }); + fs.mkdirSync(work, { recursive: true }); + fs.writeFileSync(env.GIT_CONFIG_GLOBAL, '[user]\n\tname = t\n\temail = t@test\n[commit]\n\tgpgsign = false\n[init]\n\tdefaultBranch = main\n'); spawnSync('git', ['init', '-q', '--bare', origin], { timeout: 30_000, env: { ...process.env, ...env } }); + git('--git-dir', origin, 'config', 'maintenance.auto', 'false'); + git('--git-dir', origin, 'config', 'gc.auto', '0'); git('init', '-q', '-b', 'main'); const version = opts.version ?? '1.2.3.0'; const npm = version.split('.').slice(0, 3).join('.'); @@ -66,7 +97,6 @@ export function makeFixtureRepo(opts: { policy?: Record | null; git('remote', 'add', 'origin', origin); git('push', '-q', 'origin', 'main'); git('remote', 'set-head', 'origin', 'main'); - return { root, work, origin, home, git, env }; } /** A PR branch from origin/main with an `[Unreleased]` CHANGELOG entry and a doc mentioning the version it expects. */ diff --git a/test/pregate-user-policy.test.ts b/test/pregate-user-policy.test.ts index b56a32032..12c9e622d 100644 --- a/test/pregate-user-policy.test.ts +++ b/test/pregate-user-policy.test.ts @@ -6,7 +6,7 @@ * requires-remote. Every assertion holds on any host platform: each lane the * fixture selects is either named in `local` or asserted only by label. */ -import { afterEach, describe, expect, test } from 'bun:test'; +import { afterAll, afterEach, describe, expect, test } from 'bun:test'; import { spawnSync } from 'node:child_process'; import * as fs from 'node:fs'; import * as os from 'node:os'; @@ -15,18 +15,20 @@ import { EXIT } from '../lib/headless-artifacts'; import { loadConfigFromUser, validateConfig } from '../lib/pregate/config'; import { buildContext, sha256, userConfigRejection } from '../lib/pregate/context'; import { runPregate, verifyPregate } from '../lib/pregate/index'; -import { BASE_PREGATE, BIN, cleanup, commit, makeFixtureRepo, runBin, type FixtureRepo } from './helpers/pregate-fixture'; +import { BASE_PREGATE, BIN, cleanup, cloneFixture, commit, fixtureTemplate, makeFixtureRepo, runBin, type FixtureRepo } from './helpers/pregate-fixture'; import { runScriptSync } from '../lib/script-invocation'; const repos: FixtureRepo[] = []; afterEach(() => { while (repos.length) cleanup(repos.pop()!); }); +const noBasePolicy = fixtureTemplate({ pregate: null }); +afterAll(() => noBasePolicy.dispose()); const SLUG = 'userpolicy'; const LOCAL_ALL = ['free-tests.yml/unit', 'windows.yml/windows-shard']; /** A fixture whose base carries no `.gstack/pregate.json`, a feature commit touching lib/util.js, and the user-file path for its slug. */ function setup(): { repo: FixtureRepo; userPolicy: string; run: (extra?: string[]) => ReturnType } { - const repo = makeFixtureRepo({ pregate: null }); + const repo = cloneFixture(noBasePolicy); repos.push(repo); commit(repo, { 'lib/util.js': 'exports.add = (a, b) => b + a;\n' }); const userPolicy = path.join(repo.home, 'projects', SLUG, 'pregate.json'); diff --git a/test/release-cut.test.ts b/test/release-cut.test.ts index f9b45efeb..6986b5630 100644 --- a/test/release-cut.test.ts +++ b/test/release-cut.test.ts @@ -6,12 +6,12 @@ * check run or a red one keeps a commit out; a cut that stopped half-way is * finished by the next run (docs/releases.md). */ -import { afterEach, describe, expect, test } from 'bun:test'; +import { afterAll, 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'; import { bunIsolationArgs } from '../lib/bun-isolation'; +import { gitFixtureTemplate, rebindGitDir } from './helpers/git-fixture-template'; import { BACKSTOP_CHECK, FLOOR_HOURS, NOTES_CAP, NOTES_REF, REQUIRED_CHECK, SCHEDULED_GRACE_HOURS, changelogEntries, compareVersions, cutRelease, describePlan, floorStatus, gateVerdict, gitIn, planRelease, readTrainNotes, releaseNotes, sanitizeReason, stableMovedAt, verifyRegression, verifySecurity, type Gh, type Git, @@ -55,19 +55,10 @@ function fakeGh(state: State): Gh { }; } -function fixture() { - const base = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-release-cut-')); - bases.push(base); - const origin = path.join(base, 'origin.git'); - const seed = path.join(base, 'seed'); - const work = path.join(base, 'work'); - execFileSync('git', ['init', '-q', '--bare', '-b', 'main', origin], { timeout: 30_000 }); - fs.mkdirSync(seed); - git(seed, 'init', '-q', '-b', 'main'); - git(seed, 'remote', 'add', 'origin', origin); - const shas: Record = {}; - let changelog = '# Changelog\n'; - const release = (version: string, headline: string, o: { message?: string; files?: Record } = {}) => { +/** Commits a release (VERSION, a CHANGELOG headline, optional files) in seed and pushes it to origin. */ +function releaser(seed: string, shas: Record) { + let changelog = fs.existsSync(path.join(seed, 'CHANGELOG.md')) ? fs.readFileSync(path.join(seed, 'CHANGELOG.md'), 'utf8') : '# Changelog\n'; + return (version: string, headline: string, o: { message?: string; files?: Record } = {}) => { changelog = changelog.replace('# Changelog\n', '# Changelog\n\n## [' + version + '] - 2026-10-2' + version.slice(-1) + '\n\n**' + headline + '**\n\n### Fixed\n- a thing\n'); fs.writeFileSync(path.join(seed, 'VERSION'), version + '\n'); fs.writeFileSync(path.join(seed, 'CHANGELOG.md'), changelog); @@ -78,11 +69,39 @@ function fixture() { shas[version] = git(seed, 'rev-parse', 'HEAD'); return shas[version]!; }; - release('1.0.0', 'First one.'); - release('1.0.1', 'Second: the updater no longer skips.'); - release('1.0.2', 'Third fixes Windows.'); - release('1.0.3', 'Fourth trims the preamble.'); - execFileSync('git', ['clone', '-q', origin, work], { timeout: 30_000 }); +} + +/** origin with releases 1.0.0..1.0.3 pushed from seed, and work cloned from it; built once for this file, copied per test. */ +const template = gitFixtureTemplate({ + prefix: 'gstack-release-cut-', + build: (base) => { + const origin = path.join(base, 'origin.git'); + const seed = path.join(base, 'seed'); + execFileSync('git', ['init', '-q', '--bare', '-b', 'main', origin], { timeout: 30_000 }); + fs.mkdirSync(seed); + git(seed, 'init', '-q', '-b', 'main'); + git(seed, 'remote', 'add', 'origin', origin); + const shas: Record = {}; + const release = releaser(seed, shas); + release('1.0.0', 'First one.'); + release('1.0.1', 'Second: the updater no longer skips.'); + release('1.0.2', 'Third fixes Windows.'); + release('1.0.3', 'Fourth trims the preamble.'); + execFileSync('git', ['clone', '-q', origin, path.join(base, 'work')], { timeout: 30_000 }); + return shas; + }, + rebind: (copy, tpl) => { for (const repo of ['seed', 'work']) rebindGitDir(path.join(copy, repo, '.git'), tpl, copy); }, +}); +afterAll(() => template.dispose()); + +function fixture() { + const { root: base, meta } = template.copy(); + bases.push(base); + const origin = path.join(base, 'origin.git'); + const seed = path.join(base, 'seed'); + const work = path.join(base, 'work'); + const shas: Record = { ...meta }; + const release = releaser(seed, shas); const refresh = () => { git(work, 'fetch', '-q', 'origin', '+refs/heads/*:refs/remotes/origin/*'); git(work, 'fetch', '-q', '--tags', 'origin'); }; const state: State = { checks: {}, released: new Set(), calls: [] }; return { base, origin, seed, work, shas, release, refresh, state, gh: fakeGh(state), git: gitIn(work), repo: 'o/r' }; diff --git a/test/restamp-mentions.test.ts b/test/restamp-mentions.test.ts index 0fc839989..a59c0e9c4 100644 --- a/test/restamp-mentions.test.ts +++ b/test/restamp-mentions.test.ts @@ -9,16 +9,26 @@ * version moves only on lines the branch added), and the negative control * (a base-side quoted row carrying that version stays). */ -import { afterEach, describe, expect, test } from 'bun:test'; +import { afterAll, afterEach, describe, expect, test } from 'bun:test'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { EXIT } from '../lib/headless-artifacts'; import { rewriteStamps, tentativeMentions } from '../lib/restamp'; -import { BASE_POLICY, cleanup, makeFixtureRepo, makePr, mergeMain, mergeToMain, readFile, restamp, type FixtureRepo } from './helpers/restamp-fixture'; +import type { GitFixtureTemplate } from './helpers/git-fixture-template'; +import { BASE_POLICY, cleanup, cloneFixture, fixtureTemplate, makePr, mergeMain, mergeToMain, readFile, restamp, type FixtureRepo } from './helpers/restamp-fixture'; const repos: FixtureRepo[] = []; afterEach(() => { while (repos.length) cleanup(repos.pop()!); }); +/** The helper's makeFixtureRepo, built once per policy for this file and copied per test. */ +const templates = new Map>(); +afterAll(() => { for (const t of templates.values()) t.dispose(); }); +function makeFixtureRepo(opts: { prefix: string; policy: Record }): FixtureRepo { + const key = JSON.stringify(opts.policy); + if (!templates.has(key)) templates.set(key, fixtureTemplate({ policy: opts.policy, prefix: 'restamp-mentions-' })); + return cloneFixture(templates.get(key)!, opts.prefix); +} + const stageDetail = (out: string, name: string) => new RegExp(`^stage ${name}: (\\w+)(?: \\((.*)\\))?$`, 'm').exec(out); describe('quoted stamp forms (pure)', () => { diff --git a/test/train-backstop.test.ts b/test/train-backstop.test.ts index b71244faf..322f59c8f 100644 --- a/test/train-backstop.test.ts +++ b/test/train-backstop.test.ts @@ -17,7 +17,8 @@ import type { GhRunner } from '../scripts/recover-receipts'; import { runBackstop } from '../scripts/train-backstop'; import { gitIn, trainPrefixes, type TrainRecord } from '../scripts/train-gate'; import { E2E_TOUCHFILES } from './helpers/touchfiles-data'; -import { BASE_POLICY, cleanup, makeFixtureRepo, type FixtureRepo } from './helpers/restamp-fixture'; +import { gitFixtureTemplate, rebindGitDir, type GitFixtureTemplate } from './helpers/git-fixture-template'; +import { BASE_POLICY, cleanup, fixtureRepoAt, seedFixtureRepo, type FixtureRepo } from './helpers/restamp-fixture'; import { TRAIN_BLOCK, TRAIN_POLICY } from './helpers/ship-queue-train-fixture'; const REPO = 'fixture/repo'; @@ -30,11 +31,24 @@ afterEach(() => { if (savedStateRoot === undefined) delete process.env.GSTACK_ST interface Fixture { repo: FixtureRepo; main0: string; prs: string[]; train: string[]; record: TrainRecord } -/** Five PR branches from main and the train T1..T5 gstack-ship-queue would build from them (lib/ship-queue-train.ts buildChain). */ +/** Five PR branches from main and the train T1..T5 gstack-ship-queue would build from them, built once per policy and copied per test. */ +const templates = new Map>>(); +afterAll(() => { for (const t of templates.values()) t.dispose(); }); function trainFixture(policy: Record = TRAIN_POLICY): Fixture { - const repo = makeFixtureRepo({ policy, prefix: 'train-backstop-fx-' }); + const key = JSON.stringify(policy); + if (!templates.has(key)) templates.set(key, gitFixtureTemplate({ prefix: 'train-backstop-fx-', build: root => buildTrain(root, policy), rebind: (copy, tpl) => rebindGitDir(path.join(copy, 'work', '.git'), tpl, copy) })); + const { root, meta } = templates.get(key)!.copy(); + const repo = fixtureRepoAt(root); repos.push(repo); process.env.GSTACK_STATE_ROOT = path.join(repo.root, 'state'); + return { repo, ...structuredClone(meta) }; +} + +/** lib/ship-queue-train.ts buildChain's T1..T5 over five PR branches from main. */ +function buildTrain(root: string, policy: Record): Omit { + seedFixtureRepo(root, { policy }); + const repo = fixtureRepoAt(root); + process.env.GSTACK_STATE_ROOT = path.join(repo.root, 'state'); const main0 = repo.git('rev-parse', 'HEAD'); const prs = [1, 2, 3, 4, 5].map(i => { repo.git('checkout', '-q', '-B', `pr${i}`, main0); @@ -56,7 +70,7 @@ function trainFixture(policy: Record = TRAIN_POLICY): Fixture { const record: TrainRecord = { schema: 1, kind: 'train-gate', nonce: TRAIN_NONCE, head_sha: parent, tree: repo.git('rev-parse', `${parent}^{tree}`), verdict: 'GREEN', census: { complete: true, planned: { rule: 108, judge: 24 }, executed: { rule: 108, judge: 24 }, unbound: 0, partial_shards: 0, cancelled_shards: 0, killed_before_upload: 0 }, panels: [], run: { id: 900, attempt: 1, url: 'https://example.invalid/runs/900' }, prefixes }; - return { repo, main0, prs, train, record }; + return { main0, prs, train, record }; } /** The coordinator's squash merge of PR i onto main, stamped at merge (VERSION, package.json, the release output). */ From 4ebbd4bee9eda8357590bbf10bed3c19acb6cf11 Mon Sep 17 00:00:00 2001 From: garrytan Date: Thu, 29 Oct 2026 04:34:34 +0000 Subject: [PATCH 2/3] test(free): baseline the git fixture template regression test in the test-of-test ratchet Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- test/test-of-test-ratchet.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/test-of-test-ratchet.test.ts b/test/test-of-test-ratchet.test.ts index 983429d25..cce536f8a 100644 --- a/test/test-of-test-ratchet.test.ts +++ b/test/test-of-test-ratchet.test.ts @@ -101,6 +101,7 @@ const BASELINE = [ 'test/extension-pty-inject-invariant.test.ts', 'test/gbrain-source-worktree-advance.test.ts', 'test/gbrain-sync-voyage-code-3-integration.test.ts', + 'test/git-fixture-template.test.ts', // GSTA-151: pins the copy isolation and leak scan of test/helpers/git-fixture-template.ts, which the templated git fixtures in release-cut, train-backstop, restamp-mentions, pregate-user-policy and held-install-recovery rely on 'test/gstack-gbrain-source-wireup.test.ts', 'test/gstack-home-module-scope.test.ts', 'test/gstack-upgrade-migration-v1_17_0_0.test.ts', From 6e066620dadc46008454178f81ada0ef46e4e210 Mon Sep 17 00:00:00 2001 From: garrytan Date: Thu, 29 Oct 2026 04:42:11 +0000 Subject: [PATCH 3/3] docs(changelog): unreleased entry for the git fixture templates (GSTA-151 C) --- CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f6cfab56f..febc39c1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## [Unreleased] + +**Five free-test files stop rebuilding the same git repositories for every test: they spend 17–24 s less in summed per-file runtime, with every assertion intact.** Each test still gets its own private copy of the fixture. + +### Tests +- `test/helpers/git-fixture-template.ts` (new): builds a git fixture once per test file and copies it per test with `fs.cpSync`. The adopter rewrites every path-bearing file (remote URLs, alternates, state roots), and the copy is then scanned for any remaining reference to the template, so a test can never push into or read from the shared template. `test/git-fixture-template.test.ts` pins two-copy isolation and the leak scan. +- `test/release-cut.test.ts` (~10 s to ~6 s), `test/train-backstop.test.ts` (~4 s to ~1.6 s), `test/restamp-mentions.test.ts` (via `test/helpers/restamp-fixture.ts`), `test/pregate-user-policy.test.ts` (via `test/helpers/pregate-fixture.ts`) and `test/held-install-recovery.test.ts` (~33 s to ~22 s) use the template. Test and `expect()` counts are unchanged in every file. +- `test/helpers/restamp-fixture.ts` turns off auto-maintenance (`maintenance.auto=false`, `gc.auto=0`) in the bare origin the ship-queue train tests push to. +- `test/test-of-test-ratchet.test.ts`: one BASELINE row for the new helper test, which tests a test helper by design. + ## [1.91.304.0] - 2026-10-29 **Running the Capy installer for a second workflow on the same machine no longer takes away the first one's admission.** /ship's docs step runs `scripts/install.sh --for document-release`, and that run replaced the `--for ship` install verdict with its narrower one, so the rest of /ship refused with "the install verdict certified for=document-release, which does not cover codex that ship needs" until someone reran `--for ship` (#3561, seen on gstack-evals#13).