From 2fa9d778e4c423cf18a03ecf6f1740b117a10245 Mon Sep 17 00:00:00 2001 From: AmisKwok Date: Fri, 14 Aug 2026 13:37:18 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8F=90=E4=BA=A4=E5=90=8E=E9=AB=98?= =?UTF-8?q?=E5=BD=B1=E5=93=8D=E7=BC=BA=E9=99=B7=E6=A3=80=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: traeagent --- website/package.json | 3 +- website/scripts/install-hook.mjs | 53 +++++++--- website/test/install-hook.test.mjs | 160 +++++++++++++++++++++++++++++ 3 files changed, 202 insertions(+), 14 deletions(-) create mode 100644 website/test/install-hook.test.mjs diff --git a/website/package.json b/website/package.json index 24a839c..1cac2bb 100644 --- a/website/package.json +++ b/website/package.json @@ -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", diff --git a/website/scripts/install-hook.mjs b/website/scripts/install-hook.mjs index 118516c..dbc2918 100644 --- a/website/scripts/install-hook.mjs +++ b/website/scripts/install-hook.mjs @@ -9,7 +9,9 @@ * .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 { @@ -17,6 +19,7 @@ import { chmodSync, existsSync, mkdirSync, + readFileSync, } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -25,15 +28,47 @@ 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. @@ -41,13 +76,5 @@ 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.'); diff --git a/website/test/install-hook.test.mjs b/website/test/install-hook.test.mjs new file mode 100644 index 0000000..ccc028a --- /dev/null +++ b/website/test/install-hook.test.mjs @@ -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.`);