mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-10 18:50:18 +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
+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;
|
||||
|
||||
Reference in new issue
Block a user