Git hooks that don't fight you
Most git hooks are a tangle of inline bash that's slow, undebuggable, and impossible to run by hand. Point them at one OrchStep task instead — fast, readable, and runnable outside the hook.
blog/git-hooksGit hooks start with good intentions and end with --no-verify. The pre-commit hook grows from "run the formatter" into forty lines of bash wedged inside .git/hooks/pre-commit — a file that isn't in version control, can't be run by hand, and prints nothing useful when it fails. So people bypass it, and the checks it was supposed to enforce quietly stop happening.
The problem isn't hooks. It's putting logic in the hook. A hook should be one line that calls something you can also run yourself.
Move the logic into a task
Define the checks once, in the repo, as named tasks. The fast set for pre-commit, the heavier set for pre-push — and the heavy one just calls the fast one, so there's no duplication:
name: hooks
tasks:
# Fast gate for pre-commit: format + lint
precommit:
desc: "Format and lint (fast — for pre-commit)"
steps:
- name: fmt
func: shell
do: echo "gofmt -l ."
- name: lint
func: shell
do: echo "golangci-lint run"
# Heavier gate for pre-push: precommit + tests
prepush:
desc: "Format, lint, and test (for pre-push)"
steps:
- name: checks
task: precommit
- name: test
func: shell
do: echo "go test ./..."The prepush task calls precommit as a step (task: precommit) and then adds tests. Change what "lint" means in one place and both hooks pick it up.
Wire the hooks to it
Now the hooks are trivial — one line each, the same command you'd type yourself:
#!/usr/bin/env bash
# .git/hooks/pre-commit (not in version control)
set -euo pipefail
changed=$(git diff --cached --name-only --diff-filter=d | grep '\.go$' || true)
[ -z "$changed" ] && exit 0
gofmt -l $changed
if [ -n "$(gofmt -l $changed)" ]; then
echo "gofmt failed"; exit 1
fi
golangci-lint run || { echo "lint failed"; exit 1; }#!/usr/bin/env bash
# .git/hooks/pre-commit
exec orchstep run precommitThe "before" version lives outside git, can't be invoked except by committing, and reimplements failure handling by hand. The "after" version is one line — and the logic it calls is versioned, reviewable, and runnable on demand:
orchstep run precommit # exactly what the hook runs
orchstep run prepush # exactly what pre-push runsThat last point is the whole trick. When a hook blocks your commit, you don't guess — you run the same task in your terminal and read the output.
Keep it fast, keep it skippable
Hooks earn trust by being quick and honest. Keep precommit to formatting and linting — the sub-second stuff — and push the slow test suite to prepush, where you're already waiting on the network. And when you legitimately need to bypass, git commit --no-verify still works; a good hook doesn't need to trap you, because it's fast enough that you don't want to.
What you actually gained
| Concern | Inline bash hook | Hook → OrchStep task |
|---|---|---|
| In version control | no (.git/hooks/) | yes (orchstep.yml) |
| Run it by hand | only by committing | orchstep run precommit |
| Shared fast/full logic | copy-paste | task: call reuses steps |
| Readable failures | hand-rolled | named steps, clear output |
| Hook body | dozens of lines | one line |
Where OrchStep is not the answer
If you already use a hooks manager you like, you don't have to replace it — point it at orchstep run precommit and you still get the "runnable outside the hook" benefit. The win here is moving the logic out of the hook, wherever the hook itself is configured.
Where to go next
- Quick Start — your first workflow in two minutes
- Composition — calling one task from another
- shell — running your existing tools as steps
- The Task Menu — discover these tasks without memorizing names
Tired of fighting your own pre-commit hook? Move the logic into a task you can run by hand. Install the binary and start with the demo above.
curl -fsSL https://orchstep.dev/install.sh | sh