mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-03 15:27:56 +01:00
v1.92.70.0 test(free): build git fixtures once per file and copy them per test (GSTA-151 C) (#3586)
* 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(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> * docs(changelog): unreleased entry for the git fixture templates (GSTA-151 C) --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
7762c39a4e
commit
cd788faec7
14 files changed
+385
-46
No files matched your search
@@ -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. */
|
||||
|
||||
Reference in new issue
Block a user