Files
Matysh 42335bc16d ci: add the pre-push gate and stop lying about it in the canon
Section 10.1 promised pre-push as the blocking gate that replaces pull requests.
The hook did not exist, so the document promised a check that was not there —
worse than saying nothing, because a promise like that gets relied on. Until now
process-gate ran only as the catch-up job in CI, which reports after the code is
already in dev.

The hook skips branch deletions and tags, and for a branch the remote has not
seen it measures from the merge-base with dev rather than from the root, or every
violation committed before the gate existed would make it impossible to pass. A
missing script does not block a push: old checkouts and worktrees have to stay
usable.

gh is optional on purpose. Reading issue status needs the network, and a hook
that cannot work on a train is a hook people switch off; offline it runs what it
can and CI does the strict pass.

The executable bit is the quiet part. Git skips a hook without +x and says
nothing — the gate reports success by being absent. Measured on a real push:
mode 644 produces zero lines from the gate and the push goes through, 755 stops
it. The API cannot set the bit, so install-hooks restores it on every install.

Issue: #121
User-Visible: no
2026-08-13 14:58:57 +03:00

50 lines
2.1 KiB
JavaScript

#!/usr/bin/env node
import { execFileSync } from 'node:child_process';
import { chmodSync, existsSync, readdirSync, realpathSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
const HOOKS = ['commit-msg', 'pre-push'];
// Git skips a hook that is not executable, and says nothing about it. A hook that
// silently does not run is worse than no hook: the gate reports success by being
// absent. The bit cannot be set through the GitHub API either — a file pushed
// that way arrives as 100644 — so it is restored here, on every install.
function makeHooksExecutable(hooksDir) {
if (!existsSync(hooksDir)) return;
for (const name of readdirSync(hooksDir)) {
if (!HOOKS.includes(name)) continue;
const file = join(hooksDir, name);
try {
const mode = statSync(file).mode & 0o777;
if ((mode & 0o111) !== 0o111) chmodSync(file, mode | 0o111);
} catch {
// Windows reports modes it cannot change; git there runs hooks regardless.
}
}
}
const packageRoot = realpathSync(fileURLToPath(new URL('..', import.meta.url)));
try {
// A git dependency can run `prepare` from node_modules inside the consuming
// project's checkout. Never walk up and rewrite that unrelated repository.
const gitRoot = realpathSync(execFileSync(
'git', ['rev-parse', '--show-toplevel'],
{ cwd: packageRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] },
).trim());
if (gitRoot.toLowerCase() !== packageRoot.toLowerCase()
|| !existsSync(fileURLToPath(new URL('../.githooks/commit-msg', import.meta.url)))) {
throw new Error('not the House Plan checkout root');
}
execFileSync('git', ['config', 'core.hooksPath', '.githooks'], {
cwd: packageRoot, stdio: 'ignore',
});
makeHooksExecutable(join(packageRoot, '.githooks'));
console.log('House Plan: installed repository hooks from .githooks');
} catch {
// npm also runs prepare for source archives and dependency installs where
// there is no matching writable checkout. That is not an install failure.
console.log('House Plan: no matching Git checkout; hooks were not installed');
}