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
3 changes: 2 additions & 1 deletion website/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
"build": "next build",
"start": "next start",
"export": "next build",
"setup-hook": "node scripts/install-hook.mjs"
"setup-hook": "node scripts/install-hook.mjs",
"test": "node test/install-hook.test.mjs"
},
"dependencies": {
"next": "14.2.15",
Expand Down
53 changes: 40 additions & 13 deletions website/scripts/install-hook.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,17 @@
* .git/hooks/pre-commit and makes it executable, so that committing
* automatically builds & syncs the documentation site.
*
* Safe to run repeatedly. Does nothing destructive.
* Safe to run repeatedly: if the installed hook already matches the template it
* is a no-op. If a different pre-commit hook already exists, it is backed up
* before being replaced (use --force to skip the backup).
*/

import {
copyFileSync,
chmodSync,
existsSync,
mkdirSync,
readFileSync,
} from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
Expand All @@ -25,29 +28,53 @@ import { execSync } from 'node:child_process';
const scriptDir = dirname(fileURLToPath(import.meta.url));
const repoRoot = join(scriptDir, '..', '..');
const template = join(scriptDir, 'pre-commit');
const hooksDir = join(repoRoot, '.git', 'hooks');
const target = join(hooksDir, 'pre-commit');

if (!existsSync(template)) {
console.error('[install-hook] Missing hook template:', template);
process.exit(1);
}

mkdirSync(hooksDir, { recursive: true });
// Resolve the real hooks directory (handles worktrees and non-default git dirs)
// before doing anything that could touch the filesystem.
let hooksDir;
try {
hooksDir = execSync('git rev-parse --git-path hooks', {
cwd: repoRoot,
stdio: 'pipe',
})
.toString()
.trim();
} catch {
console.error('[install-hook] Not inside a git repository?');
process.exit(1);
}

const target = join(repoRoot, hooksDir, 'pre-commit');

// Protect an existing pre-commit hook from silent overwrite. If the target is
// already identical to the template, the install is a no-op (idempotent).
if (existsSync(target)) {
const existing = readFileSync(target, 'utf8');
const desired = readFileSync(template, 'utf8');
if (existing === desired) {
console.log('[install-hook] Pre-commit hook already up to date.');
process.exit(0);
}
const force = process.argv.includes('--force');
if (!force) {
const backup = `${target}.backup-${Date.now()}`;
copyFileSync(target, backup);
console.log('[install-hook] Existing pre-commit hook backed up to:', backup);
}
}

mkdirSync(dirname(target), { recursive: true });
copyFileSync(template, target);

// Make executable on POSIX; on Windows the .sh is run by git's shell, chmod is a no-op-safe call.
try {
chmodSync(target, 0o755);
} catch {}

// Verify git can find the hook path (basic sanity).
try {
execSync('git rev-parse --git-dir', { cwd: repoRoot, stdio: 'pipe' });
} catch {
console.error('[install-hook] Not inside a git repository?');
process.exit(1);
}

console.log('[install-hook] Pre-commit hook installed at .git/hooks/pre-commit');
console.log('[install-hook] Pre-commit hook installed at', target);
console.log('[install-hook] It will build & sync the docs site on relevant commits.');
160 changes: 160 additions & 0 deletions website/test/install-hook.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
#!/usr/bin/env node
/**
* Regression tests for website/scripts/install-hook.mjs.
*
* Verifies that the installer:
* - creates the pre-commit hook on first run
* - is idempotent when the hook already matches the template
* - backs up (rather than silently overwrites) an existing custom hook
* - overwrites without backup when --force is passed
*/

import { execSync } from 'node:child_process';
import {
cpSync,
existsSync,
mkdtempSync,
mkdirSync,
readdirSync,
readFileSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import assert from 'node:assert/strict';

const __dirname = dirname(fileURLToPath(import.meta.url));
const repoRoot = join(__dirname, '..', '..');
const scriptSrc = join(repoRoot, 'website', 'scripts', 'install-hook.mjs');
const templateSrc = join(repoRoot, 'website', 'scripts', 'pre-commit');

function makeTempRepo() {
const dir = mkdtempSync(join(tmpdir(), 'zik-install-hook-'));
try {
execSync('git init', { cwd: dir, stdio: 'ignore' });
mkdirSync(join(dir, 'website', 'scripts'), { recursive: true });
cpSync(scriptSrc, join(dir, 'website', 'scripts', 'install-hook.mjs'));
cpSync(templateSrc, join(dir, 'website', 'scripts', 'pre-commit'));
return dir;
} catch (err) {
rmSync(dir, { recursive: true, force: true });
throw err;
}
}

function hookPath(dir) {
return join(dir, '.git', 'hooks', 'pre-commit');
}

function runInstaller(dir, args = '') {
return execSync(
`node ${join(dir, 'website', 'scripts', 'install-hook.mjs')} ${args}`,
{ cwd: dir, encoding: 'utf8', stdio: 'pipe' },
);
}

function listBackups(dir) {
const hooksDir = join(dir, '.git', 'hooks');
if (!existsSync(hooksDir)) return [];
return readdirSync(hooksDir).filter((n) => n.startsWith('pre-commit.backup-'));
}

const tests = [];
function test(name, fn) {
tests.push({ name, fn });
}

test('installs hook into a fresh git repository', () => {
const dir = makeTempRepo();
try {
const out = runInstaller(dir);
assert.ok(existsSync(hookPath(dir)), 'pre-commit hook should be created');
assert.ok(out.includes('installed'), 'installer should report success');
assert.strictEqual(
readFileSync(hookPath(dir), 'utf8'),
readFileSync(templateSrc, 'utf8'),
'installed hook should match template',
);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});

test('is idempotent when hook already matches template', () => {
const dir = makeTempRepo();
try {
runInstaller(dir);
const before = readFileSync(hookPath(dir), 'utf8');
const out = runInstaller(dir);
const after = readFileSync(hookPath(dir), 'utf8');
assert.strictEqual(before, after, 'hook should be unchanged');
assert.ok(out.includes('already up to date'), 'installer should report idempotency');
assert.deepStrictEqual(listBackups(dir), [], 'should not create backup files');
} finally {
rmSync(dir, { recursive: true, force: true });
}
});

test('backs up an existing custom hook instead of overwriting it', () => {
const dir = makeTempRepo();
const custom = '#!/bin/sh\necho custom\n';
try {
runInstaller(dir);
writeFileSync(hookPath(dir), custom, { mode: 0o755 });

const out = runInstaller(dir);
const backups = listBackups(dir);
assert.ok(backups.length > 0, 'installer should create a backup');
assert.strictEqual(
readFileSync(join(dir, '.git', 'hooks', backups[0]), 'utf8'),
custom,
'backup should contain the original custom hook',
);
assert.strictEqual(
readFileSync(hookPath(dir), 'utf8'),
readFileSync(templateSrc, 'utf8'),
'target should now contain the template',
);
assert.ok(out.includes('backed up'), 'installer should mention the backup');
} finally {
rmSync(dir, { recursive: true, force: true });
}
});

test('--force overwrites an existing custom hook without creating a backup', () => {
const dir = makeTempRepo();
const custom = '#!/bin/sh\necho custom\n';
try {
runInstaller(dir);
writeFileSync(hookPath(dir), custom, { mode: 0o755 });

runInstaller(dir, '--force');
assert.deepStrictEqual(listBackups(dir), [], 'should not create backup files with --force');
assert.strictEqual(
readFileSync(hookPath(dir), 'utf8'),
readFileSync(templateSrc, 'utf8'),
'target should contain the template',
);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});

let failed = false;
for (const { name, fn } of tests) {
try {
fn();
console.log(` ✓ ${name}`);
} catch (err) {
failed = true;
console.error(` ✗ ${name}`);
console.error(err.message);
}
}

if (failed) {
process.exit(1);
}
console.log(`\n${tests.length} install-hook tests passed.`);