Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .githooks/pre-commit
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
#!/bin/sh
# Fast checks for the staged snapshot, before a commit is created.
set -eu
cd "$(git rev-parse --show-toplevel)"

# Reject whitespace errors introduced by this commit, not pre-existing files.
git diff --cached --check

# Reject obvious secrets and credentials before the push/CI boundary.
if git diff --cached --name-only --diff-filter=ACMR | grep -E '(^|/)\.env($|\.(local|production|development|test)$)|\.(pem|p12|p8)$' >/dev/null; then
printf '%s\n' "pre-commit: a credential or private environment file is staged; remove it from the index." >&2
exit 1
fi

# Project-specific fast checks; the template runs its tiny Node smoke tests.
if [ -f package.json ] && command -v npm >/dev/null 2>&1; then
npm run check:staged --if-present
fi
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,10 @@
"eval:adjudicate-holdout": "node packages/agents/holdout-adjudication.mjs",
"diagnostics:knn": "node packages/agents/knn-audit.mjs",
"market:hypothesis": "node packages/agents/market-hypothesis.mjs",
"diagnostics:knn-load": "node packages/agents/knn-load-benchmark.mjs"
"diagnostics:knn-load": "node packages/agents/knn-load-benchmark.mjs",
"prepare": "sh scripts/install-git-hooks.sh",
"hooks:install": "sh scripts/install-git-hooks.sh",
"check:staged": "node scripts/check-staged-syntax.mjs"
},
"dependencies": {
"pg": "^8.23.1"
Expand Down
38 changes: 38 additions & 0 deletions scripts/check-staged-syntax.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// Parse exactly the Git index snapshot, not potentially different worktree files.
import { spawnSync } from 'node:child_process';

function git(args) {
const result = spawnSync('git', args, { maxBuffer: 16 * 1024 * 1024 });
if (result.status !== 0) {
process.stderr.write(result.stderr || Buffer.from('git index read failed\n'));
process.exit(1);
}
return result.stdout;
}

const paths = git(['diff', '--cached', '--name-only', '--diff-filter=ACMR', '-z'])
.toString('utf8').split('\0').filter(Boolean);
let errors = 0;
for (const path of paths) {
if (!/\.(?:mjs|cjs|js|json)$/.test(path)) continue;
const staged = git(['show', ':' + path]);
if (path.endsWith('.json')) {
try { JSON.parse(staged.toString('utf8')); }
catch (error) {
console.error('Invalid staged JSON:', path, error.message);
errors++;
}
continue;
}
const mode = path.endsWith('.cjs') ? 'commonjs' : 'module';
const check = spawnSync(process.execPath, ['--check', '--input-type=' + mode], {
input: staged, encoding: 'utf8', maxBuffer: 16 * 1024 * 1024,
});
if (check.status !== 0) {
console.error('Invalid staged JavaScript:', path);
console.error(check.stderr || check.stdout);
errors++;
}
}
if (errors) process.exit(1);
console.log('staged JS/JSON syntax: OK');
18 changes: 18 additions & 0 deletions scripts/install-git-hooks.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
#!/bin/sh
# Install reproducible project hooks without replacing a custom local setup.
set -eu
cd "$(dirname "$0")/.."

# npm install also runs prepare in non-Git deployment archives.
[ -e .git ] || exit 0
[ "${CI:-}" = "true" ] && exit 0

current="$(git config --local --get core.hooksPath || true)"
if [ -n "$current" ] && [ "$current" != ".githooks" ]; then
printf '%s\n' "git hooks: preserving existing core.hooksPath=$current"
exit 0
fi

chmod +x .githooks/pre-commit
git config --local core.hooksPath .githooks
printf '%s\n' "git hooks: .githooks/pre-commit installed"
52 changes: 52 additions & 0 deletions tests/precommit.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, copyFileSync, writeFileSync, chmodSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';

const sourceRoot = fileURLToPath(new URL('..', import.meta.url));

function run(root, command, args, extraEnv = {}) {
return spawnSync(command, args, {
cwd: root,
encoding: 'utf8',
env: { ...process.env, CI: '', ...extraEnv },
});
}

test('installer activates a real hook that rejects bad staged content', () => {
const root = mkdtempSync(join(tmpdir(), 'memo-precommit-'));
try {
mkdirSync(join(root, '.githooks'));
mkdirSync(join(root, 'scripts'));
copyFileSync(join(sourceRoot, '.githooks/pre-commit'), join(root, '.githooks/pre-commit'));
copyFileSync(join(sourceRoot, 'scripts/install-git-hooks.sh'), join(root, 'scripts/install-git-hooks.sh'));
writeFileSync(join(root, 'package.json'), JSON.stringify({
private: true,
scripts: { 'check:staged': 'node -e "process.exit(0)"' },
}));

assert.equal(run(root, 'git', ['init', '-q']).status, 0);
assert.equal(run(root, 'sh', ['scripts/install-git-hooks.sh']).status, 0);
assert.equal(run(root, 'git', ['config', '--local', '--get', 'core.hooksPath']).stdout.trim(), '.githooks');

writeFileSync(join(root, 'sample.txt'), 'bad whitespace \n');
assert.equal(run(root, 'git', ['add', 'sample.txt']).status, 0);
const bad = run(root, 'sh', ['.githooks/pre-commit']);
assert.notEqual(bad.status, 0, 'trailing whitespace must fail');

writeFileSync(join(root, 'sample.txt'), 'clean content\n');
assert.equal(run(root, 'git', ['add', 'sample.txt']).status, 0);
const good = run(root, 'sh', ['.githooks/pre-commit']);
assert.equal(good.status, 0, good.stderr + good.stdout);

writeFileSync(join(root, '.env'), 'DUMMY=fake\n');
assert.equal(run(root, 'git', ['add', '-f', '.env']).status, 0);
const secret = run(root, 'sh', ['.githooks/pre-commit']);
assert.notEqual(secret.status, 0, 'credential-like files must fail');
} finally {
rmSync(root, { recursive: true, force: true });
}
});
Loading