diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..c771a84 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,7 @@ +#!/bin/sh +# Refuse commits (and, via pre-merge-commit, merge commits) while main is checked out. +if [ "$(git symbolic-ref --short -q HEAD)" = "main" ]; then + echo "$(basename "$0"): committing on main is not allowed; create a branch first." >&2 + exit 1 +fi +exit 0 diff --git a/.githooks/pre-merge-commit b/.githooks/pre-merge-commit new file mode 100755 index 0000000..e539569 --- /dev/null +++ b/.githooks/pre-merge-commit @@ -0,0 +1,2 @@ +#!/bin/sh +exec "$(dirname "$0")/pre-commit" diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 0000000..91b085a --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,11 @@ +#!/bin/sh +# Reject any push that would create, update or delete main on the remote, however the +# command is spelled (bare `git push`, HEAD:main, +main, refs/heads/main, --all, --mirror). +# Only a human merges into main, through a PR in Gitea. +while read -r local_ref local_sha remote_ref remote_sha; do + if [ "$remote_ref" = "refs/heads/main" ]; then + echo "pre-push: pushing to main is not allowed; push a branch and open a PR instead." >&2 + exit 1 + fi +done +exit 0 diff --git a/README.md b/README.md index ce35e50..1118054 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@ A tiny todo CLI that exists to try out [Claude Code](https://docs.claude.com/en/docs/claude-code) features. ```sh +npm install # once per clone: turns on the git hooks in .githooks/ npm test npm start -- add "try Claude Code" npm start -- done 1 @@ -18,6 +19,12 @@ npm start -- ls | `.claude/skills/changelog/` | A custom skill — run `/changelog` | | `.claude/agents/test-writer.md` | A custom subagent — "use the test-writer agent to…" | +### Protecting `main` +Changes reach `main` only through a PR that a human merges. Three layers, strongest first: +1. **Gitea branch protection** on `main` (set in the repo settings on code.westerkam.org): the only layer that can't be bypassed from a clone. +2. **Git hooks** in `.githooks/` (enabled by `npm install`): `pre-push` rejects any push that would update `main`, however it's spelled; `pre-commit` / `pre-merge-commit` refuse commits while `main` is checked out. `--no-verify` skips them. +3. **Claude Code deny rules** in `.claude/settings.json` for `git push … main`: a quick early stop, but they match command text, so they're easy to get around. + ## Things to try - *"There's a bug in how ids are assigned after removing a todo — find it and fix it with a test."* diff --git a/package.json b/package.json index 150c342..e4eb851 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "bin": { "todo": "./src/cli.js" }, "scripts": { "start": "node src/cli.js", - "test": "node --test" + "test": "node --test", + "prepare": "git config core.hooksPath .githooks" }, "engines": { "node": ">=20" }, "license": "MIT" diff --git a/test/hooks.test.js b/test/hooks.test.js new file mode 100644 index 0000000..c717f37 --- /dev/null +++ b/test/hooks.test.js @@ -0,0 +1,55 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const HOOKS = fileURLToPath(new URL("../.githooks/", import.meta.url)); +const SHA = "1".repeat(40); +const ZERO = "0".repeat(40); + +function prePush(...lines) { + return spawnSync("sh", [join(HOOKS, "pre-push"), "origin", "git@example:repo.git"], { + input: lines.map((l) => l + "\n").join(""), + encoding: "utf8", + }); +} + +function repoOn(branch) { + const dir = mkdtempSync(join(tmpdir(), "todo-hooks-")); + spawnSync("git", ["init", "-q", "-b", branch, dir]); + return dir; +} + +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"]) { + test(`${hook} rejects main and allows other branches`, () => { + const onMain = spawnSync("sh", [join(HOOKS, hook)], { cwd: repoOn("main"), encoding: "utf8" }); + assert.equal(onMain.status, 1); + assert.match(onMain.stderr, /committing on main is not allowed/); + assert.equal(spawnSync("sh", [join(HOOKS, hook)], { cwd: repoOn("fix/x") }).status, 0); + }); +}