mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-07 17:28:23 +01:00
ship-receipt write: the targeted record survives a main merge (GST-1593) (#4058)
* ship-receipt write: the targeted record survives a main merge; classes since the read cover the PR's own change (GST-1593)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* docs: the targeted record's change since the read is the PR's own (GST-1593)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* ship-receipt depth: a branch edit to a path whose base did not move always voids the targeted record; isolate the GST-1593 tests' gate scan from the real home (GST-1593)
ownChangeSince compared the PR's per-path hunks with line numbers stripped even when main had not moved the
path's base content, so a branch commit after the targeted read that relocated the PR's money line to an
identical-context block read as unchanged and the record bound (red-team, /ship #4058 pass 1). Hunk
comparison now applies only when the base blob moved. The four direct targetedClassesSince calls in the
GST-1593 tests now run with the fixture's env, so gstack-gate-diff no longer writes under the real
~/.gstack (CI run 42179129702, free-suite (4): FREE_SUBSHARD_REJECTED real home changed).
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: pre-landing review fixes (GST-1593)
ownChangeSince: a path whose base content main also changed now moves unless replaying main's change onto
the read's content (git merge-file) gives HEAD's content exactly, so relocating the PR's money hunk to an
identical context after a main merge voids the targeted record; position-free hunk comparison is gone. The
built commit carries the branch's first-parent messages since the read, so a [skip ci] commit stays RH-14
gate class. Two red-first tests cover both.
Review-Finding: v1/outside-voice#1 reader=outside-voice
Review-Finding: v1/adversarial-native#1 reader=adversarial-native
Review-Finding: v1/outside-voice#2 reader=outside-voice
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* ci: run the checks at the review-fix head (GST-1593)
The previous commit's message quoted a CI skip marker, so no workflow ran at c462483. This commit changes
no file; the tree is c462483's.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: pre-landing review fixes, fix-check round (GST-1593)
The built commit now carries the message of every commit since the read that origin/main does not hold,
so a CI-skip marker on a side-branch commit merged with --no-ff still tags RH-14. A path whose mode at
HEAD is not the replayed mode now moves, so a mode-only change after a main merge to the PR's own file
voids the record. Two red-first tests cover both.
Review-Finding: fc1/fix-check#1 reader=fix-check
Review-Finding: fc1/fix-check#2 reader=fix-check
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* changelog.d: plain-sentence wording for the GST-1593 fragment (prose lint)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
---------
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
05114c8049
commit
f7015fa72c
6 files changed
+244
-16
No files matched your search
@@ -294,7 +294,7 @@ function cmdWrite(rest: string[], json: boolean): number {
|
||||
const product = reviewedProduct(repoRoot, base, head, pregateFile);
|
||||
let targeted: ReturnType<typeof parseReviewTargeted> | undefined;
|
||||
if (flag(rest, '--review-targeted') !== undefined) {
|
||||
try { targeted = parseReviewTargeted(jsonArg(flag(rest, '--review-targeted')), product.product_patch_id ?? 'none', read => reviewedProduct(repoRoot, base, read, pregateFile).product_patch_id, read => targetedClassesSince(repoRoot, read, headSha ?? head), read => plainDocsSince(repoRoot, read, headSha ?? head)); } catch (e: any) { targeted = { ok: false, message: `--review-targeted: ${e.message}` }; }
|
||||
try { targeted = parseReviewTargeted(jsonArg(flag(rest, '--review-targeted')), product.product_patch_id ?? 'none', read => reviewedProduct(repoRoot, base, read, pregateFile).product_patch_id, read => targetedClassesSince(repoRoot, read, headSha ?? head, base), read => plainDocsSince(repoRoot, read, headSha ?? head, base)); } catch (e: any) { targeted = { ok: false, message: `--review-targeted: ${e.message}` }; }
|
||||
}
|
||||
if (targeted && !targeted.ok) { process.stderr.write(resultLine('gstack-ship-receipt write', 'RECEIPT_INVALID', `${targeted.message}; nothing written`) + '\n'); return EXIT.fail; }
|
||||
const approval = approvalForReceipt({ repoRoot, base, head, citationText: [bodyText, ...flags(rest, '--approval')].join('\n'), runDirs: flags(rest, '--approval-run') });
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
- **A main merge after the targeted read no longer voids the record.** `gstack-ship-receipt write --review-targeted` refused with "the product changed after the read" whenever main brought in money- or secret-tagged files, though the PR's product patch was unchanged, because the classes changed since the read were computed across the merge commit (harness-evals #293, #292, #225). Write now computes those classes, and the plain-docs carry, over the PR's own change, meaning the paths whose part of the PR's diff against its merge base differs between the read and HEAD. A branch-side edit to money, secret, auth or gate code after the read, or a merge resolution that changes the PR's own hunks, still refuses. (GST-1593)
|
||||
@@ -489,7 +489,10 @@ renders its brief; `verify --targeted-out .gstack/tmp/targeted.json` writes the
|
||||
the reader got its brief verbatim and its reply was collected on a committed diff, whatever
|
||||
happened to HEAD after, and it names the commit it read (`head`). `write` binds it only while
|
||||
that commit is an ancestor of HEAD, its product patch is the receipt's, and nothing changed
|
||||
after it carries a targeted class, with the gate scan run over that range: any other product change
|
||||
after it carries a targeted class, with the gate scan run over that change. What changed after the
|
||||
read is the PR's own change, on `product_patch_id`'s basis: the paths whose part of the PR's diff
|
||||
against its merge base differs between the read and HEAD, so files only a base merge brought in never
|
||||
count, while a branch edit or a merge resolution that changes the PR's own hunks does (GST-1593). Any other product change
|
||||
after the read refuses with `RECEIPT_INVALID`, and so does a test edit the gate scan tags (a
|
||||
skipped case). A docs-only change after the read (TODOS.md included) carries the record only
|
||||
when it changes no binary file, adds no numeric claim, touches no results, prereg or eval path,
|
||||
|
||||
@@ -3502,7 +3502,9 @@ or when `--review-targeted` names a read commit that is not an ancestor of
|
||||
HEAD, or after which a change carries a targeted class (money, gate, auth or
|
||||
secret code changed after the targeted reader read it, a test edit the gate
|
||||
scan tags included), or whose product patch is not the receipt's and the change
|
||||
since it is more than plain docs. A docs-only change after the read carries the
|
||||
since it is more than plain docs. The change since the read is the PR's own: a
|
||||
base merge after the read that leaves the PR's diff alone never voids the record,
|
||||
whatever files it brought in (GST-1593). A docs-only change after the read carries the
|
||||
record only when it changes no binary file, adds no numeric claim, touches no
|
||||
results, prereg or eval path, and names no money, security, secret or auth topic; any other docs change
|
||||
needs the reader again (GST-1195). With `--body`, write also refuses, writing
|
||||
|
||||
+85
-12
@@ -479,16 +479,87 @@ export function bindShippedReceipt(root: string, baseRef: string, body: string,
|
||||
}
|
||||
|
||||
/**
|
||||
* The targeted classes of what changed from `from` to the checked-out `head`, with gstack-gate-diff run over that range whenever
|
||||
* a non-docs path changed (a test edit that skips a case is gate class); null when `from` is not an ancestor of `head`, the gate
|
||||
* scan did not run, or a class input is unreadable.
|
||||
* A commit on `from` that holds what the PR's own change did after `from`: every path whose part of the PR's diff against its
|
||||
* merge base with origin/<base> (product_patch_id's basis, with tests and metadata kept) differs between `from` and `head`, at
|
||||
* `head`'s content. A main merge moves the merge base with the files it brings, so a file only main changed keeps `from`'s
|
||||
* content (GST-1593); a branch edit, or a merge resolution that changes the PR's own hunks, moves the path: a path whose base
|
||||
* content main also changed moves unless replaying main's change onto `from`'s content (git merge-file) gives `head`'s exactly,
|
||||
* so a hunk relocated to an identical context counts. With no resolvable merge base every path that changed from `from` to
|
||||
* `head` moves, and so does a path whose mode at `head` is not the replayed one. The commit carries the messages of every
|
||||
* commit since `from` that origin/<base> does not hold, side-branch merges included (a CI skip marker is gate class). Null when
|
||||
* git cannot build it.
|
||||
*/
|
||||
export function targetedClassesSince(root: string, from: string, head: string): readonly TargetedClass[] | null {
|
||||
function ownChangeSince(root: string, base: string, from: string, head: string): string | null {
|
||||
const run = (args: string[], env?: NodeJS.ProcessEnv, input?: string) => {
|
||||
const r = spawnSync('git', args, { cwd: root, encoding: 'utf8', env, input, timeout: 60_000, maxBuffer: 256 * 1024 * 1024 });
|
||||
return r.status === 0 ? r.stdout : null;
|
||||
};
|
||||
const raw = (a: string, b: string) => {
|
||||
const out = run(['diff', '--raw', '-z', '--no-renames', '--full-index', a, b]);
|
||||
if (out === null) return null;
|
||||
const t = out.split('\0');
|
||||
const entries = new Map<string, string>();
|
||||
for (let i = 0; i + 1 < t.length; i += 2) entries.set(t[i + 1]!, t[i]!);
|
||||
return entries;
|
||||
};
|
||||
const blob = (sha: string) => {
|
||||
const r = spawnSync('git', ['cat-file', 'blob', sha], { cwd: root, timeout: 60_000, maxBuffer: 256 * 1024 * 1024 });
|
||||
return r.status === 0 ? r.stdout : null;
|
||||
};
|
||||
const gitDir = git(root, ['rev-parse', '--absolute-git-dir']);
|
||||
if (!gitDir.ok) return null;
|
||||
const tmp = path.join(gitDir.out, `gstack-since-${process.pid}-${randomBytes(4).toString('hex')}`);
|
||||
const index = `${tmp}.index`;
|
||||
const env = { ...process.env, GIT_INDEX_FILE: index, GIT_AUTHOR_NAME: 'gstack-ship-receipt', GIT_AUTHOR_EMAIL: 'ship-receipt@gstack.local', GIT_COMMITTER_NAME: 'gstack-ship-receipt', GIT_COMMITTER_EMAIL: 'ship-receipt@gstack.local', GIT_AUTHOR_DATE: '2000-01-01T00:00:00Z', GIT_COMMITTER_DATE: '2000-01-01T00:00:00Z' };
|
||||
const baseSha = baseCommit(root, `origin/${base.replace(/^origin\//, '')}`);
|
||||
const mbFrom = baseSha.ok ? git(root, ['merge-base', baseSha.out, from]) : baseSha;
|
||||
const mbHead = baseSha.ok ? git(root, ['merge-base', baseSha.out, head]) : baseSha;
|
||||
try {
|
||||
let moved: string[];
|
||||
if (mbFrom.ok && mbHead.ok) {
|
||||
const was = raw(mbFrom.out, from), now = raw(mbHead.out, head);
|
||||
if (!was || !now) return null;
|
||||
moved = [...new Set([...was.keys(), ...now.keys()])].filter(p => {
|
||||
const w = was.get(p), n = now.get(p);
|
||||
if (w === n) return false;
|
||||
const [wOld, wMode, wBase, wNew] = w?.split(' ') ?? [], [nOld, nMode, nBase, nNew] = n?.split(' ') ?? [];
|
||||
const shas = [wNew, wBase, nBase, nNew];
|
||||
if (wBase === nBase || nMode !== (wOld === `:${wMode}` ? nOld?.slice(1) : wMode) || shas.some(s => !s || /^0+$/.test(s))) return true;
|
||||
const blobs = shas.map(s => blob(s!));
|
||||
if (blobs.some(b => !b)) return true;
|
||||
const files = blobs.slice(0, 3).map((b, i) => { fs.writeFileSync(`${tmp}.${i}`, b!); return `${tmp}.${i}`; });
|
||||
const merged = spawnSync('git', ['merge-file', '-p', '-q', ...files], { cwd: root, timeout: 60_000, maxBuffer: 256 * 1024 * 1024 });
|
||||
return merged.status !== 0 || !merged.stdout.equals(blobs[3]!);
|
||||
});
|
||||
} else {
|
||||
const all = raw(from, head);
|
||||
if (!all) return null;
|
||||
moved = [...all.keys()];
|
||||
}
|
||||
const listed = moved.length ? run(['--literal-pathspecs', 'ls-tree', '-z', '--full-tree', head, '--', ...moved]) : '';
|
||||
const messages = run(['log', '--format=%B', `${from}..${head}`, ...(baseSha.ok ? [`^${baseSha.out}`] : [])]);
|
||||
if (listed === null || messages === null) return null;
|
||||
const atHead = new Map(listed.split('\0').filter(Boolean).map(l => [l.slice(l.indexOf('\t') + 1), l] as const));
|
||||
const entries = moved.map(p => `${atHead.get(p) ?? `0 ${'0'.repeat(from.length)}\t${p}`}\0`).join('');
|
||||
if (run(['read-tree', from], env) === null || run(['update-index', '-z', '--index-info'], env, entries) === null) return null;
|
||||
const tree = run(['write-tree'], env)?.trim();
|
||||
return (tree && run(['commit-tree', '--no-gpg-sign', tree, '-p', from, '-m', 'gstack-ship-receipt: the PR\'s own change since the targeted read', '-m', messages.trim() || '(no messages)'], env)?.trim()) || null;
|
||||
} finally { for (const s of ['index', '0', '1', '2']) fs.rmSync(`${tmp}.${s}`, { force: true }); }
|
||||
}
|
||||
|
||||
/**
|
||||
* The targeted classes of what the PR's own change did from `from` to `head` against origin/<base> (ownChangeSince), with
|
||||
* gstack-gate-diff run over that change whenever a non-docs path moved (a test edit that skips a case is gate class); null when
|
||||
* `from` is not an ancestor of `head`, the change cannot be built, the gate scan did not run, or a class input is unreadable.
|
||||
*/
|
||||
export function targetedClassesSince(root: string, from: string, head: string, base: string): readonly TargetedClass[] | null {
|
||||
if (!git(root, ['merge-base', '--is-ancestor', from, head]).ok) return null;
|
||||
const at = headInputs(root, from, head, undefined, true);
|
||||
const since = ownChangeSince(root, base, from, head);
|
||||
if (!since) return null;
|
||||
const at = headInputs(root, from, since, undefined, true);
|
||||
let gateTagged: number | null = 0;
|
||||
if (gateScanNeeded(at.c.paths, at.c.root)) {
|
||||
const g = runScriptSync(path.join(BIN_DIR, 'gstack-gate-diff'), [from], { cwd: root, encoding: 'utf8', timeout: 120_000, maxBuffer: 16 * 1024 * 1024 });
|
||||
const g = runScriptSync(path.join(BIN_DIR, 'gstack-gate-diff'), ['--commit', since], { cwd: root, encoding: 'utf8', timeout: 120_000, maxBuffer: 16 * 1024 * 1024 });
|
||||
const m = /GATE_SUMMARY: .*?tagged=\{([^}]*)\}/.exec(g.stdout ?? '');
|
||||
if (g.status !== 0 || !m) return null;
|
||||
gateTagged = m[1]!.split(',').reduce((n, kv) => n + (Number(kv.split(':')[1]) || 0), 0);
|
||||
@@ -498,14 +569,16 @@ export function targetedClassesSince(root: string, from: string, head: string):
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether what changed from `from` to the checked-out `head` is plain docs, which a targeted record carries across (GST-1195):
|
||||
* every path docs class, R65's claim rule (platformFits: no numeric claim, no results, prereg or eval path), and no money,
|
||||
* security, secret or auth wording in the changed lines (the risk matchers plus DOCS_CLAIM_WORDS, since the matchers spare prose).
|
||||
* False when `from` is not an ancestor or the diff is unreadable.
|
||||
* Whether what the PR's own change did from `from` to `head` against origin/<base> (ownChangeSince) is plain docs, which a
|
||||
* targeted record carries across (GST-1195): every path docs class, R65's claim rule (platformFits: no numeric claim, no results,
|
||||
* prereg or eval path), and no money, security, secret or auth wording in the changed lines (the risk matchers plus
|
||||
* DOCS_CLAIM_WORDS, since the matchers spare prose). False when `from` is not an ancestor or the change is unreadable.
|
||||
*/
|
||||
export function plainDocsSince(root: string, from: string, head: string): boolean {
|
||||
export function plainDocsSince(root: string, from: string, head: string, base: string): boolean {
|
||||
if (!git(root, ['merge-base', '--is-ancestor', from, head]).ok) return false;
|
||||
const c = collectCommit(root, from, head);
|
||||
const since = ownChangeSince(root, base, from, head);
|
||||
if (!since) return false;
|
||||
const c = collectCommit(root, from, since);
|
||||
if (c.errors.length || !c.paths.length || !c.paths.every(p => classifyPath(p, c.root) === 'docs') || c.perPath.some(p => p.binary)) return false;
|
||||
const lines = c.paths.flatMap(p => [...(c.added?.[p] ?? []), ...(c.removed?.[p] ?? [])]);
|
||||
if (lines.some(l => l.toLowerCase().split(/[^a-z0-9]+/).some(t => DOCS_CLAIM_WORDS.has(t) || AUTH_PATH_PREFIXES.some(a => t.startsWith(a))))) return false;
|
||||
|
||||
@@ -12,7 +12,7 @@ import { EXIT } from '../lib/headless-artifacts';
|
||||
import { checkoutTree } from '../lib/review-brief';
|
||||
import { TARGETED_RULES, targetedBriefSha } from '../lib/review-targeted';
|
||||
import { extractReceipt, placeReceiptFirst } from '../lib/ship-receipt';
|
||||
import { receiptDepthAtHead } from '../lib/ship-receipt-depth';
|
||||
import { receiptDepthAtHead, targetedClassesSince } from '../lib/ship-receipt-depth';
|
||||
import { cleanup, makeFixtureRepo, runBin, type FixtureRepo } from './helpers/restamp-fixture';
|
||||
|
||||
setDefaultTimeout(180_000);
|
||||
@@ -293,3 +293,152 @@ describe('/ship\'s receipt writes pass the targeted record (item 3, #3853)', ()
|
||||
expect(expand()).toEqual(['--review-targeted', `@${recordPath}`]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a main merge after the read keeps the record when the PR\'s own change is unchanged (GST-1593, harness-evals #293)', () => {
|
||||
const mainCommit = (repo: FixtureRepo, files: Record<string, string>, msg: string) => {
|
||||
repo.git('checkout', '-q', 'main');
|
||||
for (const [file, text] of Object.entries(files)) commit(repo, file, text, msg);
|
||||
repo.git('push', '-q', 'origin', 'main');
|
||||
repo.git('checkout', '-q', 'feat');
|
||||
repo.git('fetch', '-q', 'origin');
|
||||
};
|
||||
const classesSince = (repo: FixtureRepo, read: string) => {
|
||||
const saved = Object.fromEntries(Object.keys(repo.env).map(k => [k, process.env[k]]));
|
||||
Object.assign(process.env, repo.env);
|
||||
try { return targetedClassesSince(repo.work, read, repo.git('rev-parse', 'HEAD'), 'main'); } finally {
|
||||
for (const [k, v] of Object.entries(saved)) { if (v === undefined) delete process.env[k]; else process.env[k] = v; }
|
||||
}
|
||||
};
|
||||
const block = (cond: string, cap: boolean) => [` if (${cond}) {`, ' a();', ' b();', ' c();', ...(cap ? [' const spend_cap_usd = 5000;'] : []), ' d();', ' e();', ' f();', ' }'];
|
||||
const charge = (one: boolean, two: boolean, header = '// charge') => [header, 'export function charge(amount: number) {', ...block('amount > 0', one), ...block('amount < 0', two), '}', ''].join('\n');
|
||||
const mainBrings = { 'lib/budget-ledger.ts': 'export const spend_cap_usd = 40;\n', 'lib/secrets.ts': 'export const key = process.env.OPENAI_API_KEY;\n' };
|
||||
const readAndMerge = () => {
|
||||
const { repo, base, head } = moneyBranch();
|
||||
const record = path.join(repo.root, 'targeted.json');
|
||||
expect(verify(repo, targetedRead(repo, base, 'h1', 'NO FINDINGS'), record).status).toBe(0);
|
||||
mainCommit(repo, mainBrings, 'main: budget ledger and secrets');
|
||||
return { repo, head, record };
|
||||
};
|
||||
|
||||
// Value: protects=a main merge that brings money- and secret-tagged files leaves the PR's product patch unchanged, so the
|
||||
// record binds (HARN-1 on harness-evals #293, #292, #225: targetedClassesSince diffed across the merge commit and voided it);
|
||||
// fails_when=the classes since the read count files only main brought in; seam=gstack-ship-receipt write --review-targeted
|
||||
test('read, then a main merge that brings money and secret files: write binds', () => {
|
||||
const { repo, head, record } = readAndMerge();
|
||||
repo.git('merge', '-q', '--no-edit', 'origin/main');
|
||||
const bound = write(repo, ['--review-targeted', `@${record}`]);
|
||||
expect(bound.status, bound.err).toBe(EXIT.ok);
|
||||
expect(JSON.parse(bound.out).review_targeted).toMatchObject({ classes: ['money'], status: 'done' });
|
||||
expect(classesSince(repo, head)).toEqual([]);
|
||||
});
|
||||
|
||||
// Value: protects=a branch-side money edit or skipped test after the read still voids the record across a main merge;
|
||||
// fails_when=the PR-own basis drops the branch's own change; seam=targetedClassesSince and write --review-targeted
|
||||
test('after a main merge, a branch-side money edit and a branch-side skipped test are refused', () => {
|
||||
const { repo, head, record } = readAndMerge();
|
||||
repo.git('merge', '-q', '--no-edit', 'origin/main');
|
||||
const merged = repo.git('rev-parse', 'HEAD');
|
||||
commit(repo, 'src/charge.ts', 'export const spend_cap_usd = 50;\n', 'money edit after the merge');
|
||||
expect(classesSince(repo, head)).toContain('money');
|
||||
const money = write(repo, ['--review-targeted', `@${record}`]);
|
||||
expect(money.status).toBe(EXIT.fail);
|
||||
expect(money.err).toContain(`the targeted reader read ${head.slice(0, 12)}`);
|
||||
|
||||
repo.git('reset', '-q', '--hard', merged);
|
||||
commit(repo, 'test/charge.test.ts', 'import { test, expect } from "bun:test";\ntest.skip("cap", () => { expect(5).toBe(5); });\n', 'skip a case after the merge');
|
||||
expect(classesSince(repo, head)).toContain('gate');
|
||||
const skipped = write(repo, ['--review-targeted', `@${record}`]);
|
||||
expect(skipped.status).toBe(EXIT.fail);
|
||||
expect(skipped.err).toContain(`the targeted reader read ${head.slice(0, 12)}`);
|
||||
});
|
||||
|
||||
// Value: protects=a plain docs commit after the read still carries the record when a main merge also brought money and secret
|
||||
// files (GST-1195's docs carry over the same PR-own basis); fails_when=plainDocsSince reads main's files as the branch's;
|
||||
// seam=gstack-ship-receipt write --review-targeted
|
||||
test('after a main merge, a plain docs commit binds', () => {
|
||||
const { repo, record } = readAndMerge();
|
||||
repo.git('merge', '-q', '--no-edit', 'origin/main');
|
||||
commit(repo, 'docs/run-order.md', '# Run order\n\nRead the overview first, then the walkthrough.\n', 'Step 14.5 docs');
|
||||
const docs = write(repo, ['--review-targeted', `@${record}`]);
|
||||
expect(docs.status, docs.err).toBe(EXIT.ok);
|
||||
});
|
||||
|
||||
// Value: protects=a merge whose resolution changes the PR's own hunks is a change the reader never read; fails_when=the merge
|
||||
// commit's own edit to the PR's file is credited to main; seam=targetedClassesSince and write --review-targeted
|
||||
test('a main merge that also changes the PR\'s own hunks is refused', () => {
|
||||
const { repo, head, record } = readAndMerge();
|
||||
repo.git('merge', '-q', '--no-commit', '--no-ff', 'origin/main');
|
||||
fs.writeFileSync(path.join(repo.work, 'src', 'charge.ts'), 'export const spend_cap_usd = 9;\n');
|
||||
repo.git('add', '-A'); repo.git('commit', '-q', '--no-edit');
|
||||
expect(classesSince(repo, head)).toEqual(['money']);
|
||||
const evil = write(repo, ['--review-targeted', `@${record}`]);
|
||||
expect(evil.status).toBe(EXIT.fail);
|
||||
expect(evil.err).toContain(`the targeted reader read ${head.slice(0, 12)}`);
|
||||
});
|
||||
|
||||
// Value: protects=with no main merge, a branch commit after the read that moves the PR's money line to another block with the
|
||||
// same context and function header still voids the record (position-free hunks compared equal); fails_when=a path whose
|
||||
// base content did not move is judged by its line-number-free hunks; seam=targetedClassesSince
|
||||
test('no main merge: a branch edit that relocates the PR\'s money hunk to an identical context is refused', () => {
|
||||
const repo = makeFixtureRepo({ prefix: 'gst1593-relocate-' });
|
||||
repos.push(repo);
|
||||
commit(repo, 'src/charge.ts', charge(false, false), 'main: charge');
|
||||
repo.git('push', '-q', 'origin', 'main');
|
||||
repo.git('checkout', '-q', '-b', 'feat');
|
||||
const read = commit(repo, 'src/charge.ts', charge(true, false), 'cap in the first block');
|
||||
commit(repo, 'src/charge.ts', charge(false, true), 'move the cap to the second block after the read');
|
||||
expect(classesSince(repo, read)).toContain('money');
|
||||
});
|
||||
|
||||
// Value: protects=a main merge that also changed the PR's file replays cleanly (the record binds) yet a later relocation of the
|
||||
// PR's money hunk to an identical context still voids it; fails_when=position-free hunks decide a path whose base moved, or the
|
||||
// replay over-refuses a clean main change; seam=targetedClassesSince
|
||||
test('a main merge to the PR\'s own file binds; relocating the money hunk after it is refused', () => {
|
||||
const repo = makeFixtureRepo({ prefix: 'gst1593-relocate-merge-' });
|
||||
repos.push(repo);
|
||||
commit(repo, 'src/charge.ts', charge(false, false), 'main: charge');
|
||||
repo.git('push', '-q', 'origin', 'main');
|
||||
repo.git('checkout', '-q', '-b', 'feat');
|
||||
const read = commit(repo, 'src/charge.ts', charge(true, false), 'cap in the first block');
|
||||
mainCommit(repo, { 'src/charge.ts': charge(false, false, '// charge: amounts in cents') }, 'main: header');
|
||||
repo.git('merge', '-q', '--no-edit', 'origin/main');
|
||||
expect(classesSince(repo, read)).toEqual([]);
|
||||
commit(repo, 'src/charge.ts', charge(false, true, '// charge: amounts in cents'), 'move the cap to the second block after the merge');
|
||||
expect(classesSince(repo, read)).toContain('money');
|
||||
});
|
||||
|
||||
// Value: protects=a CI skip marker in a branch commit after the read stays gate class (RH-14) though the scan reads a built
|
||||
// commit; fails_when=the built commit drops the branch's messages; seam=targetedClassesSince
|
||||
test('a branch commit after the read whose message skips CI is refused', () => {
|
||||
const { repo, head } = moneyBranch();
|
||||
commit(repo, 'test/charge.test.ts', 'import { test } from "bun:test";\ntest("cap", () => {});\n', '[skip ci] add a cap case');
|
||||
expect(classesSince(repo, head)).toContain('gate');
|
||||
});
|
||||
|
||||
// Value: protects=a CI skip marker on a side-branch commit merged with --no-ff after the read stays gate class; fails_when=the
|
||||
// built commit carries only first-parent messages; seam=targetedClassesSince
|
||||
test('a side-branch commit whose message skips CI, merged with --no-ff after the read, is refused', () => {
|
||||
const { repo, head } = moneyBranch();
|
||||
repo.git('checkout', '-q', '-b', 'side');
|
||||
commit(repo, 'test/charge.test.ts', 'import { test } from "bun:test";\ntest("cap", () => {});\n', '[skip ci] add a cap case');
|
||||
repo.git('checkout', '-q', 'feat');
|
||||
repo.git('merge', '-q', '--no-ff', '-m', 'merge side', 'side');
|
||||
expect(classesSince(repo, head)).toContain('gate');
|
||||
});
|
||||
|
||||
// Value: protects=a mode-only change after the read, on a file a main merge also changed, still voids the record; fails_when=the
|
||||
// replay compares blob bytes and ignores the mode; seam=targetedClassesSince
|
||||
test('after a main merge to the PR\'s own file, a mode-only change is refused', () => {
|
||||
const repo = makeFixtureRepo({ prefix: 'gst1593-mode-' });
|
||||
repos.push(repo);
|
||||
commit(repo, 'src/charge.ts', charge(false, false), 'main: charge');
|
||||
repo.git('push', '-q', 'origin', 'main');
|
||||
repo.git('checkout', '-q', '-b', 'feat');
|
||||
const read = commit(repo, 'src/charge.ts', charge(true, false), 'cap in the first block');
|
||||
mainCommit(repo, { 'src/charge.ts': charge(false, false, '// charge: amounts in cents') }, 'main: header');
|
||||
repo.git('merge', '-q', '--no-edit', 'origin/main');
|
||||
repo.git('update-index', '--chmod=+x', 'src/charge.ts');
|
||||
repo.git('commit', '-q', '-m', 'make charge executable');
|
||||
expect(classesSince(repo, read)).toContain('money');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user