#!/bin/bash # Check an explicit committed push range. Fetch is opt-in; never pushes. command -v node >/dev/null 2>&1 || { echo 'STOP node unavailable'; exit 2; } exec node --input-type=module - "$@" <<'JS' import { spawnSync } from 'node:child_process'; import { readFileSync } from 'node:fs'; import { createHash } from 'node:crypto'; import { join } from 'node:path'; import { pathToFileURL } from 'node:url'; const stop = (reason, code = 1) => { console.log(`STOP ${reason}`); process.exit(code); }; let gitRoot = process.cwd(); const git = (...args) => { const r = spawnSync('git', args, { cwd: gitRoot, maxBuffer: 256 * 1024 * 1024 }); if (r.error || r.status !== 0) throw new Error('git'); return r.stdout; }; // Git paths must round-trip as UTF-8 before comparing them with queue paths. const pathsText = bytes => new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes); const shownPath = path => /[\r\n]/.test(path) ? JSON.stringify(path) : path; const hash = bytes => createHash('sha256').update(bytes).digest('hex'); const text = (...args) => git(...args).toString('utf8').trimEnd(); const resolve = ref => text('rev-parse', '--verify', '--end-of-options', `${ref}^{commit}`); const argv = process.argv.slice(2); if (argv.length < 2) stop('usage: push-check.sh SHA EXPECTED_LOG [--row N]... [--base-ref REF] [--fetch]', 2); const [ref, expectedFile] = argv.splice(0, 2); let baseRef = 'refs/remotes/origin/refactor', fetch = false; const rowIds = new Set(); while (argv.length) { const flag = argv.shift(); if (flag === '--fetch' && !fetch) fetch = true; else if (flag === '--row' && /^[1-9][0-9]*$/.test(argv[0] ?? '') && Number.isSafeInteger(Number(argv[0]))) rowIds.add(Number(argv.shift())); else if (flag === '--base-ref' && argv[0] && !argv[0].startsWith('-')) baseRef = argv.shift(); else stop('usage', 2); } let sha, base, expected; try { // Resolve the log against the original caller, then anchor every Git command // at the repository root. Queue reads retain their production cwd checks. expected = readFileSync(expectedFile); gitRoot = text('rev-parse', '--show-toplevel'); sha = resolve(ref); } catch { stop('sha or expected log unavailable', 2); } if (fetch) { try { git('fetch', '-q', 'origin'); } catch { stop('fetch', 2); } } try { base = resolve(baseRef); } catch { stop('base ref unavailable', 2); } try { if (!git('log', '--oneline', `${base}..${sha}`).equals(expected)) stop('log'); console.log('LOG_OK'); try { git('merge-base', '--is-ancestor', base, sha); } catch { stop('ff'); } console.log('FF_OK'); // --no-renames keeps -z numstat to exactly one path per record. Never parse // quoted path output or consult working-tree bytes. const changedPaths = pathsText(git('diff', '--no-relative', '--no-color', '--no-ext-diff', '--no-textconv', '--no-renames', '--numstat', '-z', base, sha)) .split('\0').filter(Boolean).map(record => { const first = record.indexOf('\t'), second = record.indexOf('\t', first + 1); return { path: record.slice(second + 1), binary: record.startsWith('-\t-\t') }; }); const tree = commit => { const entries = new Map(); for (const entry of pathsText(git('ls-tree', '--full-tree', '-r', '-z', commit)).split('\0').filter(Boolean)) { const tab = entry.indexOf('\t'); const [mode, type, oid] = entry.slice(0, tab).split(' '); entries.set(entry.slice(tab + 1), { mode, type, oid }); } return entries; }; const have = tree(sha); // Attributes can force a binary to appear as text in numstat. Inspect raw // committed bytes as well, following Git's first-8000-byte NUL rule. const present = changedPaths.filter(({ path, binary }) => { const entry = have.get(path); if (!entry || entry.type !== 'blob') return false; return binary || git('cat-file', 'blob', entry.oid).subarray(0, 8000).includes(0); }).map(({ path }) => path); let approved = []; if (present.length && rowIds.size) { let rows; try { const root = text('rev-parse', '--show-toplevel'); const { rows: readRows } = await import(pathToFileURL(join(root, 'packages/queue/src/store.mjs')).href); const result = readRows({ cwd: process.cwd(), env: process.env }); if (result.code !== 0) throw new Error('queue'); if (result.err.some(note => /unconfirmed tail|not confirmed durable/.test(note))) stop('queue unconfirmed tail', 2); rows = result.rows; for (const id of rowIds) if (!rows.some(r => r.id === id)) throw new Error('row'); } catch { stop('queue unavailable or invalid', 2); } for (const row of rows.filter(r => rowIds.has(r.id))) { const round = row.review?.rounds.at(-1); if (!round || round.receipts?.some(r => r.verdict === 'changes')) continue; if (!round.receipts?.some(r => r.verdict === 'approve' && r.candidate === round.candidate.digest)) continue; approved.push(round.candidate); } } const candidates = new Map(); for (const c of approved) { if (c.kind !== 'commit') continue; const candidateTree = tree(c.digest); let parent; try { parent = text('rev-parse', '--verify', `${c.digest}^1`); } catch { parent = text('hash-object', '-t', 'tree', '/dev/null'); } const changed = new Set(pathsText(git('diff-tree', '--no-relative', '-r', '--no-renames', '--name-only', '-z', parent, c.digest)).split('\0').filter(Boolean)); candidates.set(c.digest, { candidateTree, changed }); } for (const path of present) { const h = have.get(path); const digest = hash(git('cat-file', 'blob', h.oid)); const matches = approved.some(c => { if (c.kind === 'manifest') return c.text.split('\n').includes(`${digest} ${path}`); const { candidateTree, changed } = candidates.get(c.digest); const entry = candidateTree.get(path); return changed.has(path) && entry?.type === 'blob' && entry.mode === h.mode && hash(git('cat-file', 'blob', entry.oid)) === digest; }); if (!matches) stop(`binary ${shownPath(path)} ${digest} not in a reviewed candidate`); } console.log(present.length ? `BINARY_REVIEWED ${present.length}` : 'NO_BINARY'); const diff = git('diff', '--no-relative', '--no-color', '--no-ext-diff', '--no-textconv', base, sha).toString('utf8'); const secret = /BEGIN [A-Z ]*PRIVATE KEY|ghp_[A-Za-z0-9]{20}|sk-ant-[A-Za-z0-9-]{10}|xox[bp]-|AKIA[0-9A-Z]{16}|(token|secret|password)["' ]*[:=] *["'][A-Za-z0-9_\-]{24,}/i; if (diff.split('\n').some(line => line.startsWith('+') && secret.test(line))) stop('secrets'); console.log('SECRETS_CLEAN'); if (/co-authored-by|generated with/i.test(text('log', '--format=%B', `${base}..${sha}`))) stop('attribution'); console.log('NO_ATTRIBUTION'); console.log(`PUSH_READY ${base}..${sha}`); } catch { stop('git or environment error', 2); } JS