enhance(#2721): regenerating merge driver, regen:derived, and a name for the emitted-artifact family (#2730)
* test(#2721): failing-first suite for the gsd-regen driver and CONTEXT.md parity Tests precede the implementation per the TDD gate. The driver module does not exist yet, so tests/git-merge-regen-driver.test.cjs fails at require time; the contributor-standards parity assertions fail against next as it stands today, where the standards doc names two CONTEXT.md headings that have never existed. Refs #2721 * feat(#2721): add the gsd-regen merge driver and regen:derived The golden parity manifests and the two size baselines are pure functions of the source tree, so their only correct merge is "recompute" -- something git's ours/theirs interface cannot express. 140 of 143 conflicted-file instances across the open PR queue are these files. The driver deliberately does NOT regenerate. Four probes established that at merge-driver time neither the working tree nor the index reflects the merge: both hold the ours side, a file added by theirs does not exist yet, and MERGE_HEAD is unwritten. Git also invokes the driver once per conflicted path (20 here). A regenerating driver would therefore read the ours-side tree and emit a plausible-but-wrong hash manifest -- worse than a conflict, because a conflict is visible. So it accepts %A, runs zero subprocesses, records the resolved paths, and prints one notice pointing at npm run regen:derived. Staleness stays caught where it already was, by golden-install-parity in CI. Every failure path degrades toward today's behaviour (a normal conflict). install-tree is deliberately excluded per ADR-2719 section 7. Also folded in, per the no-defer rule: workflow-size.cjs claimed .md files have no eol=lf in .gitattributes; git check-attr shows eol: lf, set by .gitattributes line 2 since #1088. Refs #2721 * docs(#2721): document regen:derived and the gsd-regen merge driver Adds the how-to a contributor actually reaches for when the generated parity manifests or size baselines conflict, in both places they would look: the merge-conflict path in CONTRIBUTING.md and the full guide in TESTING-SUITES.md, including what the driver deliberately does not do (it does not clear GitHub's CONFLICTING label, and it does not regenerate mid-merge). Also scopes the new contributor-standards parity assertion to the doc's own CONTEXT.md section. Its first run flagged `## Decision`, `## Consequences` and `## Standards followed`, which the doc attributes to an ADR body and a PR body rather than to CONTEXT.md -- a doc-wide extractor would have demanded CONTEXT.md grow headings that do not belong to it. Refs #2721 * fix(#2721): stop passing %P to the merge driver — shell injection The isolated adversarial review found, and I independently reproduced, local arbitrary command execution. Git does not invoke a merge driver with an argv array. It substitutes %O %A %B %L %P textually into the configured string and runs the whole thing through a shell, and $(...) executes inside POSIX double quotes -- so quoting the placeholder does not neutralise it. %O/%A/%B are git-generated temp names and %L is an integer, but %P is the file's own path, chosen freely by any contributor. A branch renaming a covered fixture to evil$(touch PWNED_SENTINEL).json executed that command on the machine of every maintainer who merged it, and the merge still reported success. Fix removes the input rather than filtering it: %P is no longer registered, so the driver receives no attacker-controlled argument at all. The marker records a count instead of path names. A metacharacter filter would have been a guess about shell grammar; passing nothing is a property. Re-ran the identical exploit against the fixed command: nothing executed, conflict still resolved. Two regressions guard it -- a platform-independent assertion that the registered command carries no %P, and a real merge driven by the actual planInstall output with a $(...) filename. Also from review: CLI dispatch had no coverage at all (CONTRIBUTING's "CLI and command routing" matrix), which is why runInstall/runStatus now take {repoRoot} -- hardcoding REPO_ROOT was what made them untestable. Renamed planResolution to resolveAndRecord since the plan* prefix promised purity it did not have. Reconciled the eleven-vs-twelve generator count across CONTEXT.md, CONTRIBUTING.md and the changeset. Refs #2721 * test(#2721): scope safe.directory for the check-attr helper The 66f4d85a run failed 11 assertions, all in the .gitattributes scoping block, with "fatal: detected dubious ownership in repository at '/work'". The test container checks the repo out at a path its user does not own, so git refuses check-attr outright. Everything else passed (27,185). `check-attr` is a pure read of .gitattributes -- no hooks, no filters -- so the exemption is scoped to that one invocation. It is deliberately NOT applied to the driver's own production `git config` calls, which run in the user's own clone and should keep the protection. Refs #2721 * test(#2721): delete the stale assertion that the driver command carries %P The plex2 run on bdfd0856 left exactly two failures, both this test: it still asserted the pre-fix command string, i.e. the vulnerable behaviour. Deleted rather than relaxed, per RULESET.TESTS.delete-bad-tests -- its useful half is already covered, in both directions, by registeredDriverCommandNeverPassesThePlaceholderForTheFilePath. Refs #2721 * test(#2721): drive the end-to-end merges from the real planInstall output The e2e helper hand-rolled its own driver registration, and still carried %P. That meant the five real-git tests were not exercising the production command string at all -- planInstall could drift and they would keep passing. They now register exactly what a contributor gets from npm run setup:merge-driver. Refs #2721 * chore(#2721): backfill changeset pr number to 2730
This commit is contained in:
@@ -81,6 +81,139 @@ describe('docs/contributor-standards.md', () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Parity: docs/contributor-standards.md tells contributors which CONTEXT.md sections to
|
||||
* write into. If it names a heading CONTEXT.md does not have, the instruction is
|
||||
* unfollowable — and that is not hypothetical: on 2026-07-27 it named `## Domain terms`
|
||||
* and `## AI Ops Memory`, neither of which has ever existed (#2721). Two surfaces, one
|
||||
* truth; this asserts they cannot diverge again.
|
||||
*/
|
||||
const CONTEXT_MD = path.join(REPO_ROOT, 'CONTEXT.md');
|
||||
|
||||
/**
|
||||
* The body of one `## ` section, exclusive of the next `## `. Split on /\r?\n/ so a CRLF
|
||||
* checkout parses identically. Shared by every assertion below — two copies of the same
|
||||
* section parser is exactly the silent divergence RULESET.SHARED-HELPERS-LINT-VS-TEST warns of.
|
||||
*/
|
||||
function sectionBody(content, heading) {
|
||||
const lines = content.split(/\r?\n/);
|
||||
const start = lines.findIndex((l) => l.trim() === heading);
|
||||
if (start === -1) return null;
|
||||
const rest = lines.slice(start + 1);
|
||||
const end = rest.findIndex((l) => /^##\s/.test(l));
|
||||
return (end === -1 ? rest : rest.slice(0, end)).join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Backticked heading references that the standards doc attributes to CONTEXT.md.
|
||||
*
|
||||
* Scoped to the doc's own `## CONTEXT.md` section on purpose. The doc also names headings
|
||||
* belonging to *other* documents — `## Decision` and `## Consequences` describe an ADR
|
||||
* body, `## Standards followed` describes an issue/PR body. Extracting doc-wide would
|
||||
* demand CONTEXT.md grow headings that have nothing to do with it.
|
||||
*
|
||||
* `<Placeholder>` templates like `### <Module Name>` are skipped: they are shapes to
|
||||
* follow, not headings to find.
|
||||
*/
|
||||
function extractContextHeadingRefs(standardsContent) {
|
||||
const scope = sectionBody(standardsContent, '## CONTEXT.md');
|
||||
if (scope === null) return [];
|
||||
const refs = new Set();
|
||||
for (const m of scope.matchAll(/`(#{2,6}\s+[^`]+)`/g)) {
|
||||
const heading = m[1].trim();
|
||||
if (heading.includes('<')) continue;
|
||||
refs.add(heading);
|
||||
}
|
||||
return [...refs];
|
||||
}
|
||||
|
||||
/** Headings actually present in CONTEXT.md. Anchored /m — CRLF-safe without a `\n` split. */
|
||||
function actualHeadings(contextContent) {
|
||||
return new Set([...contextContent.matchAll(/^#{2,6}\s+.*$/gm)].map((m) => m[0].trim()));
|
||||
}
|
||||
|
||||
describe('docs/contributor-standards.md ↔ CONTEXT.md heading parity', () => {
|
||||
test('everyContextHeadingNamedByTheStandardsDocExistsInContextMd', () => {
|
||||
const refs = extractContextHeadingRefs(readStandardsDoc());
|
||||
const actual = actualHeadings(fs.readFileSync(CONTEXT_MD, 'utf-8'));
|
||||
|
||||
assert.ok(refs.length > 0, 'the standards doc must name at least one CONTEXT.md heading');
|
||||
const missing = refs.filter((r) => !actual.has(r));
|
||||
assert.deepEqual(
|
||||
missing,
|
||||
[],
|
||||
`docs/contributor-standards.md directs contributors to heading(s) that do not exist in ` +
|
||||
`CONTEXT.md: ${JSON.stringify(missing)}. Fix the standards doc (or add the heading).`
|
||||
);
|
||||
});
|
||||
|
||||
test('contextMdStillHasTheGlossaryHeadingTheStandardsDocNamed', () => {
|
||||
const actual = actualHeadings(fs.readFileSync(CONTEXT_MD, 'utf-8'));
|
||||
assert.ok(
|
||||
actual.has('## Glossary — Domain modules and seams'),
|
||||
'the glossary heading is the one the standards doc points Module authors at'
|
||||
);
|
||||
});
|
||||
|
||||
// Negative space for the extractor itself. The RED run of this suite flagged
|
||||
// `## Decision`, `## Consequences` and `## Standards followed` — all headings the doc
|
||||
// attributes to an ADR body or a PR body, not to CONTEXT.md. A doc-wide extractor would
|
||||
// demand CONTEXT.md sprout headings that do not belong to it.
|
||||
test('doesNotTreatAdrOrPrBodyHeadingsAsContextMdClaims', () => {
|
||||
const refs = extractContextHeadingRefs(readStandardsDoc());
|
||||
for (const foreign of ['## Decision', '## Consequences', '## Standards followed']) {
|
||||
assert.ok(
|
||||
!refs.includes(foreign),
|
||||
`${foreign} describes another document's structure and must not be read as a CONTEXT.md claim`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('matchesAHeadingReferenceRegardlessOfLineEndingStyle', () => {
|
||||
const crlf = '## Test rules and lint\r\n\r\n### Emitted Artifact Provenance\r\n';
|
||||
const found = actualHeadings(crlf);
|
||||
assert.ok(found.has('## Test rules and lint'), 'a CRLF checkout must not defeat the match');
|
||||
assert.ok(found.has('### Emitted Artifact Provenance'));
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The Emitted Artifact Provenance naming deliverable (#2721). Without these, the artifact
|
||||
* family that half the open PR queue collides on still has no name a contributor can look
|
||||
* up — which ADR-2719 identifies as a direct cause of the problem.
|
||||
*/
|
||||
describe('CONTEXT.md names the emitted-artifact family', () => {
|
||||
test('emittedAttributionRulesetIsUnderTheTestRulesAndLintSection', () => {
|
||||
const body = sectionBody(fs.readFileSync(CONTEXT_MD, 'utf-8'), '## Test rules and lint');
|
||||
assert.ok(body, 'CONTEXT.md must have a `## Test rules and lint` section');
|
||||
assert.ok(
|
||||
body.includes('RULESET.EMITTED_ATTRIBUTION='),
|
||||
'RULESET.EMITTED_ATTRIBUTION must be a sibling of the other test rules, not floating elsewhere'
|
||||
);
|
||||
});
|
||||
|
||||
test('emittedArtifactProvenanceIsRegisteredInTheGlossary', () => {
|
||||
const body = sectionBody(
|
||||
fs.readFileSync(CONTEXT_MD, 'utf-8'),
|
||||
'## Glossary — Domain modules and seams'
|
||||
);
|
||||
assert.ok(body, 'CONTEXT.md must have the glossary section');
|
||||
assert.ok(
|
||||
body.includes('### Emitted Artifact Provenance'),
|
||||
'the emitted-artifact family must be registered in the glossary'
|
||||
);
|
||||
});
|
||||
|
||||
test('emittedArtifactProvenanceIsAConceptNotAModule', () => {
|
||||
const headings = actualHeadings(fs.readFileSync(CONTEXT_MD, 'utf-8'));
|
||||
assert.ok(headings.has('### Emitted Artifact Provenance'));
|
||||
assert.ok(
|
||||
!headings.has('### Emitted Artifact Provenance Module'),
|
||||
'it ships nothing, so it takes no `Module` suffix — follows the `### Resolution Provenance` precedent'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CONTRIBUTING.md links contributor-standards.md', () => {
|
||||
test('CONTRIBUTING.md contains link to contributor-standards.md', () => {
|
||||
let contributing;
|
||||
|
||||
879
tests/git-merge-regen-driver.test.cjs
Normal file
879
tests/git-merge-regen-driver.test.cjs
Normal file
@@ -0,0 +1,879 @@
|
||||
|
||||
/**
|
||||
* Tests for the `gsd-regen` merge driver (#2721, epic #2719, ADR-2719 Phase 1).
|
||||
*
|
||||
* Design + behavior table: .gsd/phase/feat-2721-merge-driver-and-regen-derived/40-design.md
|
||||
* Test matrix: .gsd/phase/feat-2721-merge-driver-and-regen-derived/50-test-matrix.md
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const cp = require('node:child_process');
|
||||
|
||||
const { createTempDir, cleanup } = require('./helpers.cjs');
|
||||
|
||||
const REPO_ROOT = path.join(__dirname, '..');
|
||||
const DRIVER_PATH = path.join(REPO_ROOT, 'scripts', 'git-merge-regen-driver.cjs');
|
||||
|
||||
const {
|
||||
ACTION,
|
||||
REASON,
|
||||
GITDIR_SOURCE,
|
||||
NOTICE_WINDOW_MS,
|
||||
resolveGitDir,
|
||||
resolveAndRecord,
|
||||
planInstall,
|
||||
} = require(DRIVER_PATH);
|
||||
|
||||
// --- helpers ---------------------------------------------------------------
|
||||
|
||||
const GIT_TIMEOUT_MS = 30_000;
|
||||
|
||||
function git(cwd, args) {
|
||||
return cp.spawnSync('git', args, {
|
||||
cwd,
|
||||
encoding: 'utf8',
|
||||
timeout: GIT_TIMEOUT_MS,
|
||||
env: { ...process.env, GIT_CONFIG_NOSYSTEM: '1', HOME: cwd, GIT_TERMINAL_PROMPT: '0' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* `git check-attr <attr> -- <path>` in the real repo. Returns the attribute value.
|
||||
*
|
||||
* `-c safe.directory=…` is required, not incidental: the test container checks this repo
|
||||
* out at a path its user does not own, and git then refuses every command with
|
||||
* "detected dubious ownership in repository at '/work'". `check-attr` is a pure read of
|
||||
* `.gitattributes` — no hooks, no filters — so scoping the exemption to this one
|
||||
* invocation is safe. It is deliberately NOT applied to the driver's own production
|
||||
* `git config` calls, which run in the user's own clone and should keep the protection.
|
||||
* Forward slashes unconditionally: git wants them in this value on every platform.
|
||||
*/
|
||||
function checkAttr(attr, relPath) {
|
||||
const safeRoot = REPO_ROOT.replace(/\\/g, '/');
|
||||
const r = cp.spawnSync('git', ['-c', `safe.directory=${safeRoot}`, 'check-attr', attr, '--', relPath], {
|
||||
cwd: REPO_ROOT,
|
||||
encoding: 'utf8',
|
||||
timeout: GIT_TIMEOUT_MS,
|
||||
});
|
||||
assert.equal(r.status, 0, `git check-attr failed: ${r.stderr}`);
|
||||
// Format: "<path>: <attr>: <value>" — the value is everything after the last ": ".
|
||||
const line = String(r.stdout).trim();
|
||||
const idx = line.lastIndexOf(': ');
|
||||
return idx === -1 ? '' : line.slice(idx + 2);
|
||||
}
|
||||
|
||||
/** Replace an fs method with a throwing stub for the duration of `fn`, then restore. */
|
||||
function withFsFailure(method, fn) {
|
||||
const original = fs[method];
|
||||
fs[method] = () => {
|
||||
throw Object.assign(new Error('injected'), { code: 'EACCES' });
|
||||
};
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
fs[method] = original;
|
||||
}
|
||||
}
|
||||
|
||||
/** Write an "ours" temp file and return its path — production always receives a real %A. */
|
||||
function writeOurs(dir, content = 'ours-content\n') {
|
||||
const p = path.join(dir, '.merge_file_OURS');
|
||||
fs.writeFileSync(p, content);
|
||||
return p;
|
||||
}
|
||||
|
||||
/**
|
||||
* The argv shape git actually supplies under the registered command: [%O, %A, %B, %L].
|
||||
* `%P` is deliberately NOT registered — see planInstall's comment on shell interpolation.
|
||||
* `extra` lets one test prove a stray 5th entry (an old registration) is ignored.
|
||||
*/
|
||||
function gitArgv(dir, ...extra) {
|
||||
const o = path.join(dir, '.merge_file_ANC');
|
||||
const b = path.join(dir, '.merge_file_THEIRS');
|
||||
fs.writeFileSync(o, '');
|
||||
fs.writeFileSync(b, 'theirs-content\n');
|
||||
return [o, writeOurs(dir), b, '7', ...extra];
|
||||
}
|
||||
|
||||
function readMarker(gitDir) {
|
||||
return JSON.parse(fs.readFileSync(path.join(gitDir, 'gsd-regen-pending.json'), 'utf8'));
|
||||
}
|
||||
|
||||
function seedMarker(gitDir, value) {
|
||||
fs.mkdirSync(gitDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(gitDir, 'gsd-regen-pending.json'),
|
||||
typeof value === 'string' ? value : JSON.stringify(value),
|
||||
);
|
||||
}
|
||||
|
||||
const GOLDEN_DIR = path.join(REPO_ROOT, 'tests', 'fixtures', 'golden-install-parity');
|
||||
const INSTALL_TREE_DIR = path.join(REPO_ROOT, 'tests', 'fixtures', 'install-tree');
|
||||
|
||||
function jsonFixturesIn(dir) {
|
||||
return fs
|
||||
.readdirSync(dir)
|
||||
.filter((f) => f.endsWith('.json'))
|
||||
.sort();
|
||||
}
|
||||
|
||||
// --- .gitattributes scoping (rows 1-10) ------------------------------------
|
||||
|
||||
describe('.gitattributes declares the gsd-regen driver for exactly the churning artifacts', () => {
|
||||
test('goldenParityFixturesDeclareGsdRegenMergeDriver', () => {
|
||||
assert.equal(
|
||||
checkAttr('merge', 'tests/fixtures/golden-install-parity/claude.json'),
|
||||
'gsd-regen',
|
||||
);
|
||||
});
|
||||
|
||||
test('allNineteenGoldenFixturesDeclareTheDriver', () => {
|
||||
const fixtures = jsonFixturesIn(GOLDEN_DIR);
|
||||
assert.ok(fixtures.length > 0, 'expected golden-install-parity fixtures to exist');
|
||||
for (const f of fixtures) {
|
||||
assert.equal(
|
||||
checkAttr('merge', `tests/fixtures/golden-install-parity/${f}`),
|
||||
'gsd-regen',
|
||||
`${f} must declare merge=gsd-regen`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('workflowSizeBaselineDeclaresTheDriver', () => {
|
||||
assert.equal(checkAttr('merge', 'tests/workflow-size-baseline.json'), 'gsd-regen');
|
||||
});
|
||||
|
||||
test('agentSizeBaselineDeclaresTheDriver', () => {
|
||||
assert.equal(checkAttr('merge', 'tests/agent-size-baseline.json'), 'gsd-regen');
|
||||
});
|
||||
|
||||
// NEGATIVE SPACE — ADR-2719 §7 keeps install-tree committed precisely so that
|
||||
// "the installer stopped shipping X" stays an absolute failure. Capturing it
|
||||
// with the driver would silently convert that absolute into an auto-resolve.
|
||||
test('installTreeFixturesAreNotCapturedByTheDriver', () => {
|
||||
assert.equal(
|
||||
checkAttr('merge', 'tests/fixtures/install-tree/claude.json'),
|
||||
'unspecified',
|
||||
'install-tree must keep normal merge semantics (ADR-2719 §7)',
|
||||
);
|
||||
});
|
||||
|
||||
test('noInstallTreeFixtureIsCapturedByTheDriver', () => {
|
||||
const fixtures = jsonFixturesIn(INSTALL_TREE_DIR);
|
||||
assert.ok(fixtures.length > 0, 'expected install-tree fixtures to exist');
|
||||
for (const f of fixtures) {
|
||||
assert.equal(
|
||||
checkAttr('merge', `tests/fixtures/install-tree/${f}`),
|
||||
'unspecified',
|
||||
`${f} must NOT be captured by the driver`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('installTreeFixturesAreNotMarkedLinguistGenerated', () => {
|
||||
assert.equal(
|
||||
checkAttr('linguist-generated', 'tests/fixtures/install-tree/claude.json'),
|
||||
'unspecified',
|
||||
'ADR-2719 §7 keeps install-tree because its diffs are readable',
|
||||
);
|
||||
});
|
||||
|
||||
test('goldenParityFixturesAreMarkedLinguistGenerated', () => {
|
||||
assert.equal(
|
||||
checkAttr('linguist-generated', 'tests/fixtures/golden-install-parity/claude.json'),
|
||||
'true',
|
||||
);
|
||||
});
|
||||
|
||||
test('sizeBaselineDeclarationsAreExactPathsNotAGlob', () => {
|
||||
assert.equal(
|
||||
checkAttr('merge', 'tests/other-size-baseline.json'),
|
||||
'unspecified',
|
||||
'the two baselines are declared by exact path, never by a tests/*-size-baseline.json glob',
|
||||
);
|
||||
});
|
||||
|
||||
test('driverPatternDoesNotCrossADirectorySeparator', () => {
|
||||
assert.equal(
|
||||
checkAttr('merge', 'tests/fixtures/golden-install-parity/sub/nested.json'),
|
||||
'unspecified',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// --- resolveGitDir (rows 11-19) --------------------------------------------
|
||||
|
||||
describe('resolveGitDir', () => {
|
||||
test('resolvesGitDirWhenDotGitIsADirectory', (t) => {
|
||||
const dir = createTempDir('gsd-regen-');
|
||||
t.after(() => cleanup(dir));
|
||||
fs.mkdirSync(path.join(dir, '.git'));
|
||||
|
||||
const r = resolveGitDir(dir);
|
||||
assert.equal(r.source, GITDIR_SOURCE.DIRECTORY);
|
||||
assert.equal(r.gitDir, path.join(dir, '.git'));
|
||||
});
|
||||
|
||||
test('resolvesGitDirFromAWorktreePointerFile', (t) => {
|
||||
const dir = createTempDir('gsd-regen-');
|
||||
t.after(() => cleanup(dir));
|
||||
const target = path.join(dir, 'real-gitdir');
|
||||
fs.mkdirSync(target);
|
||||
fs.writeFileSync(path.join(dir, '.git'), `gitdir: ${target}\n`);
|
||||
|
||||
const r = resolveGitDir(dir);
|
||||
assert.equal(r.source, GITDIR_SOURCE.GITFILE);
|
||||
assert.equal(r.gitDir, target);
|
||||
});
|
||||
|
||||
test('resolvesARelativeWorktreePointerAgainstCwd', (t) => {
|
||||
const dir = createTempDir('gsd-regen-');
|
||||
t.after(() => cleanup(dir));
|
||||
fs.mkdirSync(path.join(dir, 'nested'));
|
||||
fs.writeFileSync(path.join(dir, '.git'), 'gitdir: ./nested\n');
|
||||
|
||||
const r = resolveGitDir(dir);
|
||||
assert.equal(r.source, GITDIR_SOURCE.GITFILE);
|
||||
assert.equal(r.gitDir, path.resolve(dir, './nested'));
|
||||
});
|
||||
|
||||
test('parsesAWorktreePointerWrittenWithCrlf', (t) => {
|
||||
const dir = createTempDir('gsd-regen-');
|
||||
t.after(() => cleanup(dir));
|
||||
const target = path.join(dir, 'real-gitdir');
|
||||
fs.mkdirSync(target);
|
||||
fs.writeFileSync(path.join(dir, '.git'), `gitdir: ${target}\r\n`);
|
||||
|
||||
const r = resolveGitDir(dir);
|
||||
assert.equal(r.source, GITDIR_SOURCE.GITFILE);
|
||||
assert.equal(r.gitDir, target, 'a trailing CR must not become part of the path');
|
||||
});
|
||||
|
||||
test('parsesAWorktreePointerWithNoSpaceAfterTheColon', (t) => {
|
||||
const dir = createTempDir('gsd-regen-');
|
||||
t.after(() => cleanup(dir));
|
||||
const target = path.join(dir, 'real-gitdir');
|
||||
fs.mkdirSync(target);
|
||||
fs.writeFileSync(path.join(dir, '.git'), `gitdir:${target}\n`);
|
||||
|
||||
assert.equal(resolveGitDir(dir).gitDir, target);
|
||||
});
|
||||
|
||||
test('treatsAnEmptyPointerFileAsUnresolved', (t) => {
|
||||
const dir = createTempDir('gsd-regen-');
|
||||
t.after(() => cleanup(dir));
|
||||
fs.writeFileSync(path.join(dir, '.git'), '');
|
||||
|
||||
const r = resolveGitDir(dir);
|
||||
assert.equal(r.source, GITDIR_SOURCE.UNRESOLVED);
|
||||
assert.equal(r.gitDir, null);
|
||||
});
|
||||
|
||||
test('treatsAGarbagePointerFileAsUnresolved', (t) => {
|
||||
const dir = createTempDir('gsd-regen-');
|
||||
t.after(() => cleanup(dir));
|
||||
fs.writeFileSync(path.join(dir, '.git'), 'this is not a gitdir pointer\n');
|
||||
|
||||
assert.equal(resolveGitDir(dir).source, GITDIR_SOURCE.UNRESOLVED);
|
||||
});
|
||||
|
||||
test('treatsAMissingDotGitAsUnresolved', (t) => {
|
||||
const dir = createTempDir('gsd-regen-');
|
||||
t.after(() => cleanup(dir));
|
||||
|
||||
const r = resolveGitDir(dir);
|
||||
assert.equal(r.source, GITDIR_SOURCE.UNRESOLVED);
|
||||
assert.equal(r.gitDir, null);
|
||||
});
|
||||
|
||||
test('treatsAnUnreadablePointerFileAsUnresolved', (t) => {
|
||||
const dir = createTempDir('gsd-regen-');
|
||||
t.after(() => cleanup(dir));
|
||||
fs.writeFileSync(path.join(dir, '.git'), 'gitdir: somewhere\n');
|
||||
|
||||
const r = withFsFailure('readFileSync', () => resolveGitDir(dir));
|
||||
assert.equal(r.source, GITDIR_SOURCE.UNRESOLVED, 'must degrade, never throw');
|
||||
});
|
||||
});
|
||||
|
||||
// --- resolveAndRecord: happy & boundary (rows 20-29) -------------------------
|
||||
|
||||
describe('resolveAndRecord — resolution and the notice window', () => {
|
||||
test('acceptsOursAndNoticesOnTheFirstResolution', (t) => {
|
||||
const dir = createTempDir('gsd-regen-');
|
||||
t.after(() => cleanup(dir));
|
||||
const gitDir = path.join(dir, '.git');
|
||||
fs.mkdirSync(gitDir);
|
||||
|
||||
const r = resolveAndRecord({ argv: gitArgv(dir), gitDir, now: 1_000_000 });
|
||||
assert.equal(r.action, ACTION.ACCEPT_OURS);
|
||||
assert.equal(r.reason, REASON.OK_RESOLVED);
|
||||
assert.equal(r.exitCode, 0);
|
||||
assert.equal(r.notice, true);
|
||||
assert.equal(r.pendingCount, 1);
|
||||
});
|
||||
|
||||
test('suppressesTheNoticeForASubsequentPathInTheSameOperation', (t) => {
|
||||
const dir = createTempDir('gsd-regen-');
|
||||
t.after(() => cleanup(dir));
|
||||
const gitDir = path.join(dir, '.git');
|
||||
seedMarker(gitDir, { startedAt: 999_999, count: 1 });
|
||||
|
||||
const r = resolveAndRecord({ argv: gitArgv(dir), gitDir, now: 1_000_000 });
|
||||
assert.equal(r.notice, false);
|
||||
assert.equal(r.pendingCount, 2, 'the second conflicted path in the same operation');
|
||||
});
|
||||
|
||||
test('treatsAMarkerJustInsideTheWindowAsTheSameOperation', (t) => {
|
||||
const dir = createTempDir('gsd-regen-');
|
||||
t.after(() => cleanup(dir));
|
||||
const gitDir = path.join(dir, '.git');
|
||||
const now = 5_000_000;
|
||||
seedMarker(gitDir, { startedAt: now - (NOTICE_WINDOW_MS - 1), count: 1 });
|
||||
|
||||
assert.equal(resolveAndRecord({ argv: gitArgv(dir), gitDir, now }).notice, false);
|
||||
});
|
||||
|
||||
test('treatsAMarkerAtExactlyTheWindowAsTheSameOperation', (t) => {
|
||||
const dir = createTempDir('gsd-regen-');
|
||||
t.after(() => cleanup(dir));
|
||||
const gitDir = path.join(dir, '.git');
|
||||
const now = 5_000_000;
|
||||
seedMarker(gitDir, { startedAt: now - NOTICE_WINDOW_MS, count: 1 });
|
||||
|
||||
assert.equal(
|
||||
resolveAndRecord({ argv: gitArgv(dir), gitDir, now }).notice,
|
||||
false,
|
||||
'the window is inclusive at the limit',
|
||||
);
|
||||
});
|
||||
|
||||
test('resetsAndRenoticesForAMarkerJustOutsideTheWindow', (t) => {
|
||||
const dir = createTempDir('gsd-regen-');
|
||||
t.after(() => cleanup(dir));
|
||||
const gitDir = path.join(dir, '.git');
|
||||
const now = 5_000_000;
|
||||
seedMarker(gitDir, { startedAt: now - (NOTICE_WINDOW_MS + 1), count: 7 });
|
||||
|
||||
const r = resolveAndRecord({ argv: gitArgv(dir), gitDir, now });
|
||||
assert.equal(r.notice, true, 'a later operation must not inherit the previous silence');
|
||||
assert.equal(r.pendingCount, 1, 'the stale count must reset, not accumulate');
|
||||
});
|
||||
|
||||
test('noticesOnceAcrossAllTwentyArtifactResolutions', (t) => {
|
||||
const dir = createTempDir('gsd-regen-');
|
||||
t.after(() => cleanup(dir));
|
||||
const gitDir = path.join(dir, '.git');
|
||||
fs.mkdirSync(gitDir);
|
||||
|
||||
const notices = [];
|
||||
for (let i = 0; i < 20; i += 1) {
|
||||
const r = resolveAndRecord({ argv: gitArgv(dir), gitDir, now: 2_000_000 + i });
|
||||
notices.push(r.notice);
|
||||
}
|
||||
assert.equal(notices.filter(Boolean).length, 1, 'exactly one notice for the whole operation');
|
||||
assert.equal(readMarker(gitDir).count, 20, 'this repo conflicts on 20 artifacts');
|
||||
});
|
||||
|
||||
test('countsEveryResolutionInTheOperation', (t) => {
|
||||
const dir = createTempDir('gsd-regen-');
|
||||
t.after(() => cleanup(dir));
|
||||
const gitDir = path.join(dir, '.git');
|
||||
fs.mkdirSync(gitDir);
|
||||
|
||||
resolveAndRecord({ argv: gitArgv(dir), gitDir, now: 3_000_000 });
|
||||
const r = resolveAndRecord({ argv: gitArgv(dir), gitDir, now: 3_000_001 });
|
||||
assert.equal(r.pendingCount, 2);
|
||||
});
|
||||
|
||||
test('resolvesAnAddAddConflictWhereTheAncestorIsEmpty', (t) => {
|
||||
const dir = createTempDir('gsd-regen-');
|
||||
t.after(() => cleanup(dir));
|
||||
const gitDir = path.join(dir, '.git');
|
||||
fs.mkdirSync(gitDir);
|
||||
|
||||
const argv = gitArgv(dir);
|
||||
fs.writeFileSync(argv[0], ''); // %O is a 0-byte file in the add/add case
|
||||
const r = resolveAndRecord({ argv, gitDir, now: 4_000_000 });
|
||||
assert.equal(r.action, ACTION.ACCEPT_OURS);
|
||||
assert.equal(r.exitCode, 0);
|
||||
});
|
||||
|
||||
test('resolvesOnTheMinimumThreeArgumentForm', (t) => {
|
||||
const dir = createTempDir('gsd-regen-');
|
||||
t.after(() => cleanup(dir));
|
||||
const gitDir = path.join(dir, '.git');
|
||||
fs.mkdirSync(gitDir);
|
||||
|
||||
const [o, a, b] = gitArgv(dir);
|
||||
const r = resolveAndRecord({ argv: [o, a, b], gitDir, now: 4_100_000 });
|
||||
assert.equal(r.action, ACTION.ACCEPT_OURS);
|
||||
assert.equal(r.pendingCount, 1);
|
||||
});
|
||||
|
||||
// An old registration (or a hand-edited .git/config) may still pass %P. It must be
|
||||
// inert data, never consumed — the driver's contract does not depend on it.
|
||||
test('ignoresAStrayFifthArgumentFromAnOldRegistration', (t) => {
|
||||
const dir = createTempDir('gsd-regen-');
|
||||
t.after(() => cleanup(dir));
|
||||
const gitDir = path.join(dir, '.git');
|
||||
fs.mkdirSync(gitDir);
|
||||
|
||||
const r = resolveAndRecord({
|
||||
argv: gitArgv(dir, 'tests/workflow-size-baseline.json'),
|
||||
gitDir,
|
||||
now: 4_200_000,
|
||||
});
|
||||
assert.equal(r.action, ACTION.ACCEPT_OURS);
|
||||
assert.equal(r.pendingCount, 1);
|
||||
assert.equal(r.realPath, undefined, 'no path is read from argv at all');
|
||||
});
|
||||
});
|
||||
|
||||
// --- resolveAndRecord: negative & hostile (rows 30-46) -----------------------
|
||||
|
||||
describe('resolveAndRecord — degrades toward a normal conflict, never toward a wrong resolution', () => {
|
||||
const badArgvCases = [
|
||||
['declinesRatherThanGuessingWhenArgvIsTooShort', (dir) => gitArgv(dir).slice(0, 2)],
|
||||
['declinesOnEmptyArgv', () => []],
|
||||
];
|
||||
for (const [name, build] of badArgvCases) {
|
||||
test(name, (t) => {
|
||||
const dir = createTempDir('gsd-regen-');
|
||||
t.after(() => cleanup(dir));
|
||||
const gitDir = path.join(dir, '.git');
|
||||
fs.mkdirSync(gitDir);
|
||||
|
||||
const r = resolveAndRecord({ argv: build(dir), gitDir, now: 6_000_000 });
|
||||
assert.equal(r.action, ACTION.DECLINE);
|
||||
assert.equal(r.reason, REASON.FAIL_BAD_ARGV);
|
||||
assert.equal(r.exitCode, 1, 'a non-zero exit gives git a normal conflict — today’s behavior');
|
||||
});
|
||||
}
|
||||
|
||||
test('declinesWhenTheOursSideIsMissing', (t) => {
|
||||
const dir = createTempDir('gsd-regen-');
|
||||
t.after(() => cleanup(dir));
|
||||
const gitDir = path.join(dir, '.git');
|
||||
fs.mkdirSync(gitDir);
|
||||
|
||||
const [ancestor, , theirs] = gitArgv(dir);
|
||||
const neverWritten = path.join(dir, '.merge_file_NEVER_WRITTEN');
|
||||
const r = resolveAndRecord({
|
||||
argv: [ancestor, neverWritten, theirs, '7', 'tests/workflow-size-baseline.json'],
|
||||
gitDir,
|
||||
now: 6_100_000,
|
||||
});
|
||||
assert.equal(r.action, ACTION.DECLINE);
|
||||
assert.equal(r.reason, REASON.FAIL_OURS_UNREADABLE);
|
||||
assert.equal(r.exitCode, 1);
|
||||
});
|
||||
|
||||
const blankOursCases = [
|
||||
['declinesOnAnEmptyOursPath', ''],
|
||||
['declinesOnAWhitespaceOnlyOursPath', ' '],
|
||||
];
|
||||
for (const [name, oursPath] of blankOursCases) {
|
||||
test(name, (t) => {
|
||||
const dir = createTempDir('gsd-regen-');
|
||||
t.after(() => cleanup(dir));
|
||||
const gitDir = path.join(dir, '.git');
|
||||
fs.mkdirSync(gitDir);
|
||||
|
||||
const [o, , b] = gitArgv(dir);
|
||||
const r = resolveAndRecord({ argv: [o, oursPath, b], gitDir, now: 6_200_000 });
|
||||
assert.equal(r.action, ACTION.DECLINE);
|
||||
assert.equal(r.reason, REASON.FAIL_BAD_ARGV);
|
||||
});
|
||||
}
|
||||
|
||||
// Valid JSON that is not a usable marker object. Each must be treated as absent
|
||||
// (reset + notice) rather than throwing or being read as state.
|
||||
const hostileMarkers = [
|
||||
['treatsANumericMarkerAsAbsent', '0'],
|
||||
['treatsAStringMarkerAsAbsent', '"str"'],
|
||||
['treatsAnArrayMarkerAsAbsent', '[]'],
|
||||
['treatsANullMarkerAsAbsent', 'null'],
|
||||
['treatsABooleanMarkerAsAbsent', 'true'],
|
||||
['treatsAnEmptyMarkerFileAsAbsent', ''],
|
||||
['treatsACorruptMarkerAsAbsent', '{not json at all'],
|
||||
['treatsANonNumericStartedAtAsAbsent', '{"startedAt":"yesterday","count":1}'],
|
||||
['treatsANonFiniteStartedAtAsAbsent', '{"startedAt":1e999,"count":1}'],
|
||||
['treatsANonNumericCountAsAbsent', '{"startedAt":1,"count":"three"}'],
|
||||
['treatsANonFiniteCountAsAbsent', '{"startedAt":1,"count":1e999}'],
|
||||
['treatsANegativeCountAsAbsent', '{"startedAt":1,"count":-5}'],
|
||||
['treatsAMissingCountAsAbsent', '{"startedAt":1}'],
|
||||
];
|
||||
for (const [name, raw] of hostileMarkers) {
|
||||
test(name, (t) => {
|
||||
const dir = createTempDir('gsd-regen-');
|
||||
t.after(() => cleanup(dir));
|
||||
const gitDir = path.join(dir, '.git');
|
||||
seedMarker(gitDir, raw);
|
||||
|
||||
const r = resolveAndRecord({ argv: gitArgv(dir), gitDir, now: 7_000_000 });
|
||||
assert.equal(r.action, ACTION.ACCEPT_OURS, 'a bad marker must never block the merge');
|
||||
assert.equal(r.notice, true);
|
||||
assert.equal(r.pendingCount, 1, 'an unusable marker resets rather than accumulating');
|
||||
});
|
||||
}
|
||||
|
||||
test('stillResolvesWhenTheMarkerCannotBeWritten', (t) => {
|
||||
const dir = createTempDir('gsd-regen-');
|
||||
t.after(() => cleanup(dir));
|
||||
const gitDir = path.join(dir, '.git');
|
||||
fs.mkdirSync(gitDir);
|
||||
|
||||
const argv = gitArgv(dir);
|
||||
const r = withFsFailure('writeFileSync', () =>
|
||||
resolveAndRecord({ argv, gitDir, now: 8_000_000 }),
|
||||
);
|
||||
assert.equal(r.action, ACTION.ACCEPT_OURS);
|
||||
assert.equal(r.exitCode, 0, 'a diagnostic must never fail a merge');
|
||||
});
|
||||
|
||||
test('resolvesAndNoticesEveryTimeWhenTheGitDirIsUnknown', (t) => {
|
||||
const dir = createTempDir('gsd-regen-');
|
||||
t.after(() => cleanup(dir));
|
||||
|
||||
const first = resolveAndRecord({ argv: gitArgv(dir), gitDir: null, now: 9_000_000 });
|
||||
const second = resolveAndRecord({ argv: gitArgv(dir), gitDir: null, now: 9_000_001 });
|
||||
assert.equal(first.action, ACTION.ACCEPT_OURS);
|
||||
assert.equal(first.exitCode, 0);
|
||||
assert.equal(first.notice, true);
|
||||
assert.equal(second.notice, true, 'without a marker there is nothing to dedupe against');
|
||||
});
|
||||
});
|
||||
|
||||
// --- planInstall (rows 47-50) ----------------------------------------------
|
||||
|
||||
describe('planInstall', () => {
|
||||
test('plansBothMergeDriverConfigEntries', () => {
|
||||
const keys = planInstall({ repoRoot: '/repo' }).entries.map((e) => e.key);
|
||||
assert.deepEqual(keys, ['merge.gsd-regen.name', 'merge.gsd-regen.driver']);
|
||||
});
|
||||
|
||||
test('normalizesTheDriverCommandToForwardSlashesOnEveryPlatform', () => {
|
||||
const { entries } = planInstall({ repoRoot: 'C:\\Users\\dev\\gsd-core' });
|
||||
const driver = entries.find((e) => e.key === 'merge.gsd-regen.driver').value;
|
||||
assert.ok(!driver.includes('\\'), `driver command must contain no backslash: ${driver}`);
|
||||
});
|
||||
|
||||
// `driverCommandPassesEveryPlaceholderGitProvides` lived here and asserted that the
|
||||
// command carried %P. That was the vulnerable behaviour, so the test has been deleted
|
||||
// rather than relaxed — a test that asserts the old, now-wrong behaviour is worse than
|
||||
// no test. Its useful half (the four git-generated placeholders ARE passed) is folded
|
||||
// into `registeredDriverCommandNeverPassesThePlaceholderForTheFilePath`, which asserts
|
||||
// both directions in one place.
|
||||
|
||||
test('plansIdenticalEntriesOnRepeatedInvocation', () => {
|
||||
assert.deepEqual(planInstall({ repoRoot: '/repo' }), planInstall({ repoRoot: '/repo' }));
|
||||
});
|
||||
});
|
||||
|
||||
// --- CLI dispatch (rows 62-73) ---------------------------------------------
|
||||
// CONTRIBUTING.md → "QA Matrix Requirements" / "CLI and command routing" requires a
|
||||
// negative-input matrix for any command dispatcher: unknown subcommands, duplicate and
|
||||
// conflicting flags, plus assertions on exit status and the absence of a stack trace.
|
||||
|
||||
describe('CLI dispatch', () => {
|
||||
/** Run the driver as a real child process — its output never touches this test's stdout. */
|
||||
function runCli(args, cwd = REPO_ROOT) {
|
||||
return cp.spawnSync(process.execPath, [DRIVER_PATH, ...args], {
|
||||
cwd,
|
||||
encoding: 'utf8',
|
||||
timeout: GIT_TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Swap process.stdout.write for the duration of `fn`. Same monkeypatch-and-restore-in-
|
||||
* finally shape as withFsFailure — the run* functions print, and leaking that into the
|
||||
* runner's stream is how a reporter ends up parsing a driver banner as a test result.
|
||||
*/
|
||||
function withStdoutSilenced(fn) {
|
||||
const original = process.stdout.write;
|
||||
process.stdout.write = () => true;
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
process.stdout.write = original;
|
||||
}
|
||||
}
|
||||
|
||||
/** A stack frame looks like a line beginning with whitespace + "at ". */
|
||||
function hasStackTrace(text) {
|
||||
return /^\s+at\s/m.test(String(text));
|
||||
}
|
||||
|
||||
const rejectedInvocations = [
|
||||
['rejectsAnUnknownFlag', ['--bogus']],
|
||||
['rejectsTwoConflictingFlags', ['--install', '--status']],
|
||||
['rejectsADuplicatedFlag', ['--install', '--install']],
|
||||
['rejectsAFlagCombinedWithAPositionalArgument', ['--status', 'extra']],
|
||||
];
|
||||
for (const [name, args] of rejectedInvocations) {
|
||||
test(name, () => {
|
||||
const r = runCli(args);
|
||||
assert.equal(r.status, 2, `${args.join(' ')} must exit 2, got ${r.status}: ${r.stderr}`);
|
||||
assert.ok(
|
||||
!hasStackTrace(r.stderr),
|
||||
`usage errors must not print a stack trace, got: ${r.stderr}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test('driverModeWithNoArgumentsDeclinesRatherThanCrashing', () => {
|
||||
const r = runCli([]);
|
||||
assert.equal(r.status, 1, 'too-few-args declines, which git reads as a normal conflict');
|
||||
assert.ok(!hasStackTrace(r.stderr), `expected no stack trace, got: ${r.stderr}`);
|
||||
});
|
||||
|
||||
test('statusEmitsParseableJsonWithTheDocumentedShape', () => {
|
||||
const r = runCli(['--status']);
|
||||
assert.equal(r.status, 0);
|
||||
const report = JSON.parse(r.stdout);
|
||||
assert.equal(typeof report.registered, 'boolean');
|
||||
assert.equal(typeof report.pendingCount, 'number');
|
||||
});
|
||||
|
||||
/** A scratch repo so registration never touches the developer's own .git/config. */
|
||||
function scratchRepo(t) {
|
||||
const dir = createTempDir('gsd-regen-cli-');
|
||||
t.after(() => cleanup(dir));
|
||||
git(dir, ['init', '-q', '.']);
|
||||
return dir;
|
||||
}
|
||||
|
||||
test('installRegistersBothConfigEntriesAndStatusReportsIt', (t) => {
|
||||
const dir = scratchRepo(t);
|
||||
const { statusOf, runInstall } = require(DRIVER_PATH);
|
||||
|
||||
assert.equal(statusOf({ repoRoot: dir }).registered, false, 'precondition: not registered');
|
||||
assert.equal(withStdoutSilenced(() => runInstall({ repoRoot: dir })), 0);
|
||||
assert.equal(statusOf({ repoRoot: dir }).registered, true);
|
||||
|
||||
const driver = git(dir, ['config', '--get', 'merge.gsd-regen.driver']).stdout.trim();
|
||||
assert.equal(driver, planInstall({ repoRoot: dir }).entries[1].value);
|
||||
});
|
||||
|
||||
test('installIsIdempotent', (t) => {
|
||||
const dir = scratchRepo(t);
|
||||
const { statusOf, runInstall } = require(DRIVER_PATH);
|
||||
|
||||
withStdoutSilenced(() => runInstall({ repoRoot: dir }));
|
||||
const first = git(dir, ['config', '--get-all', 'merge.gsd-regen.driver']).stdout;
|
||||
assert.equal(withStdoutSilenced(() => runInstall({ repoRoot: dir })), 0);
|
||||
const second = git(dir, ['config', '--get-all', 'merge.gsd-regen.driver']).stdout;
|
||||
|
||||
assert.equal(second, first, 'a second install must not append a duplicate value');
|
||||
assert.equal(statusOf({ repoRoot: dir }).registered, true);
|
||||
});
|
||||
|
||||
test('uninstallRemovesTheRegistration', (t) => {
|
||||
const dir = scratchRepo(t);
|
||||
const { statusOf, runInstall, runUninstall } = require(DRIVER_PATH);
|
||||
|
||||
withStdoutSilenced(() => runInstall({ repoRoot: dir }));
|
||||
assert.equal(withStdoutSilenced(() => runUninstall({ repoRoot: dir })), 0);
|
||||
assert.equal(statusOf({ repoRoot: dir }).registered, false);
|
||||
});
|
||||
|
||||
test('uninstallOnACleanRepoSucceedsRatherThanFailing', (t) => {
|
||||
const dir = scratchRepo(t);
|
||||
const { runUninstall, statusOf } = require(DRIVER_PATH);
|
||||
|
||||
assert.equal(withStdoutSilenced(() => runUninstall({ repoRoot: dir })), 0);
|
||||
assert.equal(statusOf({ repoRoot: dir }).registered, false);
|
||||
});
|
||||
|
||||
/**
|
||||
* REGRESSION — arbitrary command execution via `%P` (isolated security review, #2721).
|
||||
*
|
||||
* Git does not invoke a merge driver with an argv array: it substitutes the placeholders
|
||||
* textually into the configured string and runs the whole thing through a shell. Quoting
|
||||
* does not save you — `$(…)` executes inside POSIX double quotes. `%O`/`%A`/`%B` are
|
||||
* git-generated temp names and `%L` is an integer, but `%P` is the file's own path, which
|
||||
* any contributor names freely. Registering `"%P"` let a branch that renamed a covered
|
||||
* fixture to `evil$(touch PWNED).json` run that command on the machine of every maintainer
|
||||
* who merged it — and the merge still reported success, so nothing looked wrong.
|
||||
*
|
||||
* The structural assertion is the real guard: it is platform-independent and fails the
|
||||
* moment someone re-adds the placeholder.
|
||||
*/
|
||||
test('registeredDriverCommandNeverPassesThePlaceholderForTheFilePath', () => {
|
||||
const { entries } = planInstall({ repoRoot: REPO_ROOT });
|
||||
const driver = entries.find((e) => e.key === 'merge.gsd-regen.driver').value;
|
||||
assert.ok(
|
||||
!driver.includes('%P'),
|
||||
'git shell-interpolates %P — passing it is arbitrary command execution. Do not re-add it.',
|
||||
);
|
||||
for (const safe of ['%O', '%A', '%B', '%L']) {
|
||||
assert.ok(driver.includes(safe), `${safe} is git-generated and must still be passed`);
|
||||
}
|
||||
});
|
||||
|
||||
test('aFilenameCarryingShellSubstitutionCannotExecuteDuringAMerge', (t) => {
|
||||
const dir = createTempDir('gsd-regen-inject-');
|
||||
t.after(() => cleanup(dir));
|
||||
|
||||
git(dir, ['init', '-q', '.']);
|
||||
git(dir, ['config', 'user.email', 'test@example.com']);
|
||||
git(dir, ['config', 'user.name', 'test']);
|
||||
// Register the REAL production command string — a hand-rolled one would not regress.
|
||||
for (const { key, value } of planInstall({ repoRoot: REPO_ROOT }).entries) {
|
||||
git(dir, ['config', key, value]);
|
||||
}
|
||||
|
||||
fs.writeFileSync(path.join(dir, '.gitattributes'), 'evil*.json merge=gsd-regen\n');
|
||||
// Written with fs, so this shell never expands it — the payload is the literal name.
|
||||
const evil = 'evil$(touch PWNED_SENTINEL).json';
|
||||
const sentinel = path.join(dir, 'PWNED_SENTINEL');
|
||||
const write = (v) => fs.writeFileSync(path.join(dir, evil), `{"v":${v}}\n`);
|
||||
|
||||
write(0);
|
||||
git(dir, ['add', '-A']);
|
||||
git(dir, ['commit', '-qm', 'base']);
|
||||
const base = git(dir, ['rev-parse', 'HEAD']).stdout.trim();
|
||||
|
||||
git(dir, ['checkout', '-qb', 'ours']);
|
||||
write(1);
|
||||
git(dir, ['add', '-A']);
|
||||
git(dir, ['commit', '-qm', 'ours']);
|
||||
|
||||
git(dir, ['checkout', '-q', base]);
|
||||
git(dir, ['checkout', '-qb', 'theirs']);
|
||||
write(2);
|
||||
git(dir, ['add', '-A']);
|
||||
git(dir, ['commit', '-qm', 'theirs']);
|
||||
|
||||
git(dir, ['checkout', '-q', 'ours']);
|
||||
git(dir, ['merge', 'theirs', '-m', 'merge']);
|
||||
|
||||
assert.equal(
|
||||
fs.existsSync(sentinel),
|
||||
false,
|
||||
'a filename containing $(...) must never execute — see the regression note above',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// --- real-git end-to-end (rows 51-55) — #2721 AC1 --------------------------
|
||||
|
||||
describe('gsd-regen driver under real git operations', () => {
|
||||
/**
|
||||
* Build a repo whose `derived.json` is a stand-in for a golden fixture: both
|
||||
* branches edit a DIFFERENT workflow file and both regenerate the artifact, so
|
||||
* the artifact conflicts while the sources do not. That is exactly the #2721
|
||||
* scenario (7 of 7 conflicting PRs collide on the identical artifact set).
|
||||
*/
|
||||
function buildScenario(t, { register }) {
|
||||
const dir = createTempDir('gsd-regen-e2e-');
|
||||
t.after(() => cleanup(dir));
|
||||
|
||||
git(dir, ['init', '-q', '.']);
|
||||
git(dir, ['config', 'user.email', 'test@example.com']);
|
||||
git(dir, ['config', 'user.name', 'test']);
|
||||
if (register) {
|
||||
// Register the REAL production entries. A hand-rolled command string would let the
|
||||
// end-to-end tests keep passing while planInstall drifted — and would have kept
|
||||
// registering the %P form these tests exist to prove is gone.
|
||||
for (const { key, value } of planInstall({ repoRoot: REPO_ROOT }).entries) {
|
||||
git(dir, ['config', key, value]);
|
||||
}
|
||||
}
|
||||
|
||||
fs.mkdirSync(path.join(dir, 'workflows'));
|
||||
fs.writeFileSync(path.join(dir, '.gitattributes'), 'derived.json merge=gsd-regen\n');
|
||||
fs.writeFileSync(path.join(dir, 'workflows', 'a.md'), 'A0\n');
|
||||
fs.writeFileSync(path.join(dir, 'workflows', 'b.md'), 'B0\n');
|
||||
fs.writeFileSync(path.join(dir, 'derived.json'), '{"a":"A0","b":"B0"}\n');
|
||||
git(dir, ['add', '-A']);
|
||||
git(dir, ['commit', '-qm', 'base']);
|
||||
const base = git(dir, ['rev-parse', 'HEAD']).stdout.trim();
|
||||
|
||||
git(dir, ['checkout', '-qb', 'ours']);
|
||||
fs.writeFileSync(path.join(dir, 'workflows', 'a.md'), 'A1\n');
|
||||
fs.writeFileSync(path.join(dir, 'derived.json'), '{"a":"A1","b":"B0"}\n');
|
||||
git(dir, ['add', '-A']);
|
||||
git(dir, ['commit', '-qm', 'ours edits workflow a']);
|
||||
|
||||
git(dir, ['checkout', '-q', base]);
|
||||
git(dir, ['checkout', '-qb', 'theirs']);
|
||||
fs.writeFileSync(path.join(dir, 'workflows', 'b.md'), 'B1\n');
|
||||
fs.writeFileSync(path.join(dir, 'derived.json'), '{"a":"A0","b":"B1"}\n');
|
||||
git(dir, ['add', '-A']);
|
||||
git(dir, ['commit', '-qm', 'theirs edits workflow b']);
|
||||
|
||||
git(dir, ['checkout', '-q', 'ours']);
|
||||
return dir;
|
||||
}
|
||||
|
||||
test('twoBranchesEditingDifferentWorkflowsMergeCleanlyWithTheDriver', (t) => {
|
||||
const dir = buildScenario(t, { register: true });
|
||||
|
||||
const merge = git(dir, ['merge', 'theirs', '-m', 'merge']);
|
||||
assert.equal(merge.status, 0, `merge should succeed: ${merge.stdout}${merge.stderr}`);
|
||||
assert.equal(git(dir, ['ls-files', '-u']).stdout.trim(), '', 'no unmerged index entries');
|
||||
assert.ok(
|
||||
!fs.readFileSync(path.join(dir, 'derived.json'), 'utf8').includes('<<<<<<<'),
|
||||
'the artifact must carry no conflict markers',
|
||||
);
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(dir, 'workflows', 'b.md'), 'utf8'),
|
||||
'B1\n',
|
||||
'the source side of the merge must still be applied normally',
|
||||
);
|
||||
});
|
||||
|
||||
// Control: proves the test observes the DRIVER, not a trivially-mergeable artifact.
|
||||
test('theSameTwoBranchesConflictWithoutTheDriver', (t) => {
|
||||
const dir = buildScenario(t, { register: false });
|
||||
|
||||
const merge = git(dir, ['merge', 'theirs', '-m', 'merge']);
|
||||
assert.notEqual(merge.status, 0, 'without the driver this scenario must conflict');
|
||||
assert.notEqual(git(dir, ['ls-files', '-u']).stdout.trim(), '');
|
||||
});
|
||||
|
||||
test('resolvesUnderRebaseNotJustMerge', (t) => {
|
||||
const dir = buildScenario(t, { register: true });
|
||||
git(dir, ['checkout', '-q', 'theirs']);
|
||||
|
||||
const rebase = git(dir, ['rebase', 'ours']);
|
||||
assert.equal(rebase.status, 0, `rebase should succeed: ${rebase.stdout}${rebase.stderr}`);
|
||||
assert.equal(git(dir, ['ls-files', '-u']).stdout.trim(), '');
|
||||
});
|
||||
|
||||
test('leavesAOneSidedChangeToGitsTrivialMerge', (t) => {
|
||||
const dir = buildScenario(t, { register: true });
|
||||
git(dir, ['checkout', '-q', '-b', 'sideways', 'ours']);
|
||||
fs.writeFileSync(path.join(dir, 'workflows', 'c.md'), 'C1\n');
|
||||
git(dir, ['add', '-A']);
|
||||
git(dir, ['commit', '-qm', 'unrelated']);
|
||||
|
||||
const merge = git(dir, ['merge', 'ours', '-m', 'merge']);
|
||||
assert.equal(merge.status, 0);
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(dir, 'derived.json'), 'utf8'),
|
||||
'{"a":"A1","b":"B0"}\n',
|
||||
'a one-sided change is git’s trivial merge — the driver must not be involved',
|
||||
);
|
||||
});
|
||||
|
||||
test('resolvedArtifactIsExactlyTheOursSide', (t) => {
|
||||
const dir = buildScenario(t, { register: true });
|
||||
const ours = fs.readFileSync(path.join(dir, 'derived.json'), 'utf8');
|
||||
|
||||
git(dir, ['merge', 'theirs', '-m', 'merge']);
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(dir, 'derived.json'), 'utf8'),
|
||||
ours,
|
||||
'the driver invents nothing — it takes ours verbatim and defers to regen:derived',
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user