BLOG/DEVELOPER DAILY
THEME
DEVELOPER DAILY

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.

Jun 22, 2026 OrchStep Team 5 minROLE: AnySCALE: Any
RUNNABLE DEMO
Full source for this post: blog/git-hooks
VIEW SOURCE

Git 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:

orchstep.yml
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:

BEFORE — LOGIC IN THE HOOK
#!/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; }
AFTER — HOOK CALLS THE TASK
#!/usr/bin/env bash
# .git/hooks/pre-commit
exec orchstep run precommit

The "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 runs

That 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

ConcernInline bash hookHook → OrchStep task
In version controlno (.git/hooks/)yes (orchstep.yml)
Run it by handonly by committingorchstep run precommit
Shared fast/full logiccopy-pastetask: call reuses steps
Readable failureshand-rollednamed steps, clear output
Hook bodydozens of linesone 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.

#GIT#GIT-HOOKS#LINTING#TASK-RUNNER
Try it in two minutes — one binary, no signup.
curl -fsSL https://orchstep.dev/install.sh | sh

RELATED — DEVELOPER DAILY