2026-10-21 00:44:30 +02:00
|
|
|
import { test, after } from "node:test";
|
2026-10-21 00:39:56 +02:00
|
|
|
import assert from "node:assert/strict";
|
|
|
|
|
import { spawnSync } from "node:child_process";
|
2026-10-21 00:55:32 +02:00
|
|
|
import { mkdtempSync, rmSync, cpSync, statSync, writeFileSync, existsSync } from "node:fs";
|
2026-10-21 00:39:56 +02:00
|
|
|
import { tmpdir } from "node:os";
|
|
|
|
|
import { join } from "node:path";
|
|
|
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
|
|
2026-10-21 00:44:30 +02:00
|
|
|
const ROOT = fileURLToPath(new URL("../", import.meta.url));
|
|
|
|
|
const HOOKS = join(ROOT, ".githooks");
|
2026-10-21 00:39:56 +02:00
|
|
|
const SHA = "1".repeat(40);
|
|
|
|
|
const ZERO = "0".repeat(40);
|
|
|
|
|
|
2026-10-21 00:44:30 +02:00
|
|
|
// Run git and the hooks without inheriting GIT_DIR & co, e.g. when tests run from inside a hook.
|
|
|
|
|
const env = Object.fromEntries(Object.entries(process.env).filter(([k]) => !k.startsWith("GIT_")));
|
|
|
|
|
const run = (cmd, args, opts = {}) => spawnSync(cmd, args, { env, encoding: "utf8", ...opts });
|
|
|
|
|
|
|
|
|
|
const dirs = [];
|
|
|
|
|
after(() => dirs.forEach((d) => rmSync(d, { recursive: true, force: true })));
|
2026-10-21 00:39:56 +02:00
|
|
|
|
|
|
|
|
function repoOn(branch) {
|
|
|
|
|
const dir = mkdtempSync(join(tmpdir(), "todo-hooks-"));
|
2026-10-21 00:44:30 +02:00
|
|
|
dirs.push(dir);
|
|
|
|
|
const init = run("git", ["init", "-q", "-b", branch, dir]);
|
|
|
|
|
assert.equal(init.status, 0, `git init failed: ${init.stderr}`);
|
2026-10-21 00:39:56 +02:00
|
|
|
return dir;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-21 00:44:30 +02:00
|
|
|
function prePush(...lines) {
|
|
|
|
|
return run("sh", [join(HOOKS, "pre-push"), "origin", "git@example:repo.git"], {
|
|
|
|
|
input: lines.map((l) => l + "\n").join(""),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-21 00:39:56 +02:00
|
|
|
for (const [label, line] of [
|
|
|
|
|
["an update", `refs/heads/fix/x ${SHA} refs/heads/main ${SHA}`],
|
|
|
|
|
["a new ref", `refs/heads/fix/x ${SHA} refs/heads/main ${ZERO}`],
|
|
|
|
|
["a deletion", `(delete) ${ZERO} refs/heads/main ${SHA}`],
|
|
|
|
|
]) {
|
|
|
|
|
test(`pre-push rejects ${label} of main`, () => {
|
|
|
|
|
const res = prePush(line);
|
|
|
|
|
assert.equal(res.status, 1);
|
|
|
|
|
assert.match(res.stderr, /pushing to main is not allowed/);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
test("pre-push rejects --all style pushes that include main", () => {
|
|
|
|
|
const res = prePush(`refs/heads/fix/x ${SHA} refs/heads/fix/x ${SHA}`, `refs/heads/main ${SHA} refs/heads/main ${SHA}`);
|
|
|
|
|
assert.equal(res.status, 1);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("pre-push allows other branches, including ones named like main", () => {
|
|
|
|
|
const res = prePush(`refs/heads/fix/x ${SHA} refs/heads/fix/x ${ZERO}`, `refs/heads/maintenance ${SHA} refs/heads/maintenance ${SHA}`);
|
|
|
|
|
assert.equal(res.status, 0);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
for (const hook of ["pre-commit", "pre-merge-commit"]) {
|
2026-10-21 00:44:30 +02:00
|
|
|
test(`${hook} rejects main, names itself, and allows other branches`, () => {
|
|
|
|
|
const onMain = run("sh", [join(HOOKS, hook)], { cwd: repoOn("main") });
|
2026-10-21 00:39:56 +02:00
|
|
|
assert.equal(onMain.status, 1);
|
2026-10-21 00:44:30 +02:00
|
|
|
assert.match(onMain.stderr, new RegExp(`^${hook}: committing on main is not allowed`));
|
|
|
|
|
assert.equal(run("sh", [join(HOOKS, hook)], { cwd: repoOn("fix/x") }).status, 0);
|
2026-10-21 00:39:56 +02:00
|
|
|
});
|
|
|
|
|
}
|
2026-10-21 00:44:30 +02:00
|
|
|
|
|
|
|
|
test("install-hooks copies every hook into .git/hooks as an executable", () => {
|
|
|
|
|
const repo = repoOn("fix/x");
|
|
|
|
|
cpSync(join(ROOT, "scripts"), join(repo, "scripts"), { recursive: true });
|
|
|
|
|
cpSync(HOOKS, join(repo, ".githooks"), { recursive: true });
|
|
|
|
|
const res = run("sh", [join(repo, "scripts", "install-hooks.sh")]);
|
|
|
|
|
assert.equal(res.status, 0, res.stderr);
|
|
|
|
|
for (const hook of ["pre-push", "pre-commit", "pre-merge-commit"]) {
|
|
|
|
|
assert.ok(statSync(join(repo, ".git", "hooks", hook)).mode & 0o100, `${hook} is not executable`);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-21 00:55:32 +02:00
|
|
|
test("install-hooks ignores a global core.hooksPath", () => {
|
|
|
|
|
const repo = repoOn("fix/x");
|
|
|
|
|
const elsewhere = mkdtempSync(join(tmpdir(), "todo-hooks-"));
|
|
|
|
|
dirs.push(elsewhere);
|
|
|
|
|
const globalConfig = join(elsewhere, "gitconfig");
|
|
|
|
|
writeFileSync(globalConfig, `[core]\n\thooksPath = ${elsewhere}\n`);
|
|
|
|
|
cpSync(join(ROOT, "scripts"), join(repo, "scripts"), { recursive: true });
|
|
|
|
|
cpSync(HOOKS, join(repo, ".githooks"), { recursive: true });
|
|
|
|
|
const res = run("sh", [join(repo, "scripts", "install-hooks.sh")], { env: { ...env, GIT_CONFIG_GLOBAL: globalConfig } });
|
|
|
|
|
assert.equal(res.status, 0, res.stderr);
|
|
|
|
|
assert.ok(existsSync(join(repo, ".git", "hooks", "pre-push")));
|
|
|
|
|
assert.equal(existsSync(join(elsewhere, "pre-push")), false);
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-21 00:44:30 +02:00
|
|
|
test("install-hooks skips cleanly outside a git checkout", () => {
|
|
|
|
|
const dir = mkdtempSync(join(tmpdir(), "todo-hooks-"));
|
|
|
|
|
dirs.push(dir);
|
|
|
|
|
cpSync(join(ROOT, "scripts"), join(dir, "scripts"), { recursive: true });
|
|
|
|
|
const res = run("sh", [join(dir, "scripts", "install-hooks.sh")], { env: { ...env, GIT_CEILING_DIRECTORIES: tmpdir() } });
|
|
|
|
|
assert.equal(res.status, 0);
|
|
|
|
|
assert.match(res.stderr, /not a git checkout; skipping/);
|
|
|
|
|
});
|