00 // The dangerous three seconds

AI writes quickly. Humans review optimistically.

A coding assistant can produce four hundred convincing lines before you finish your coffee. The awkward part is that “convincing” is not a compiler guarantee. It may paste an environment value into a test, import a package that does not exist, leave a stub behind, or change a build file while solving an unrelated problem. Then muscle memory takes over: stage, commit, push.

Git hooks put executable checkpoints inside that reflex. Git runs pre-commit before creating a commit and pre-push before transmitting refs. Exit with a nonzero status and the operation stops. That makes hooks ideal for fast, local feedback—not magical security, but a useful tripwire close to the mistake.

This matters more in an AI-assisted workflow because generated code arrives without the small pauses that normally build understanding. When you write an import yourself, you probably remember whether you installed its package. When an assistant edits six files at once, the diff can look internally consistent while depending on a nonexistent API. Hooks convert a few high-confidence expectations into machine-enforced friction: credentials should not enter history, changed source should satisfy local quality rules, and manifests should still describe a buildable project.

01 stage 02 scan secrets 03 validate code 04 push

Tip 01 // The AI API key sniffer

Scan what is staged—not whatever happens to be on disk

A naive hook greps the working directory. That produces the wrong answer when a file has staged and unstaged versions. The index is what the commit will contain, so the hook below lists staged paths with NUL separators and reads each staged blob through git show ":$path". It never prints the matching value, because turning the terminal log into a second secret leak would be an impressively self-defeating feature.

.git/hooks/pre-commitbash // chmod +x
#!/usr/bin/env bash
set -euo pipefail

PATTERN='(sk-(proj-)?[A-Za-z0-9_-]{20,}|sk-ant-[A-Za-z0-9_-]{20,}|A(KIA|SIA)[A-Z0-9]{16}|(api[_-]?key|secret|token)[[:space:]]*[:=][[:space:]]*[A-Za-z0-9_./+=-]{32,}|-----BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY-----)'
found=0

while IFS= read -r -d '' path; do
  # Read the index version. -I treats binary input as non-matching.
  if git show ":$path" 2>/dev/null |
    LC_ALL=C grep -IqE "$PATTERN"; then
    printf 'ERROR: potential secret in staged file: %s\n' "$path" >&2
    found=1
  fi
done < <(git diff --cached --name-only --diff-filter=ACMR -z)

if (( found )); then
  printf 'Commit blocked. Remove the value and rotate it if exposed.\n' >&2
  exit 1
fi

Save it, then run chmod +x .git/hooks/pre-commit. The expressions intentionally favor interruption over perfect classification. Provider formats evolve, high-entropy generic keys are hard to identify reliably, and false positives happen. Production teams should supplement this tripwire with a maintained scanner such as Gitleaks or TruffleHog and GitHub secret scanning. If a real key ever reached a commit or remote, deletion is not remediation: revoke or rotate it.

The scope is deliberately narrow. It checks files participating in this commit, not every historical object and not untracked files you forgot to add. That keeps normal commits quick, but it means the hook cannot certify that the repository has always been clean. Perform a full repository and history scan during onboarding and in scheduled CI. Also treat pattern matches as signals rather than proof: an example token in documentation can resemble a credential, while a provider-specific secret may evade a stale expression. Prefer a documented suppression tied to a fake fixture over weakening the pattern for everybody.

Tip 02 // The hallucination validator

Spend the expensive checks only where the push changed

Pre-push is a better home for linting and compilation because it runs less often than pre-commit. Git supplies each local and remote object ID on standard input. This hook builds a changed-file list, lints only changed JavaScript and TypeScript files with the repository’s already-installed ESLint, and triggers cargo check --locked when Rust changed.

.git/hooks/pre-pushchanged files // no surprise downloads
#!/usr/bin/env bash
set -euo pipefail

remote_name="${1:-origin}"
remote_head="$(git symbolic-ref -q \
  "refs/remotes/$remote_name/HEAD" 2>/dev/null || true)"
changed=()

while read -r local_ref local_oid remote_ref remote_oid; do
  [[ "$local_oid" =~ ^0+$ ]] && continue  # deleted ref

  if git cat-file -e "$remote_oid^{commit}" 2>/dev/null; then
    base="$remote_oid"
  elif [[ -n "$remote_head" ]]; then
    base="$(git merge-base "$local_oid" "$remote_head")"
  else
    base="$(git hash-object -t tree /dev/null)"
  fi

  while IFS= read -r -d '' file; do
    changed+=("$file")
  done < <(git diff --name-only --diff-filter=ACMR \
    -z "$base" "$local_oid")
done

js_files=()
rust_changed=0
for file in "${changed[@]}"; do
  case "$file" in
    *.js|*.jsx|*.mjs|*.cjs|*.ts|*.tsx) js_files+=("$file") ;;
    *.rs|Cargo.toml|Cargo.lock) rust_changed=1 ;;
  esac
done

if ((${#js_files[@]})); then
  [[ -x node_modules/.bin/eslint ]] || {
    echo "ESLint missing. Run npm ci first." >&2; exit 1;
  }
  node_modules/.bin/eslint --max-warnings=0 "${js_files[@]}"
fi

if ((rust_changed)); then
  cargo check --locked
fi

echo "Pre-push validation passed."

The “already-installed” detail matters. Calling an unrestricted package runner can fetch a similarly named package while you are trying to detect hallucinated dependencies. Pin tools in the project lockfile and fail closed when they are missing. For a containerized project, add a conditional such as docker build --pull=false -t local/prepush-check . when Dockerfile, a lockfile, or deployment code changes. Keep the hook under a minute; leave full matrices, integration tests, and networked builds to CI.

Add type checks when they provide information the linter cannot: npm run typecheck --if-present is a useful project-level companion after changed-file linting. Be careful with the phrase “only changed files,” though. ESLint can inspect an isolated file; a Rust compiler, TypeScript project reference, or bundler reasons about a dependency graph. Trigger those broader checks only when relevant inputs changed, but let the tool inspect the graph it needs. Artificially forcing every validator to one-file scope can make the hook fast by making it wrong.

Tip 03 // The lazy-developer automation

Make every future repository inherit the guardrails

Hooks stored in .git/hooks are local metadata, not normal tracked files. That is useful for personal customization and terrible for remembering setup. Git’s template directory solves the next-repository problem: its contents are copied into a new repository’s Git directory during initialization.

one-time installationfuture init and clone
mkdir -p "$HOME/.git-templates/hooks"

cp ./pre-commit "$HOME/.git-templates/hooks/pre-commit"
cp ./pre-push   "$HOME/.git-templates/hooks/pre-push"
chmod +x "$HOME/.git-templates/hooks/"*

git config --global init.templateDir "$HOME/.git-templates"

New repositories now receive executable copies. A template is a stamp, not a live link: editing the template later does not update repositories already created. Install into those explicitly, or rerun git init to copy template files that are not already present. If you truly want one centrally updated hook set, configure core.hooksPath globally instead. That is powerful, but it also replaces repository-specific hook discovery, so a universal dispatcher is safer than one enormous script that assumes every repository uses Node.

There is one more portability trap: executable bits and shell availability. The examples require Bash because they use arrays and process substitution. They work naturally on Linux, macOS, and Git Bash, but a team with PowerShell-only Windows environments should ship an equivalent wrapper rather than assuming /usr/bin/env bash exists. Test installation on every supported developer platform, and make the installer verify both the executable permission and a harmless dry run. A beautiful hook that Git silently ignores is just documentation with ambitions.

04 // Know where the guardrail ends

A local hook protects attention, not policy

Fast locally

Secret patterns and formatting checks belong before commit. Compilation belongs before push. Slow, flaky checks train developers to bypass everything.

Version the source

Keep reviewed hook sources in .githooks/ and provide an installer. Never ask developers to execute an AI-generated hook they have not read.

Enforce remotely

Git allows hooks to be bypassed, and a compromised workstation can modify them. Repeat critical checks in protected CI and enable server-side push protection.

The best setup is layered: tiny deterministic checks on the laptop, maintained scanners and complete tests in CI, and repository rules that prevent unreviewed failures from merging. The hook’s real job is to make the safest action the easiest action while the developer still remembers what the AI changed.

05 // Turn snippets into a team contract

Choose each checkpoint by cost and consequence

The scripts become durable when the team agrees what belongs at each boundary. Pre-commit should answer questions that are cheap, deterministic, and directly related to the staged snapshot: secret patterns, forbidden files, formatting, and generated-file drift. Pre-push can afford dependency-aware linting, type checking, compilation, and a quick container build. CI owns the expensive matrix: clean-environment installation, integration tests, full secret scanners, license checks, and multiple runtimes.

BoundaryGood checksTarget
Pre-commitStaged secrets, format, forbidden filesSeconds
Pre-pushChanged-file lint, types, compile, smoke buildUnder a minute
Protected CIClean install, full tests, maintained scannersAuthoritative

Pin tool versions, keep hook source under review, and print a clear repair command when blocking work. “Validation failed” creates a scavenger hunt; “ESLint is missing—run npm ci” creates a next step. Record bypasses in the pull request when an emergency genuinely requires one. A bypass that is visible, explained, and rechecked by CI is an exception. An invisible --no-verify habit is the collapse of the control.

Finally, design for failure without designing for surrender. If a network service is unavailable, local checks should not hang. If a scanner crashes, sensitive repositories should fail closed and say why. If the repository is an experimental sandbox, a warning may be appropriate. The policy depends on risk; the important part is that the decision is explicit, reviewed, and consistent across human- and AI-authored changes.

Sources // primary documentation

Implementation references

  1. Git — Hooks, pre-commit, and pre-push behavior
  2. Git — Repository templates and init.templateDir
  3. GitHub — Supported secret-scanning patterns
  4. GitHub — Push protection
  5. OpenAI — API key safety best practices

Written and reviewed by

Kawshik Ahmed Ornob

Cybersecurity specialist, AI and NLP researcher, and full-stack engineer writing practical field notes about safer developer workflows.