Skip to main content
Back to Blog
command hooksgit hookspre-commit hookwebhook securityhook automation

How to Apply Command Hooks: Git, CLI and Pipeline Examples

Greg Ceccarelli
Greg Ceccarelli
·14 min read

A teammate just pushed a commit that skipped the formatter, and the agent on the other machine ran a shell command nobody reviewed. You are staring at the same class of problem in two places: a Git repo and an automation that fires when something happens. How to apply command hooks starts with that shared pattern. A command hook is a shell command bound to a lifecycle event, not an adhesive strip on a wall. The event decides when the command runs and whether a bad result can stop the action.

Local hooks feel helpful until someone clones the repo fresh or passes --no-verify. Server hooks and CI do not share that weakness. The rest of this walkthrough covers picking the event, installing a Git hook, wiring the same idea into coding agents and frontend builds, splitting convenience from enforcement, locking down outbound calls, and rolling checks out without stalling the team.

Table of Contents

Pick the Right Event Before You Write a Hook

A command hook is a shell command bound to a lifecycle event. That event decides both the moment the command runs and whether a failure can stop the action. Pick too late and the bad change is already shared. Pick too early and you block work that a cheaper check never needed to see.

Use the table as the lookup, then attach the script to the narrowest row that matches the control.

EventRuns whenCan stop the action?Typical use
pre-commitBefore a commit is createdYes, nonzero exitFormatting, lint and unit tests on changed files
commit-msgAfter the message is written, before the commitYes, nonzero exitEnforcing message structure
pre-pushBefore commits leave the workstationYes, nonzero exitSecret scans and slower checks
pre-receiveServer side, once per push before refs changeYes, rejects the whole pushOrganization-wide policy
updateServer side, once per branch or tagYes, rejects that refBranch-level rules
post-receiveServer side, after refs updateNo, the action is completeNotifications and deploy triggers
PreToolUse (Claude Code)Before an agent runs a toolYes, blocks the tool callBlocking risky shell commands
PostToolUse (Claude Code)After an agent tool finishesNoFormatting or linting edited files

pre-commit is the right home for formatting, lint, and unit tests on changed files, because the commit does not exist yet. commit-msg only cares about the message text. pre-push is where secret scans and slower checks belong, still on the workstation, before data leaves. On the server, pre-receive can reject an entire push, and update can reject one branch or tag. post-receive runs after the refs move, so it can notify or trigger a deploy, but it cannot undo the push.

Claude Code uses the same split. PreToolUse runs before a tool call and can block it. PostToolUse runs after the tool finishes, which suits formatting files the agent just edited.

Practical rule: Attach the hook to the narrowest event that matches the control. A failed check on one commit is cheaper than a rejected push in front of the whole team.

How to Set Up a Git Command Hook Step by Step

Git hooks bind an executable to a lifecycle event so nobody has to remember the command. Client-side hooks run on a developer's machine during commit, merge, or push. Server-side hooks run in the repository that receives the push. The example below is a pre-commit hook that lints staged files.

A simple three-step guide illustrating how to set up a Git pre-commit command hook.

Install and prove the hook

  1. Open the repository's .git/hooks directory. That is the default location Git checks, unless configuration points elsewhere.
  2. Create a file named pre-commit with no extension, and start it with #!/bin/sh. The exact name is the event, and the shebang tells the shell how to run it.
  3. Add a lint command scoped to changed files, and exit nonzero when it fails. A hook that runs before the operation stops it with a nonzero exit status. A post-operation hook can record or notify, but it cannot undo the action.
  4. Run chmod +x on the file. Git skips a hook that is not executable, which is the usual reason a new hook "does nothing."
  5. Stage a file with a deliberate lint error and run git commit. You want the commit blocked and the script's message printed, or you have not proved the hook.
  6. Fix the error and commit again. The commit should go through, which shows the hook fails closed only on a real problem.

A hook that works on one laptop proves nothing about the next machine. Hooks are not transferred when a repository is cloned, so teams install them separately or manage a shared setup. Point Git at a tracked directory with core.hooksPath, or ship an install script everyone runs once. Until that step exists, the next clone has an empty hooks folder and the lint never runs.

This short walkthrough shows the same executable-hook idea in a live repo, including what a blocked commit looks like:

Keep the script small. Formatting and a fast lint on staged files belong here. A full integration suite does not. If the hook takes long enough that people start skipping it, you have put the wrong check on this event.

How to Attach Command Hooks to Coding Agents and Frontend Builds

The same block-on-failure pattern shows up outside .git/hooks. Two setups cover most product teams: Claude Code, and a frontend repo using Husky with lint-staged. A plain CLI chain is the third case, and it uses the identical exit code.

A male developer using dual monitors to configure automation hooks in a code editor for web projects.

Claude Code

Hooks live in the hooks object of .claude/settings.json, keyed by the event name. A matcher limits them to one tool, and type of command points at a script.

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": ".claude/hooks/block-rm.sh"
          }
        ]
      }
    ]
  }
}

hooks is the map of events. PreToolUse runs before the tool call, which is the only moment you can still stop a recursive delete. matcher set to Bash means file edits do not pay this cost. command is the script path.

The script receives the event as JSON on stdin. Read the command field with jq, and refuse anything that looks like rm -rf. An exit code of 2 blocks the call and sends stderr back to the agent. Confirm that exit-code behavior against the current Claude Code docs before you rely on it, because agent interfaces move. Teams wiring several agents into one workspace can treat this the same way they treat AI agent integration: the guard runs every time, not only when the model remembers the rule.

Husky, lint-staged, and any CLI

For a frontend repo, install Husky and lint-staged as dev dependencies. Run the Husky init command so it creates .husky/pre-commit, then call lint-staged from that file. Configure lint-staged to run Prettier and ESLint only on staged files. Edit one file badly, commit, and watch the hook stop the commit. If the bad file still lands, the hook is not on the path Git is executing.

Outside Git and agents, wire a script into the tool's own command chain so a linter runs before a release command and exits nonzero on failure. The release never starts. That is the whole pattern: the next command does not run unless this one returns success.

Local, Server and CI Hooks Compared

A hook that runs only on a developer's laptop is a convenience, not a control. Anyone can pass --no-verify, edit the script, or skip the install after a clone. If the check matters to the organization, it has to run somewhere the person pushing cannot turn off.

A comparison diagram illustrating the differences between local hooks, server hooks, and CI hooks in software development.

LayerWhere it runsCan the pusher skip it?Best for
Local hooks (pre-commit, pre-push)Developer machineYes, with --no-verify or an edited configFast formatting, lint and secret checks
Server hooks (pre-receive, update)Receiving repositoryNoOrganization-wide policy checks
CI pipelineShared runner after the pushNoIntegration and end-to-end tests

Server-side Git hooks are the organization-wide control point. pre-receive runs once before references update and can reject the whole push. update runs once per branch or tag. post-receive runs after the update and fits notifications, not policy. A local hook improves the developer's day. It is not a security boundary.

The two-layer pattern

Put inexpensive formatting, unit tests on changed files, and secret checks in pre-commit or pre-push, so people get feedback before they wait on a remote. Repeat the mandatory policy checks in pre-receive or update, so enforcement does not depend on a laptop config.

Fast, reversible checks stay local. Anything that gates a merge or a deploy stays at a trusted central boundary.

That split also fits AI-generated code. Let the agent format and lint on the machine that produced the diff. Do not let a merge depend on a hook the agent, or a hurried developer, can disable. OWASP lists CI/CD exploits and upstream infrastructure compromise among software supply-chain threats, which is a reason to treat these commands as controlled execution points with explicit scope, review, and permissions.

Mobile teams hit the same wall when a green laptop check is treated as a release. A shared pipeline, the kind covered in this guide to automate Expo app deployments, is where integration tests and store builds belong. Write the policy those pipelines enforce as executable specifications, so the server hook and the CI job check the same rule instead of two drifting copies.

How to Secure Outbound Hooks in Automation Pipelines

An outbound hook that triggers a deploy or opens a ticket is a production action. Treating the callback URL as "just a notification" is how unsigned payloads and leaked tokens show up in incident reviews. Give it the same care as the code it ships.

A graphic explaining how to secure outbound hooks in automation pipelines with three key production actions.

GitHub's webhook guidance lines up with a short fail-safe sequence:

  1. Send only over HTTPS. Reject plaintext endpoints when the config loads, which cuts off casual interception and a class of SSRF against internal hosts.
  2. Sign the raw payload with HMAC-SHA256 plus a timestamp. The receiver can tell a real sender from a spoofed POST.
  3. Reject signatures outside a five-minute tolerance. Old, captured requests cannot be replayed next week.
  4. Attach a unique event or idempotency ID. Duplicate delivery becomes detectable instead of mysterious.
  5. Acknowledge quickly. Return a 2XX within 10 seconds, then do the work asynchronously so the sender does not time out and retry a job you already started.
  6. Retry transient failures with exponential backoff and jitter. After a bounded count, move the event to a dead-letter queue instead of retrying forever.

Delivery rules that prevent duplicate work

Treat delivery as at-least-once. Persist the event ID before any side effect, and make every handler idempotent so a retry cannot open a second ticket, start a second deploy, or post a second message. The other rule is simpler and broken more often: never put credentials in callback URLs. Use a high-entropy webhook secret instead.

Storefront and billing callbacks fail the same way Git hooks do when the receiver is slow or the signature check is optional. A Shopify API integration reference is a useful cross-check for signing, retries, and idempotent handlers on commerce events. If the side effect is a ticket, apply the same signing and idempotency rules you would use for a Jira integration, or every retry becomes another duplicate issue.

Roll Out Command Hooks Without Blocking Your Team

A hook that runs the full test suite before every commit will eventually get bypassed. Roll it out in stages, and make each check blocking only after you have confirmed that it runs quickly and reports useful failures.

  1. Inventory commands and group them by purpose: formatting, static analysis, tests, security, or policy.
  2. Run cheap, deterministic checks first. Cache dependency and test results where possible.
  3. Keep local checks narrow. pre-commit should validate changed files; broader suites belong in CI.
  4. Set a latency budget and review actual run times. Slow hooks are a common reason developers disable them.
  5. Begin with advisory checks. Promote them to blocking after they establish a stable baseline, and allow an emergency bypass with a documented reason.
  6. Measure hook duration, failure causes, bypass rate, and post-merge defects. Whether hooks help depends on your starting point, so use these measures to judge the effect in your own workflow.
SymptomLikely causeFix
Hook never runsFile is not executable or has the wrong nameRun chmod +x and check the exact name in .git/hooks
Hook works in CI but not on a laptopHooks were not installed after cloningSet core.hooksPath or run the shared install script
Commit blocked with no explanationScript exits nonzero without explaining whyPrint the failing check and exit with an explicit code
Commit takes minutesFull test suite runs in pre-commitMove the suite to CI and run only changed files
Developers keep using --no-verifyRuns are slow or produce false positivesMeasure duration, tune checks, and document the emergency bypass
Passes locally, fails on the serverTool versions or environment variables differPin tool versions and run the same command in CI

Install one hook this week for the check that causes the most pain. Set its latency budget, then review the bypass log after two weeks. Frequent bypasses point to a slow or noisy check, not careless developers.

The same rollout principle applies to coding-agent and webhook hooks: local commands provide fast feedback, while server-side checks must enforce rules that local users can bypass. Hook behavior depends on the event, so test the actual command and failure path before making it blocking.


SpecStory, Inc. builds Stoa, a shared workspace where product teams turn live decisions into executable context, drafted specs, and code traceable to the conversation. Command hooks can run agreed checks on a commit, an agent tool call, or a pipeline event, rather than leaving them in a document people may not open. See how that fits your team's path from agreement to first commit at SpecStory, Inc..

Newsletter

Get new posts in your inbox

Bring your team together to build better products. Fresh takes on remote collaboration and AI-driven development.