git.lucas.co / gitsite
the static git browser that builds this site
git clone https://git.lucas.co/gitsite.git

commitc14114a188456b915ee204fad1f7a9b45065d996
parentbd32c8ae8b
authorLucas Galante <[email protected]>
date2026-09-24 12:42
Hooks: scan staged changes for secrets before the push hook publishes them

pre-commit-scan runs gitleaks on the staged diff and refuses a commit that
looks like it carries a credential -- needed here specifically because
post-commit-push puts every commit on GitHub seconds later, leaving no
window to notice. Where gitleaks is not installed the commit goes through
with a warning, so a fresh machine can still commit. install-hooks.sh now
symlinks both hooks.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

 install-hooks.sh | 29 +++++++++++++++++++----------
 pre-commit-scan  | 37 +++++++++++++++++++++++++++++++++++++
 readme.txt       | 13 ++++++++++++-
 3 files changed, 68 insertions(+), 11 deletions(-)

diff --git a/install-hooks.sh b/install-hooks.sh
index 45f4d8d..7aea017 100755
--- a/install-hooks.sh
+++ b/install-hooks.sh
@@ -1,7 +1,8 @@
 #!/bin/sh
-# Symlink post-commit-push into every repo listed in repos.conf, as its
-# post-commit hook. Safe to re-run: an existing symlink is refreshed, and a
-# hook that is a real file (someone's own) is left alone and reported.
+# Symlink the hooks into every repo listed in repos.conf: post-commit-push as
+# its post-commit hook and pre-commit-scan as its pre-commit hook. Safe to
+# re-run: an existing symlink is refreshed, and a hook that is a real file
+# (someone's own) is left alone and reported.
 #
 # Work trees are found by NAME under $GIT_WORK_ROOTS, since repos.conf records
 # only where a repo is mirrored from. A name that resolves to nothing fails
@@ -9,7 +10,8 @@
 set -eu
 
 BASE=$(dirname "$(readlink -f "$0")")
-HOOK="$BASE/post-commit-push"
+# hook-name=file, one pair per word.
+HOOKS="pre-commit=pre-commit-scan post-commit=post-commit-push"
 WORK_ROOTS="${GIT_WORK_ROOTS:-$HOME/projects/cce $HOME/projects}"
 FAILED=""
 
@@ -25,14 +27,21 @@ for name in $names; do
         continue
     fi
     hooks=$(git -C "$wt" rev-parse --path-format=absolute --git-path hooks)
-    dest="$hooks/post-commit"
-    if [ -e "$dest" ] && [ ! -L "$dest" ]; then
-        echo "!! $name: $dest exists and is not a symlink -- left alone"
+    mkdir -p "$hooks"
+    bad=""
+    for pair in $HOOKS; do
+        dest="$hooks/${pair%%=*}"
+        if [ -e "$dest" ] && [ ! -L "$dest" ]; then
+            echo "!! $name: $dest exists and is not a symlink -- left alone"
+            bad=1
+            continue
+        fi
+        ln -sfn "$BASE/${pair#*=}" "$dest"
+    done
+    if [ -n "$bad" ]; then
         FAILED="$FAILED $name"
         continue
     fi
-    mkdir -p "$hooks"
-    ln -sfn "$HOOK" "$dest"
     echo "   $name"
 done
 
@@ -41,4 +50,4 @@ if [ -n "$FAILED" ]; then
     echo "done, with failures:$FAILED"
     exit 1
 fi
-echo "done: post-commit hook installed in every repo"
+echo "done: pre-commit and post-commit hooks installed in every repo"
diff --git a/pre-commit-scan b/pre-commit-scan
new file mode 100755
index 0000000..7c7bfd3
--- /dev/null
+++ b/pre-commit-scan
@@ -0,0 +1,37 @@
+#!/bin/sh
+# pre-commit hook: refuse a commit whose staged changes look like a secret.
+#
+# Installed by install-hooks.sh next to post-commit-push, and it exists
+# because of that hook: a commit here is on GitHub seconds later, so there is
+# no window to notice a pasted credential before it is public. Only the
+# staged diff is scanned, so it costs well under a second.
+#
+# Uses gitleaks when it is on PATH. Without it the commit goes through, with a
+# warning on stderr -- a fresh machine must still be able to commit -- so
+# install it to get the protection: `pacman -S gitleaks` on Arch, `brew
+# install gitleaks` on macOS, or a binary from
+# https://github.com/gitleaks/gitleaks/releases.
+#
+# A finding blocks the commit and prints where it is, value redacted. For a
+# true false positive, allowlist it in the repo's .gitleaks.toml or pass
+# --no-verify to that one commit -- remembering that the push hook publishes
+# whatever gets past this one.
+
+if ! command -v gitleaks >/dev/null 2>&1; then
+    echo "pre-commit: gitleaks is not installed -- staged changes were NOT scanned for secrets" >&2
+    exit 0
+fi
+
+# `gitleaks git --pre-commit` is the 8.19+ spelling; older releases have
+# `protect` instead. Both scan only what is staged.
+if gitleaks git --help >/dev/null 2>&1; then
+    gitleaks git --pre-commit --staged --redact --verbose --no-banner
+else
+    gitleaks protect --staged --redact --verbose --no-banner
+fi
+status=$?
+if [ "$status" -ne 0 ]; then
+    echo "pre-commit: the staged changes look like they contain a secret -- commit refused." >&2
+    echo "  false positive? allowlist it in .gitleaks.toml, or git commit --no-verify" >&2
+fi
+exit "$status"
diff --git a/readme.txt b/readme.txt
index b029ced..fc21562 100644
--- a/readme.txt
+++ b/readme.txt
@@ -23,8 +23,13 @@ gitsite.service, gitsite.timer
 post-commit-push
              git post-commit hook: pushes the committed branch to origin,
              so committing is publishing -- see "Publishing model" below
+pre-commit-scan
+             git pre-commit hook: refuses a commit whose staged changes
+             look like a secret (gitleaks), since the push hook would
+             publish it seconds later; warns and lets the commit through
+             where gitleaks is not installed
 install-hooks.sh
-             symlinks post-commit-push into every repo in repos.conf
+             symlinks both hooks into every repo in repos.conf
 style.css    matches lucas.co (black, white, blue links, Circe)
 
 Workflow
@@ -71,6 +76,12 @@ IS the publishing step. The hook never forces: a rejected push leaves the
 commit local and says so, and you resolve it with git pull / git push as
 usual. It does nothing on a detached HEAD or mid-rebase.
 
+Because of that, pre-commit-scan runs first: with gitleaks installed it
+scans the staged diff and refuses a commit that looks like it carries a
+credential, so nothing of the kind reaches GitHub. Without gitleaks the
+commit goes through with a warning -- install it (pacman -S gitleaks) to
+have the check actually run.
+
 The 27 crates pinned to https://git.lucas.co/<name>.git?rev=... keep
 working because the dumb-http clone dirs are built from the same mirrors;
 a rev exists here as long as it is reachable on GitHub.