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
4 changes: 4 additions & 0 deletions .githooks/pre-commit
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
#!/usr/bin/env sh
set -eu

npm run precommit:check
25 changes: 25 additions & 0 deletions docs/pre-commit-hooks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Pre-Commit Hook Suite

The repository uses a Git `pre-commit` hook to keep code quality checks close to the point where changes are authored.

## Architecture

- `.githooks/pre-commit` is the Git entry point and delegates to `npm run precommit:check`.
- `scripts/pre-commit.mjs` discovers staged files with `git diff --cached --name-only --diff-filter=ACMR`.
- The hook only runs checks that are relevant to the staged files:
- text safety scan for unresolved merge markers and common secret patterns;
- ESLint for staged JavaScript and TypeScript files;
- Vitest for staged unit/component test files;
- package lock synchronization when `package.json` or `package-lock.json` changes.
- `npm install` runs the `prepare` script, which sets `core.hooksPath` to `.githooks` for the local checkout.

## Operational Notes

- Run `npm run precommit:check` manually before pushing to reproduce hook behavior.
- Use `git commit --no-verify` only for emergency recovery, and follow up with a normal commit that passes the hook before opening a pull request.
- CI should still run the full build, test, and security review suite; the local hook is a fast feedback layer, not a replacement for CI.

## Monitoring and Rollout

- Treat hook failures as local quality signals; aggregate CI failures by command name (`eslint`, `vitest`, and lockfile sync) for dashboards and alerting.
- Roll out hook policy changes in canary fashion by first documenting the expected commands, then enabling them in `.githooks/pre-commit` after developers have the dependencies installed.
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

97 changes: 97 additions & 0 deletions scripts/pre-commit.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
#!/usr/bin/env node
import { spawnSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";

const TEXT_FILE_PATTERN = /\.(?:[cm]?[jt]sx?|json|md|css|scss|html|ya?ml|mjs|cjs|sh)$/i;
const LINT_FILE_PATTERN = /\.(?:[cm]?[jt]sx?|[cm]?js)$/i;
const TEST_FILE_PATTERN = /^tests\/.*\.test\.(?:[cm]?[jt]sx?)$/i;
const SECRET_PATTERN = /(AKIA[0-9A-Z]{16}|-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----|(?:api[_-]?key|secret|token|password)\s*[:=]\s*["'][^"']{16,})/i;

export function getStagedFiles(git = runGit) {
const result = git(["diff", "--cached", "--name-only", "--diff-filter=ACMR"]);
if (result.status !== 0) {
throw new Error(result.stderr || "Unable to read staged files");
}
return result.stdout.split(/\r?\n/).map((file) => file.trim()).filter(Boolean);
}

export function planChecks(files) {
const existing = files.filter((file) => existsSync(file));
const lintFiles = existing.filter((file) => LINT_FILE_PATTERN.test(file));
const textFiles = existing.filter((file) => TEXT_FILE_PATTERN.test(file));
const commands = [];

if (lintFiles.length > 0) {
commands.push(["npx", ["eslint", "--max-warnings=0", ...lintFiles]]);
}

const testFiles = existing.filter((file) => TEST_FILE_PATTERN.test(file));
if (testFiles.length > 0) {
commands.push(["npm", ["test", "--", ...testFiles, "--passWithNoTests"]]);
}

if (existing.includes("package.json") || existing.includes("package-lock.json")) {
commands.push(["npm", ["install", "--package-lock-only", "--ignore-scripts"]]);
}

return { textFiles, commands };
}

export function scanTextFiles(files) {
const violations = [];
for (const file of files) {
const content = readFileSync(file, "utf8");
if (/^(<<<<<<< |=======|>>>>>>> )/m.test(content)) {
violations.push(`${file}: contains unresolved merge conflict markers`);
}
if (SECRET_PATTERN.test(content)) {
violations.push(`${file}: contains a value that looks like a secret`);
}
}
return violations;
}

function runGit(args) {
return spawnSync("git", args, { encoding: "utf8" });
}

function runCommand(command, args) {
process.stdout.write(`pre-commit: ${command} ${args.join(" ")}\n`);
return spawnSync(command, args, { stdio: "inherit", shell: process.platform === "win32" });
}

export function main({ run = runCommand, git = runGit } = {}) {
let files;
try {
files = getStagedFiles(git);
} catch (error) {
console.error(`pre-commit: ${error.message}`);
return 1;
}

if (files.length === 0) {
process.stdout.write("pre-commit: no staged files to check\n");
return 0;
}

const { textFiles, commands } = planChecks(files);
const violations = scanTextFiles(textFiles);
if (violations.length > 0) {
console.error(["pre-commit: blocked commit", ...violations.map((violation) => `- ${violation}`)].join("\n"));
return 1;
}

for (const [command, args] of commands) {
const result = run(command, args);
if (result.status !== 0) {
return result.status ?? 1;
}
}

process.stdout.write("pre-commit: all checks passed\n");
return 0;
}

if (import.meta.url === `file://${process.argv[1]}`) {
process.exit(main());
}
58 changes: 58 additions & 0 deletions tests/unit/preCommit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";

import { planChecks, scanTextFiles } from "../../scripts/pre-commit.mjs";

let tempDirs: string[] = [];

afterEach(() => {
for (const dir of tempDirs) {
rmSync(dir, { recursive: true, force: true });
}
tempDirs = [];
});

function tempFile(name: string, content: string) {
const dir = mkdtempSync(join(tmpdir(), "pre-commit-"));
tempDirs.push(dir);
const file = join(dir, name);
writeFileSync(file, content);
return file;
}

describe("pre-commit hook planning", () => {
it("runs lint and targeted tests for staged test files", () => {
const file = "tests/unit/preCommit.test.ts";

const plan = planChecks([file]);

expect(plan.commands).toContainEqual(["npx", ["eslint", "--max-warnings=0", file]]);
expect(plan.commands).toContainEqual(["npm", ["test", "--", file, "--passWithNoTests"]]);
});

it("runs lockfile synchronization for package changes", () => {
const plan = planChecks(["package.json"]);

expect(plan.commands).toContainEqual(["npm", ["install", "--package-lock-only", "--ignore-scripts"]]);
});
});

describe("pre-commit text scanning", () => {
it("blocks unresolved merge conflict markers", () => {
const file = tempFile("conflict.ts", ["<<<<<<< HEAD", "const a = 1;", "=======", "const a = 2;", ">>>>>>> branch", ""].join("\n"));

expect(scanTextFiles([file])).toEqual([
`${file}: contains unresolved merge conflict markers`,
]);
});

it("blocks common secret-shaped values", () => {
const file = tempFile("secret.ts", "const token = '" + "12345678901234567890" + "';\n");

expect(scanTextFiles([file])).toEqual([
`${file}: contains a value that looks like a secret`,
]);
});
});
Loading