06 · Integrating with Git Hooks & CI Scripts¶
Shell scripts are the glue behind most git workflows and CI pipelines. This module covers writing git hooks (scripts that run automatically at points in the git lifecycle) and shell steps for CI systems like GitHub Actions.
Git hooks: where they live¶
| Hook | Runs when |
|---|---|
pre-commit |
before a commit is created (can abort it) |
commit-msg |
after the commit message is written (can validate/reject it) |
pre-push |
before git push sends anything to the remote |
post-checkout |
after git checkout/git switch |
post-merge |
after a successful git merge (including git pull) |
A pre-commit hook: block commits with debug statements¶
#!/usr/bin/env bash
# .git/hooks/pre-commit
set -euo pipefail
staged_files=$(git diff --cached --name-only --diff-filter=ACM -- '*.sh')
if [[ -z "$staged_files" ]]; then
exit 0 # no shell files staged — nothing to check
fi
found_issue=0
while IFS= read -r file; do
if grep -nE "^\s*(echo\s+\"?DEBUG|set -x\b)" "$file" > /dev/null; then
echo "pre-commit: '$file' contains leftover debug output — remove before committing" >&2
found_issue=1
fi
done <<< "$staged_files"
exit "$found_issue"
A non-zero exit from pre-commit aborts the commit — this pattern
catches an entire class of "oops, forgot to remove debug code" mistakes
before they ever reach the repository.
A pre-commit hook: run shellcheck on staged scripts¶
#!/usr/bin/env bash
# .git/hooks/pre-commit
set -euo pipefail
staged_sh_files=$(git diff --cached --name-only --diff-filter=ACM -- '*.sh')
[[ -z "$staged_sh_files" ]] && exit 0
failed=0
while IFS= read -r file; do
if ! shellcheck "$file"; then
failed=1
fi
done <<< "$staged_sh_files"
if [[ "$failed" -eq 1 ]]; then
echo "pre-commit: shellcheck failed — fix the warnings above, or 'git commit --no-verify' to bypass" >&2
exit 1
fi
A commit-msg hook: enforce a message format¶
#!/usr/bin/env bash
# .git/hooks/commit-msg
set -euo pipefail
commit_msg_file="$1"
commit_msg=$(head -1 "$commit_msg_file")
if [[ ! "$commit_msg" =~ ^(feat|fix|docs|refactor|test|chore)(\(.+\))?:\ .+ ]]; then
echo "commit-msg: message must follow 'type: description' (e.g. 'fix: correct off-by-one')" >&2
echo "Got: $commit_msg" >&2
exit 1
fi
commit-msg hooks receive the path to a temp file holding the message as
$1 — this is how you validate conventional-commit style formatting
automatically.
Sharing hooks across a team¶
Hooks in .git/hooks/ are not version-controlled by default. The
standard fix is core.hooksPath:
mkdir -p .githooks
# move your hook scripts into .githooks/, commit them normally
git config core.hooksPath .githooks
Every contributor who runs git config core.hooksPath .githooks (often
via a one-time make setup or ./scripts/bootstrap.sh) then shares the
exact same hooks, tracked in version control like any other code.
Shell steps in CI (GitHub Actions example)¶
# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install shellcheck
run: sudo apt-get update && sudo apt-get install -y shellcheck
- name: Lint all shell scripts
run: |
set -euo pipefail
find . -name "*.sh" -print0 | xargs -0 shellcheck
- name: Install bats
run: sudo apt-get install -y bats
- name: Run tests
run: bats tests/*.bats
CI runners execute each run: block as a shell script — the same
set -euo pipefail discipline from Level 2 applies directly, and a failing
step (non-zero exit) fails the whole pipeline, exactly like a local script.
A pre-push hook: prevent pushing directly to main¶
#!/usr/bin/env bash
# .git/hooks/pre-push
set -euo pipefail
protected_branch="main"
current_branch=$(git rev-parse --abbrev-ref HEAD)
if [[ "$current_branch" == "$protected_branch" ]]; then
echo "pre-push: direct pushes to '$protected_branch' are blocked — open a PR instead" >&2
exit 1
fi
How It Actually Works¶
A git hook (like pre-push or pre-commit) is nothing magical to the
kernel — it's an ordinary executable file under .git/hooks/ that git
fork()s and execve()s at a specific point in its own internal command
sequence, passing hook-specific data either as command-line arguments or
piped to the hook's stdin (the exact convention differs per hook type). Git
then checks that hook process's exit status exactly like a shell checks any
command: nonzero aborts the git operation before it proceeds, zero lets it
continue — the hook has no other channel to communicate "stop" back to git.
Because a hook script inherits the environment git itself was invoked with
(not necessarily your full interactive shell environment, especially when
git is invoked from a GUI client or CI runner), hooks that rely on tools
only found via an interactively-configured $PATH are a common source of
"works in my terminal, fails as a hook" bugs — the same root cause as the
cron $PATH issue covered earlier in this course, because both are cases of
a script executing with a minimal, non-login-shell environment.
CI runners work the same way at a larger scale: each pipeline step is
typically its own forked process (often inside a fresh container), so
state like cd or exported variables from one script step doesn't persist
into the next step unless the CI system explicitly serializes and re-injects
environment files between steps — mirroring exactly why background jobs and
subshells in bash can't leak state back to their parent.
Cheat sheet¶
| Hook/tool | Purpose |
|---|---|
.git/hooks/pre-commit |
validate/lint before a commit is created |
.git/hooks/commit-msg |
validate the commit message text |
.git/hooks/pre-push |
validate before pushing to a remote |
core.hooksPath |
point git at a version-controlled hooks directory |
git diff --cached --name-only |
list staged files (for a pre-commit hook) |
CI run: step |
just a shell script — same conventions apply |
Exercise¶
Set up .githooks/pre-commit in a test repo that runs shellcheck against
every staged .sh file and blocks the commit if any fail, wire it up with
git config core.hooksPath .githooks, and confirm it actually blocks a
commit containing a deliberately unquoted variable. Then write a matching
GitHub Actions workflow that runs the same shellcheck check in CI.