Hooks and automation: let Git check your work
Run checks automatically: hooks that block secrets and bad commit messages, shared hooks with the pre-commit framework, scripting GitHub with gh, and a first GitHub Actions workflow.
By the end of this lesson you'll be able to
0 of 6 completeClient and server hooks
A hook is a script Git runs at a fixed moment. If a hook that runs before something exits non-zero, Git stops: no commit, no push. That makes hooks the earliest, cheapest place to catch mistakes:
| Hook | Runs | Typical check |
|---|---|---|
pre-commit | before the commit is created | secrets, formatting, lint on staged files |
commit-msg | after you write the message | message format |
pre-push | before anything is sent | run the fast tests |
post-merge, post-checkout | after the operation | reinstall dependencies if the lockfile changed |
pre-receive, update | on the server, when a push arrives | reject pushes (self-hosted Git servers) |
On GitHub you don't run server hooks yourself; push protection, rulesets and Actions play that role (the Collaboration and Security lessons).
Writing hooks
Hooks live in .git/hooks/, named after the event, and must be executable. Git ships samples ending in
.sample. These two are the most useful you can write:
#!/usr/bin/env bash # .githooks/pre-commit: block commits that add something that looks like a secret. pattern='AKIA[0-9A-Z]{16}|api[_-]?key[[:space:]]*=[[:space:]]*["'\''][^"'\'']{8,}|-----BEGIN [A-Z ]*PRIVATE KEY-----' if git diff --cached -U0 | grep '^+' | grep -qiE "$pattern"; then echo "pre-commit: this commit seems to add a secret. Remove it, or use --no-verify if you are sure." >&2 exit 1 fi
#!/usr/bin/env bash # .githooks/commit-msg: require a Conventional Commits subject, type(scope)!: description if ! head -1 "$1" | grep -qE '^(feat|fix|docs|chore|refactor|test|ci|perf|build)(\([a-z0-9-]+\))?!?: .+'; then echo "commit-msg: start the subject with a type, e.g. 'fix: handle an empty cart'" >&2 exit 1 fi
git diff --cached is what's staged, the exact content of the commit; grep '^+' keeps only added lines,
so removing a secret is never blocked. The commit-msg hook gets the path of the message file as $1.
$ chmod +x .githooks/* $ git config core.hooksPath .githooks # use the tracked folder instead of .git/hooks $ git commit -m "feat: add config" # with API_KEY = "sk-live-1234567890" staged pre-commit: this commit seems to add a secret. Remove it, or use --no-verify if you are sure. $ git commit -m "add config" # safe code, bad message commit-msg: start the subject with a type, e.g. 'fix: handle an empty cart' $ git commit -m "feat: read the API key from the environment"
chmod +x .githooks/*chmodchange permissions
git config core.hooksPath .githooksgitversion controlconfigread or set settings
git commit -m "feat: add config"commitsave staged changes
.git/hooks is never pushed or cloned, so a tracked .githooks/ folder plus core.hooksPath is how you
share hooks. Each teammate runs the git config line once (put it in your README or setup script).
The pre-commit framework
Hand-written hooks get hard to share across languages and machines. The pre-commit framework installs hooks from a config file, pins their versions, and runs them only on the files you changed:
# .pre-commit-config.yaml repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v6.0.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer - id: check-yaml - id: check-added-large-files - id: detect-private-key - repo: https://github.com/gitleaks/gitleaks rev: v8.30.1 hooks: - id: gitleaks
$ pipx install pre-commit # or pip install pre-commit $ pre-commit install # writes .git/hooks/pre-commit for this clone $ pre-commit run --all-files # check the whole repository once $ pre-commit autoupdate # move every hook to its latest release
JavaScript projects often use husky and lint-staged for the same job. Whichever you pick, run the same tools in CI.
Scripting GitHub with gh
The GitHub CLI turns everything you click on GitHub into commands you can script:
$ gh pr list --author @me --state open # my open pull requests $ gh pr checks # CI status of this branch's pull request $ gh run list --limit 5 # recent Actions runs $ gh run watch # follow a run live $ gh run view --log-failed # only the logs of failed steps $ gh issue list --label bug --json number,title --jq '.[] | "\(.number) \(.title)"' $ gh secret set DEPLOY_KEY < key.txt # set an Actions secret from a file $ gh api repos/{owner}/{repo}/branches/main/protection # any REST endpoint gh doesn't wrap
gh secret set DEPLOY_KEY < key.txt<read input from a file
--json and --jq give you stable output for scripts. gh api reaches the whole GitHub API with your
login, and {owner}/{repo} fill in from the current repository.
Your first GitHub Actions workflow
GitHub Actions runs jobs on GitHub's machines when something happens in your repository. A workflow is
a YAML file in .github/workflows/:
# .github/workflows/ci.yml name: CI on: pull_request: push: branches: [main] jobs: test: runs-on: ubuntu-latest timeout-minutes: 10 steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: '3.12' - run: pip install pre-commit pytest - run: pre-commit run --all-files - run: pytest
| Part | Means |
|---|---|
on | which events start it: here every pull request, and pushes to main |
jobs.test.runs-on | a fresh Ubuntu machine for each run |
uses | a reusable action: check out the code, install Python |
run | a shell command; a non-zero exit fails the job |
Push it on a branch and open a pull request: the run appears under the pull request's checks. Then, in your main ruleset, make test a required status check, and nothing failing can merge. The CI/CD course goes much deeper: caching, matrices, environments, secrets and deployments.
Guard a repository at both ends
Add a shared pre-commit hook that blocks secrets and a commit-msg hook that requires Conventional Commits, prove each one rejects a bad commit and accepts a good one, then add a CI workflow that runs the same checks on every pull request.
.githooks/ in the repository, and .github/workflows/ci.yml running on pull requestsReveal one safe solution
$ mkdir -p .githooks $ nano .githooks/pre-commit # paste the pre-commit hook from this lesson $ nano .githooks/commit-msg # paste the commit-msg hook $ chmod +x .githooks/* && git config core.hooksPath .githooks $ echo 'API_KEY = "sk-live-1234567890"' > config.py && git add config.py $ git commit -m "feat: add config" pre-commit: this commit seems to add a secret. Remove it, or use --no-verify if you are sure. $ printf 'import os\nAPI_KEY = os.environ["API_KEY"]\n' > config.py && git add config.py $ git commit -m "add config" commit-msg: start the subject with a type, e.g. 'fix: handle an empty cart' $ git commit -m "feat: read the API key from the environment" $ git add .githooks && git commit -m "chore: share the Git hooks" $ mkdir -p .github/workflows && nano .github/workflows/ci.yml # the workflow from this lesson $ git switch -c ci/add-workflow && git add .github && git commit -m "ci: run checks on pull requests" $ git push -u origin ci/add-workflow && gh pr create --fill $ gh pr checks --watch
mkdir -p .githooksmkdirmake a directory-pcreate missing parents, no error if it exists
chmod +x .githooks/* && git config core.hooksPath .githookschmodchange permissions&&run the next command only if this one workedgitversion controlconfigread or set settings
echo 'API_KEY = "sk-live-1234567890"' > config.py && git add config.pyechoprint text>write output to a file, replacing itaddstage changes
git commit -m "feat: add config"commitsave staged changes
git push -u origin ci/add-workflow && gh pr create --fillpushupload commits
The two rejected commits never happened; git log shows only the good ones. The workflow's run on the pull
request proves the checks also run where nobody can skip them.
If the repository has no tests yet, remove the pytest step: pytest exits with code 5 when it finds
nothing to run, which fails the job.
Try it yourself
0 of 6 steps doneRun each task in your own account or terminal, then tick it off.
Quick check
When does a pre-commit hook run, and what happens if it exits with 1?
Official reading for this lesson
Or get every quiz answer right and it completes itself.