* test(#3144): bound the git/worktree cluster onto the process seam Migrates 180 unbounded sync spawn sites across 19 files. Every previously unbounded call now carries an explicit timeout with a comment giving the number and why. The migration is not a callee swap. execSync and execFileSync throw on a non-zero exit and the seam never does, so each site was classified first: sites that rely on the throw route to gitOrThrow, and sites that already read .status to detect an EXPECTED non-zero -- an intended cherry-pick conflict, a rev-parse outside a repo driving a skip -- route to the never-throwing runGit instead, which would otherwise throw on exactly the exit being probed for. Two same-named git() helpers in worktree-cleanup.test.cjs have different return contracts, one trimmed and one raw; both are preserved rather than unified. Collapses five hand-rolled throw wrappers onto one throwIfFailed in git-fixture.cjs, which gitOrThrow now also uses so the shape cannot drift. Allowlist drops 139 to 120; BASELINE lowered to match. * test(#3144): fix pre-PR review findings Documents throwIfFailed in the CONTEXT.md glossary and CONTRIBUTING.md -- it became the shared throw mechanism without either doc naming it. Routes the sixth and seventh hand-rolled copies of the throw shape through throwIfFailed (worktree-baseref-install, worktree-safety-reap); the first consolidation missed both. Converts ci-rebase-check's 8 fixture-setup calls from unchecked runGit to gitOrThrow so a failed setup step aborts where it fails rather than surfacing later as a confusing failure against the wrong subject. Adds 12 direct unit tests for throwIfFailed, which until now was only exercised transitively. Splits verify.test.cjs's non-git grep/sed bound off GIT_TIMEOUT_MS. --------- Co-authored-by: sim <sim@local>
204 lines
9.8 KiB
JavaScript
204 lines
9.8 KiB
JavaScript
// allow-test-rule: source-text-is-the-product (see #2913)
|
|
// .github/workflows/release.yml is the deployed CI contract; the cherry-pick
|
|
// error handler is bash inside the YAML. This test has two layers:
|
|
// 1. Real-git: proves the DISCRIMINATION LOGIC (check for unmerged paths →
|
|
// skip if empty, abort if conflict) is correct by exercising it against
|
|
// a real git repo with both scenarios.
|
|
// 2. Source-text: asserts the discrimination logic AND the separate summary
|
|
// heading actually made it into release.yml — so a regression that
|
|
// removes the check from the workflow is caught even if the logic test
|
|
// still passes.
|
|
|
|
'use strict';
|
|
|
|
const { describe, test, before, after } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const os = require('node:os');
|
|
const { runGit } = require('./helpers/process-seam.cjs');
|
|
const { gitOrThrow } = require('./helpers/git-fixture.cjs');
|
|
const { cleanup } = require('./helpers.cjs');
|
|
|
|
const RELEASE_WORKFLOW = path.join(__dirname, '..', '.github', 'workflows', 'release.yml');
|
|
|
|
// 15000ms: git plumbing (config, add, commit, rev-parse, checkout, cherry-pick)
|
|
// against a small mkdtemp fixture repo — gitOrThrow's own documented default,
|
|
// reused here as the named constant for this file's calls.
|
|
const GIT_TIMEOUT_MS = 15000;
|
|
|
|
// ─── git helpers ────────────────────────────────────────────────────────────
|
|
|
|
// Migrated from a hand-rolled throw-on-non-zero over spawnSync's `-C cwd`
|
|
// argv form to gitOrThrow's `{ cwd }` option — same external behavior (throws
|
|
// on non-zero exit, returns trimmed stdout on success), no caller reads the
|
|
// old custom error message so the throw-shape swap is safe.
|
|
function git(cwd, ...args) {
|
|
return gitOrThrow(args, { cwd, timeoutMs: GIT_TIMEOUT_MS }).trim();
|
|
}
|
|
|
|
function makeRepo(dir) {
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
gitOrThrow(['init', dir], { timeoutMs: GIT_TIMEOUT_MS });
|
|
git(dir, 'config', 'user.email', 'test@test');
|
|
git(dir, 'config', 'user.name', 'Test');
|
|
git(dir, 'config', 'commit.gpgsign', 'false');
|
|
// Ensure the default branch is 'main' regardless of the system's
|
|
// init.defaultBranch setting (older Git defaults to 'master').
|
|
gitOrThrow(['symbolic-ref', 'HEAD', 'refs/heads/main'], { cwd: dir, timeoutMs: GIT_TIMEOUT_MS });
|
|
return dir;
|
|
}
|
|
|
|
function commitFile(dir, file, content, message) {
|
|
fs.writeFileSync(path.join(dir, file), content);
|
|
git(dir, 'add', file);
|
|
git(dir, 'commit', '-m', message);
|
|
return git(dir, 'rev-parse', 'HEAD');
|
|
}
|
|
|
|
/**
|
|
* The discrimination logic extracted from release.yml's cherry-pick error
|
|
* handler. This mirrors what the bash does after `git cherry-pick -x "$SHA"`
|
|
* exits non-zero:
|
|
* - Check for unmerged paths (`git diff --name-only --diff-filter=U`).
|
|
* - Empty → skip (already applied by content), record, continue.
|
|
* - Non-empty → genuine conflict, abort, return conflict info.
|
|
*
|
|
* @returns {{ status: 'empty' | 'conflict', unmerged: string[] }}
|
|
*/
|
|
function discriminateCherryPick(dir) {
|
|
const unmerged = git(dir, 'diff', '--name-only', '--diff-filter=U');
|
|
const unmergedFiles = unmerged.split('\n').filter(Boolean);
|
|
if (unmergedFiles.length === 0) {
|
|
git(dir, 'cherry-pick', '--skip');
|
|
return { status: 'empty', unmerged: [] };
|
|
}
|
|
git(dir, 'cherry-pick', '--abort');
|
|
return { status: 'conflict', unmerged: unmergedFiles };
|
|
}
|
|
|
|
// ─── real-git tests: prove the discrimination logic ─────────────────────────
|
|
|
|
describe('#2913 — cherry-pick empty-vs-conflict discrimination (real git)', () => {
|
|
let tmpDir;
|
|
|
|
before(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2913-'));
|
|
});
|
|
|
|
after(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('an already-applied-by-content commit is detected as empty and skipped', () => {
|
|
const repo = makeRepo(path.join(tmpDir, 'empty-pick'));
|
|
// Seed: create file.txt with "v1"
|
|
commitFile(repo, 'file.txt', 'v1\n', 'init');
|
|
const baseSha = git(repo, 'rev-parse', 'HEAD');
|
|
|
|
// On "next": bump file.txt to v2, then create a chore commit that sets it back to v1 then to v2
|
|
git(repo, 'checkout', '-b', 'next');
|
|
commitFile(repo, 'file.txt', 'v2\n', 'feat: bump to v2');
|
|
// Now create a commit that is a no-op against base by content: set file.txt to "v1\n"
|
|
// then immediately set it back — but that creates a non-empty diff. Instead,
|
|
// simulate the exact scenario: a commit whose PATCH applies empty because
|
|
// the content is already present.
|
|
//
|
|
// Create the "chore" commit on a throwaway branch from base, then cherry-pick
|
|
// it onto next where the change is already present.
|
|
git(repo, 'checkout', baseSha);
|
|
git(repo, 'checkout', '-b', 'throwaway');
|
|
// Apply a change that sets file.txt to v2 (same content as next already has)
|
|
const choreSha = commitFile(repo, 'file.txt', 'v2\n', 'chore: sync version to v2');
|
|
|
|
// Go back to next and try to cherry-pick the chore commit.
|
|
// file.txt is already v2 on next → the patch applies empty.
|
|
git(repo, 'checkout', 'next');
|
|
|
|
// Attempt the cherry-pick — it exits non-zero (empty).
|
|
const r = runGit(['cherry-pick', '-x', choreSha], { cwd: repo, timeoutMs: GIT_TIMEOUT_MS });
|
|
assert.notStrictEqual(r.exitCode, 0, 'cherry-pick of an already-applied commit must exit non-zero');
|
|
|
|
// Apply the discrimination logic.
|
|
const result = discriminateCherryPick(repo);
|
|
assert.strictEqual(result.status, 'empty', 'an empty pick must be classified as "empty", not "conflict"');
|
|
assert.deepStrictEqual(result.unmerged, []);
|
|
|
|
// After skip, HEAD should be back on next (no cherry-pick state).
|
|
const headAfter = git(repo, 'rev-parse', 'next');
|
|
const currentHead = git(repo, 'rev-parse', 'HEAD');
|
|
assert.strictEqual(currentHead, headAfter, 'after skip, HEAD should be unchanged (no new commit applied)');
|
|
|
|
// #2913 MINOR 1: after --skip, the sequencer must be clean so the next
|
|
// iteration's cherry-pick succeeds. A regression that switched --skip to
|
|
// --quit (which leaves sequencer state) would fail here.
|
|
git(repo, 'checkout', baseSha);
|
|
git(repo, 'checkout', '-b', 'throwaway2');
|
|
const realSha = commitFile(repo, 'other.txt', 'real change\n', 'fix: a real fix');
|
|
git(repo, 'checkout', 'next');
|
|
const realPick = runGit(['cherry-pick', '-x', realSha], { cwd: repo, timeoutMs: GIT_TIMEOUT_MS });
|
|
assert.strictEqual(realPick.exitCode, 0,
|
|
`after --skip, the sequencer must be clean so the next cherry-pick succeeds; got status ${realPick.exitCode}:\n${realPick.stderr || realPick.stdout}`);
|
|
});
|
|
|
|
test('a genuine cherry-pick conflict is detected as conflict and aborted', () => {
|
|
const repo = makeRepo(path.join(tmpDir, 'conflict-pick'));
|
|
// Seed: file.txt with "base line"
|
|
commitFile(repo, 'file.txt', 'base line\n', 'init');
|
|
|
|
// On "next": change line 1 to "next version"
|
|
git(repo, 'checkout', '-b', 'next');
|
|
const conflictingSha = commitFile(repo, 'file.txt', 'next version\n', 'fix: change to next version');
|
|
|
|
// Go back to main and make a DIFFERENT change to the same line.
|
|
git(repo, 'checkout', 'main');
|
|
commitFile(repo, 'file.txt', 'main version\n', 'fix: change to main version');
|
|
|
|
// Attempt to cherry-pick the next commit — it conflicts (same line, different content).
|
|
const r = runGit(['cherry-pick', '-x', conflictingSha], { cwd: repo, timeoutMs: GIT_TIMEOUT_MS });
|
|
assert.notStrictEqual(r.exitCode, 0, 'cherry-pick of a conflicting commit must exit non-zero');
|
|
|
|
// Apply the discrimination logic.
|
|
const result = discriminateCherryPick(repo);
|
|
assert.strictEqual(result.status, 'conflict', 'a genuine conflict must be classified as "conflict"');
|
|
assert.ok(result.unmerged.length > 0, 'a genuine conflict must have unmerged files');
|
|
assert.ok(result.unmerged.includes('file.txt'), 'file.txt must be in the unmerged list');
|
|
});
|
|
});
|
|
|
|
// ─── source-text assertions: prove the logic is in release.yml ──────────────
|
|
|
|
describe('#2913 — release.yml cherry-pick error handler discriminates empty from conflict', () => {
|
|
const text = fs.existsSync(RELEASE_WORKFLOW)
|
|
? fs.readFileSync(RELEASE_WORKFLOW, 'utf8')
|
|
: '';
|
|
|
|
test('the cherry-pick error handler checks for unmerged paths before aborting', () => {
|
|
// The fix adds a `git diff --name-only --diff-filter=U` check inside the
|
|
// `if ! git cherry-pick` block. Without it, every empty pick aborts the run.
|
|
assert.ok(text.length > 0, 'release.yml must exist');
|
|
assert.ok(
|
|
text.includes('--diff-filter=U'),
|
|
'release.yml cherry-pick error handler must check for unmerged paths (git diff --diff-filter=U) before aborting — without this, an already-applied commit aborts the entire hotfix create (#2913)',
|
|
);
|
|
});
|
|
|
|
test('empty picks are skipped (git cherry-pick --skip), not aborted', () => {
|
|
assert.ok(text.length > 0, 'release.yml must exist');
|
|
assert.ok(
|
|
text.includes('cherry-pick --skip'),
|
|
'release.yml must skip (not abort) already-applied cherry-picks — git cherry-pick --skip continues the loop (#2913)',
|
|
);
|
|
});
|
|
|
|
test('the job summary distinguishes already-applied from not-fix-chore skips', () => {
|
|
assert.ok(text.length > 0, 'release.yml must exist');
|
|
// The fix introduces a separate variable/heading for already-applied commits
|
|
// so they are not silently dropped or confused with not-fix/chore skips.
|
|
assert.ok(
|
|
/already applied|applied by content|SKIPPED_EMPTY|empty pick/i.test(text),
|
|
'release.yml job summary must distinguish "skipped (already applied)" from "skipped (not fix/chore)" — a separate heading or variable for empty picks (#2913)',
|
|
);
|
|
});
|
|
});
|