mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +01:00
train T3: merge #3586 at 6e066620da
This commit is contained in:
commit
7029e0c73e
11 files changed
+382
-43
No files matched your search
@@ -2,6 +2,16 @@
|
||||
|
||||
## [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.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Tests
|
||||
- `test/review-depth.test.ts`: the #19 shape plans fix-check, Red Team and native adversarial; full depth, a fail-closed classification, `--depth-floor full`, a 151-line, missing or unread range and a gate-touching fix keep the full pass; a forced specialist and changed numbers keep their reviewers; 150, 151 and a docs-only range; the scoped pass counts toward the cap and a blocker it leaves stops; `DEPTH_RULES` moves with the constant.
|
||||
- `test/review-plan-cli.test.ts`: `--range-base` plans `NEXT: scoped`; a 151-line range, no range, an unknown base and uncommitted fixes plan full.
|
||||
|
||||
@@ -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<typeof copy>, 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)']);
|
||||
});
|
||||
});
|
||||
@@ -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<typeof makeFixture>;
|
||||
|
||||
|
||||
@@ -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<T> { root: string; meta: T }
|
||||
export interface GitFixtureTemplate<T> { copy: (prefix?: string) => GitFixtureCopy<T>; 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<T>(opts: {
|
||||
prefix: string;
|
||||
build: (root: string) => T;
|
||||
rebind: (copyRoot: string, templateRoot: string) => void;
|
||||
realpath?: boolean;
|
||||
}): GitFixtureTemplate<T> {
|
||||
let template: GitFixtureCopy<T> | 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<string, unknown> | null; pregate?: Record<string, unknown> | null; prefix?: string; baseFiles?: Record<string, string> } = {}): FixtureRepo {
|
||||
export type FixtureOptions = { registry?: Record<string, unknown> | null; pregate?: Record<string, unknown> | null; prefix?: string; baseFiles?: Record<string, string> };
|
||||
|
||||
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<null> {
|
||||
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<unknown>, 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<string, unknown> | 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 {
|
||||
|
||||
@@ -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<string, unknown> | null; version?: string; prefix?: string } = {}): FixtureRepo {
|
||||
export type FixtureOptions = { policy?: Record<string, unknown> | 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<null> {
|
||||
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<unknown>, 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<string, unknown> | 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. */
|
||||
|
||||
@@ -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<typeof runBin> } {
|
||||
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');
|
||||
|
||||
+39
-20
@@ -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<string, string> = {};
|
||||
let changelog = '# Changelog\n';
|
||||
const release = (version: string, headline: string, o: { message?: string; files?: Record<string, string> } = {}) => {
|
||||
/** Commits a release (VERSION, a CHANGELOG headline, optional files) in seed and pushes it to origin. */
|
||||
function releaser(seed: string, shas: Record<string, string>) {
|
||||
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<string, string> } = {}) => {
|
||||
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<string, string> = {};
|
||||
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<string, string> = { ...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' };
|
||||
|
||||
@@ -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<string, GitFixtureTemplate<null>>();
|
||||
afterAll(() => { for (const t of templates.values()) t.dispose(); });
|
||||
function makeFixtureRepo(opts: { prefix: string; policy: Record<string, unknown> }): 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)', () => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<string, GitFixtureTemplate<Omit<Fixture, 'repo'>>>();
|
||||
afterAll(() => { for (const t of templates.values()) t.dispose(); });
|
||||
function trainFixture(policy: Record<string, unknown> = 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<string, unknown>): Omit<Fixture, 'repo'> {
|
||||
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<string, unknown> = 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). */
|
||||
|
||||
Reference in new issue
Block a user