mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
@@ -5,9 +5,20 @@ import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const TRAILER = /^([A-Za-z][A-Za-z0-9-]*):\s*(.*?)\s*$/;
|
||||
export const ENFORCEMENT_BOUNDARY = '8e2973fa7a7cb1a80204ff95ecf3f2d7c36ed2ce';
|
||||
|
||||
/** Git invokes commit-msg before it removes the editor template. Ignore the
|
||||
* standard comment/scissors suffix exactly as Git will when it records the
|
||||
* commit, while leaving ordinary prose after trailers invalid. */
|
||||
export function cleanedCommitMessage(message) {
|
||||
const lines = String(message).replace(/\r/g, '').split('\n');
|
||||
const scissors = lines.findIndex((line) => /^\s*#\s*-+\s*>8\s*-+\s*$/.test(line));
|
||||
const visible = scissors >= 0 ? lines.slice(0, scissors) : lines;
|
||||
return visible.filter((line) => !/^\s*#/.test(line)).join('\n');
|
||||
}
|
||||
|
||||
export function terminalTrailers(message) {
|
||||
const lines = String(message).replace(/\r/g, '').split('\n');
|
||||
const lines = cleanedCommitMessage(message).split('\n');
|
||||
while (lines.length && !lines.at(-1).trim()) lines.pop();
|
||||
const out = new Map();
|
||||
for (let index = lines.length - 1; index >= 0; index--) {
|
||||
@@ -31,6 +42,14 @@ export function validateCommitMessage(message, changedFiles = []) {
|
||||
if (visible.length !== 1 || !/^(yes|no)$/.test(visible[0])) {
|
||||
errors.push("expected exactly one terminal 'User-Visible: yes|no' trailer");
|
||||
}
|
||||
const normalizedFiles = changedFiles.map((file) => file.replaceAll('\\', '/'));
|
||||
if (visible.length === 1 && visible[0] === 'yes') {
|
||||
for (const changelog of ['docs/CHANGELOG.md', 'docs/CHANGELOG.ru.md']) {
|
||||
if (!normalizedFiles.includes(changelog)) {
|
||||
errors.push(`user-visible commit must update ${changelog}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
const changesGolden = changedFiles.some((file) =>
|
||||
/^demo\/golden\/baselines\/.*\.(png|json)$/.test(file.replaceAll('\\', '/')));
|
||||
if (changesGolden) {
|
||||
@@ -48,13 +67,42 @@ function git(args) {
|
||||
return execFileSync('git', args, { encoding: 'utf8' }).trim();
|
||||
}
|
||||
|
||||
function assertHookMode() {
|
||||
const row = git(['ls-files', '-s', '.githooks/commit-msg']);
|
||||
export function assertHookMode(row = git(['ls-files', '-s', '.githooks/commit-msg'])) {
|
||||
if (!row.startsWith('100755 ')) {
|
||||
throw new Error('.githooks/commit-msg must be tracked as executable (100755)');
|
||||
}
|
||||
}
|
||||
|
||||
function gitObjectExists(revision, runner = git) {
|
||||
try { runner(['cat-file', '-e', `${revision}^{commit}`]); return true; }
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
export function resolveValidationRange({
|
||||
eventName, beforeSha, baseSha, headSha, defaultBranch,
|
||||
}, runner = git) {
|
||||
if (!headSha) throw new Error('HEAD_SHA is required');
|
||||
if (eventName === 'pull_request') {
|
||||
if (!baseSha) throw new Error('BASE_SHA is required for a pull request');
|
||||
return `${runner(['merge-base', baseSha, headSha])}..${headSha}`;
|
||||
}
|
||||
const hasBefore = !!beforeSha && !/^0+$/.test(beforeSha)
|
||||
&& gitObjectExists(beforeSha, runner);
|
||||
const comparison = hasBefore
|
||||
? beforeSha
|
||||
: `refs/remotes/origin/${defaultBranch || 'main'}`;
|
||||
return `${runner(['merge-base', comparison, headSha])}..${headSha}`;
|
||||
}
|
||||
|
||||
function assertDescendsFromBoundary(commit) {
|
||||
try { git(['merge-base', '--is-ancestor', ENFORCEMENT_BOUNDARY, commit]); }
|
||||
catch {
|
||||
throw new Error(
|
||||
`${commit} does not descend from provenance boundary ${ENFORCEMENT_BOUNDARY}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function validateOne(label, message, files) {
|
||||
const errors = validateCommitMessage(message, files);
|
||||
if (errors.length) throw new Error(`${label}:\n- ${errors.join('\n- ')}`);
|
||||
@@ -73,23 +121,33 @@ function main(argv) {
|
||||
validateOne('commit message', readFileSync(messageFile, 'utf8'), files);
|
||||
}
|
||||
const rangeAt = argv.indexOf('--range');
|
||||
if (rangeAt >= 0) {
|
||||
const range = argv[rangeAt + 1];
|
||||
const githubRange = argv.includes('--github-range');
|
||||
if (rangeAt >= 0 || githubRange) {
|
||||
const range = githubRange
|
||||
? resolveValidationRange({
|
||||
eventName: process.env.EVENT_NAME,
|
||||
beforeSha: process.env.BEFORE_SHA,
|
||||
baseSha: process.env.BASE_SHA,
|
||||
headSha: process.env.HEAD_SHA,
|
||||
defaultBranch: process.env.DEFAULT_BRANCH,
|
||||
})
|
||||
: argv[rangeAt + 1];
|
||||
if (!range) throw new Error('--range requires a git revision range');
|
||||
const head = range.split('..').at(-1);
|
||||
assertDescendsFromBoundary(head);
|
||||
const commits = git(['rev-list', '--reverse', range]).split('\n').filter(Boolean);
|
||||
for (const commit of commits) {
|
||||
// PROCESS.md explicitly forbids rewriting published history. The commit
|
||||
// that introduces this validator is the enforcement boundary; older PR
|
||||
// ancestry without the script remains auditable but cannot block a
|
||||
// future release branch forever.
|
||||
try { git(['cat-file', '-e', `${commit}:scripts/validate-commit-provenance.mjs`]); }
|
||||
// The immutable introducing commit is the boundary. File presence is
|
||||
// intentionally irrelevant: deleting the validator inside the range
|
||||
// must not create a two-commit bypass.
|
||||
try { git(['merge-base', '--is-ancestor', ENFORCEMENT_BOUNDARY, commit]); }
|
||||
catch { continue; }
|
||||
const parentCount = Number(git(['rev-list', '--parents', '-n', '1', commit]).split(/\s+/).length) - 1;
|
||||
if (parentCount > 1) continue;
|
||||
validateOne(
|
||||
commit,
|
||||
git(['show', '-s', '--format=%B', commit]),
|
||||
git(['diff-tree', '--no-commit-id', '--name-only', '-r', commit]).split('\n').filter(Boolean),
|
||||
git(['diff-tree', '--root', '--no-commit-id', '--name-only', '-r', commit]).split('\n').filter(Boolean),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user