Block pushes and commits to main with git hooks
pre-push rejects any push that would update refs/heads/main, however the command is spelled; pre-commit and pre-merge-commit refuse commits while main is checked out. npm install enables them via core.hooksPath. The README documents the three layers of protection for main. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
183e44db62
commit
8b08c7b27a
6 files changed
+84
-1
No files matched your search
Executable
+7
@@ -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
|
||||||
Executable
+2
@@ -0,0 +1,2 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
exec "$(dirname "$0")/pre-commit"
|
||||||
Executable
+11
@@ -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
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
A tiny todo CLI that exists to try out [Claude Code](https://docs.claude.com/en/docs/claude-code) features.
|
A tiny todo CLI that exists to try out [Claude Code](https://docs.claude.com/en/docs/claude-code) features.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
npm install # once per clone: turns on the git hooks in .githooks/
|
||||||
npm test
|
npm test
|
||||||
npm start -- add "try Claude Code"
|
npm start -- add "try Claude Code"
|
||||||
npm start -- done 1
|
npm start -- done 1
|
||||||
@@ -18,6 +19,12 @@ npm start -- ls
|
|||||||
| `.claude/skills/changelog/` | A custom skill — run `/changelog` |
|
| `.claude/skills/changelog/` | A custom skill — run `/changelog` |
|
||||||
| `.claude/agents/test-writer.md` | A custom subagent — "use the test-writer agent to…" |
|
| `.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
|
## Things to try
|
||||||
|
|
||||||
- *"There's a bug in how ids are assigned after removing a todo — find it and fix it with a test."*
|
- *"There's a bug in how ids are assigned after removing a todo — find it and fix it with a test."*
|
||||||
|
|||||||
+2
-1
@@ -6,7 +6,8 @@
|
|||||||
"bin": { "todo": "./src/cli.js" },
|
"bin": { "todo": "./src/cli.js" },
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node src/cli.js",
|
"start": "node src/cli.js",
|
||||||
"test": "node --test"
|
"test": "node --test",
|
||||||
|
"prepare": "git config core.hooksPath .githooks"
|
||||||
},
|
},
|
||||||
"engines": { "node": ">=20" },
|
"engines": { "node": ">=20" },
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in new issue
Block a user