mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-07 17:28:23 +01:00
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>
This commit is contained in:
1 parent
2f769b4614
commit
c4624834f2
2 files changed
+54
-18
No files matched your search
+27
-16
@@ -482,9 +482,11 @@ export function bindShippedReceipt(root: string, baseRef: string, body: string,
|
|||||||
* 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
|
* 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
|
* 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
|
* `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, and where the base
|
* 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 did not move any change to the PR's side moves it, so a hunk relocated to an identical context counts. With no resolvable
|
* content main also changed moves unless replaying main's change onto `from`'s content (git merge-file) gives `head`'s exactly,
|
||||||
* merge base every path that changed from `from` to `head` moves. Null when git cannot build it.
|
* so a hunk relocated to an identical context counts. With no resolvable merge base every path that changed from `from` to
|
||||||
|
* `head` moves. The commit carries the branch's first-parent messages since `from` (a CI skip marker is gate class). Null when
|
||||||
|
* git cannot build it.
|
||||||
*/
|
*/
|
||||||
function ownChangeSince(root: string, base: string, from: string, head: string): string | null {
|
function ownChangeSince(root: string, base: string, from: string, head: string): string | null {
|
||||||
const run = (args: string[], env?: NodeJS.ProcessEnv, input?: string) => {
|
const run = (args: string[], env?: NodeJS.ProcessEnv, input?: string) => {
|
||||||
@@ -499,11 +501,19 @@ function ownChangeSince(root: string, base: string, from: string, head: string):
|
|||||||
for (let i = 0; i + 1 < t.length; i += 2) entries.set(t[i + 1]!, t[i]!);
|
for (let i = 0; i + 1 < t.length; i += 2) entries.set(t[i + 1]!, t[i]!);
|
||||||
return entries;
|
return entries;
|
||||||
};
|
};
|
||||||
const hunks = (a: string, b: string, p: string) => run(['--literal-pathspecs', 'diff', '--no-color', '--no-ext-diff', '--no-renames', '--binary', a, b, '--', p])
|
const blob = (sha: string) => {
|
||||||
?.split('\n').filter(l => !l.startsWith('index ')).map(l => l.replace(/^@@ -\d+(?:,\d+)? \+\d+(?:,\d+)? @@/, '@@')).join('\n') ?? null;
|
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 baseSha = baseCommit(root, `origin/${base.replace(/^origin\//, '')}`);
|
||||||
const mbFrom = baseSha.ok ? git(root, ['merge-base', baseSha.out, from]) : baseSha;
|
const mbFrom = baseSha.ok ? git(root, ['merge-base', baseSha.out, from]) : baseSha;
|
||||||
const mbHead = baseSha.ok ? git(root, ['merge-base', baseSha.out, head]) : baseSha;
|
const mbHead = baseSha.ok ? git(root, ['merge-base', baseSha.out, head]) : baseSha;
|
||||||
|
try {
|
||||||
let moved: string[];
|
let moved: string[];
|
||||||
if (mbFrom.ok && mbHead.ok) {
|
if (mbFrom.ok && mbHead.ok) {
|
||||||
const was = raw(mbFrom.out, from), now = raw(mbHead.out, head);
|
const was = raw(mbFrom.out, from), now = raw(mbHead.out, head);
|
||||||
@@ -511,28 +521,29 @@ function ownChangeSince(root: string, base: string, from: string, head: string):
|
|||||||
moved = [...new Set([...was.keys(), ...now.keys()])].filter(p => {
|
moved = [...new Set([...was.keys(), ...now.keys()])].filter(p => {
|
||||||
const w = was.get(p), n = now.get(p);
|
const w = was.get(p), n = now.get(p);
|
||||||
if (w === n) return false;
|
if (w === n) return false;
|
||||||
if (!w || !n || w.split(' ')[2] === n.split(' ')[2]) return true;
|
const [, , wBase, wNew] = w?.split(' ') ?? [], [, , nBase, nNew] = n?.split(' ') ?? [];
|
||||||
const before = hunks(mbFrom.out, from, p);
|
const shas = [wNew, wBase, nBase, nNew];
|
||||||
return before === null || before !== hunks(mbHead.out, head, p);
|
if (wBase === nBase || 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 {
|
} else {
|
||||||
const all = raw(from, head);
|
const all = raw(from, head);
|
||||||
if (!all) return null;
|
if (!all) return null;
|
||||||
moved = [...all.keys()];
|
moved = [...all.keys()];
|
||||||
}
|
}
|
||||||
const gitDir = git(root, ['rev-parse', '--absolute-git-dir']);
|
|
||||||
if (!gitDir.ok) return null;
|
|
||||||
const index = path.join(gitDir.out, `gstack-since-${process.pid}-${randomBytes(4).toString('hex')}.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' };
|
|
||||||
try {
|
|
||||||
const listed = moved.length ? run(['--literal-pathspecs', 'ls-tree', '-z', '--full-tree', head, '--', ...moved]) : '';
|
const listed = moved.length ? run(['--literal-pathspecs', 'ls-tree', '-z', '--full-tree', head, '--', ...moved]) : '';
|
||||||
if (listed === null) return null;
|
const messages = run(['log', '--first-parent', '--format=%B', `${from}..${head}`]);
|
||||||
|
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 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('');
|
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;
|
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();
|
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'], env)?.trim()) || null;
|
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 { fs.rmSync(index, { force: true }); }
|
} finally { for (const s of ['index', '0', '1', '2']) fs.rmSync(`${tmp}.${s}`, { force: true }); }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -309,6 +309,8 @@ describe('a main merge after the read keeps the record when the PR\'s own change
|
|||||||
for (const [k, v] of Object.entries(saved)) { if (v === undefined) delete process.env[k]; else process.env[k] = v; }
|
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 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 readAndMerge = () => {
|
||||||
const { repo, base, head } = moneyBranch();
|
const { repo, base, head } = moneyBranch();
|
||||||
@@ -378,8 +380,6 @@ describe('a main merge after the read keeps the record when the PR\'s own change
|
|||||||
// same context and function header still voids the record (position-free hunks compared equal); fails_when=a path whose
|
// 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
|
// 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', () => {
|
test('no main merge: a branch edit that relocates the PR\'s money hunk to an identical context is refused', () => {
|
||||||
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) => ['export function charge(amount: number) {', ...block('amount > 0', one), ...block('amount < 0', two), '}', ''].join('\n');
|
|
||||||
const repo = makeFixtureRepo({ prefix: 'gst1593-relocate-' });
|
const repo = makeFixtureRepo({ prefix: 'gst1593-relocate-' });
|
||||||
repos.push(repo);
|
repos.push(repo);
|
||||||
commit(repo, 'src/charge.ts', charge(false, false), 'main: charge');
|
commit(repo, 'src/charge.ts', charge(false, false), 'main: charge');
|
||||||
@@ -389,4 +389,29 @@ describe('a main merge after the read keeps the record when the PR\'s own change
|
|||||||
commit(repo, 'src/charge.ts', charge(false, true), 'move the cap to the second block after the read');
|
commit(repo, 'src/charge.ts', charge(false, true), 'move the cap to the second block after the read');
|
||||||
expect(classesSince(repo, read)).toContain('money');
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user