Git hooks
The dotfiles pre-commit hook checks what you have staged, before Git creates the commit. It catches common mistakes early; it does not replace CI, pre-push validation, or a review for sensitive data.
There are two independent hook systems:
| System | Purpose | Source |
|---|---|---|
| This repository’s pre-commit hook | Check staged changes | hooks/ |
| Personal Git templates | Protect pushes and clean up merged branches in other repositories too | symlinks/config/git/templates/ |
Before committing
Review the intended commit, then let Git invoke the installed hook:
git diff --cached
git commit
To check the current index without creating a commit:
sh hooks/pre-commit
These checks do not stage fixes or stash your work. If a file is partially staged, fixing its working copy is not enough: review and stage the intended fix, then retry. Do not use git add . as a generic cure.
Default pre-commit flow
The entrypoint runs the sensitive scan first, then Rust/PowerShell checks. A failure stops the commit. Checks are selected by staged paths:
| Staged change | Check |
|---|---|
| Added text in the staged diff | Sensitive-pattern scan |
Rust source, including deletion or rename away from .rs | Whole-crate cargo fmt --check and host Clippy |
| Cargo manifest/lockfile, Cargo config, or toolchain file | The same Rust checks, using the staged compiler pin |
Present .ps1, .psm1, or .psd1 file | PSScriptAnalyzer on those staged files |
| Only TOML data, shell, or workflow files | No Rust compilation solely because of that path; extra guards need full mode |
Clippy uses the ci profile, all targets, a locked dependency graph, and warnings as errors. The hook checks the compiler against the staged cli/rust-toolchain.toml; it does not install or switch toolchains.
If pwsh is absent, PowerShell analysis is skipped. If pwsh exists but its PSScriptAnalyzer module is absent or broken, the check fails. A skipped check is not coverage.
Scan scope
The index is the input
The sensitive scanner reads the diff from HEAD to the index, or from an empty tree for the first commit. It scans added lines only. An edited rename is still scanned without treating every unchanged line as newly added.
Rust and CI guards export the entire index to an isolated snapshot. Consequently:
- An unstaged fix cannot hide a staged error.
- An unstaged deletion does not remove the staged file from checks.
- An untracked file cannot satisfy a staged build.
- The working tree is not overwritten or stashed.
Builds reuse the checkout’s Cargo target cache, so checks still write build artifacts. Shell and PowerShell input handling preserves literal filenames, including spaces and non-ASCII characters; quoted Git display names are not passed to analyzers.
The verifier itself comes from the working tree. The installed entrypoint finds the checkout with git rev-parse --show-toplevel and runs its hooks/ helpers. Pattern files and analyzer helpers also come from that checkout. Review and stage verifier changes separately; an index snapshot does not make the verifier immutable or trusted.
What the sensitive scan can detect
Detection rules are case-insensitive extended regular expressions. They match each added line’s text, so ^ and $ do not anchor to a diagnostic filename or line number. Findings show file/line locations but redact matched content.
Allowlist matches replace only the safe span before detection. The rest of the line is still scanned: allowing an Action SHA or a package checksum must not hide an appended credential. Invalid detection or allowlist expressions fail the scan, even for an empty diff.
This is not a general secret detector. It does not cover binary data, unchanged history, or every encoded/unknown credential format. Inspect findings locally; do not share raw sensitive input or unsanitized logs. See Secrets.
Full guard mode
Opt into broader, still path-triggered, checks:
DOTFILES_HOOKS_FULL=1 sh hooks/pre-commit
# Or for the commit itself:
DOTFILES_HOOKS_FULL=1 git commit
true and yes also enable the flag.
| Trigger | Additional coverage |
|---|---|
| Rust/build inputs | Windows GNU-target Clippy when installed, then the full Rust test suite |
conf/*.toml or symlinks/ | Typed configuration validation and config_drift |
| Cargo manifest, lockfile, or deny policy | Wildcard dependency guard and cargo-deny bans/licenses/sources when available |
| Shell or hook paths | ShellCheck when available, on affected shell inputs |
| POSIX wrapper or its test script | Linux wrapper tests |
| Release workflow | Staged publishing invariants: provenance, exact commit handoff, permissions, serialization, and attestation publication |
Deletions and both sides of renames participate in trigger selection. Release guards read the staged workflow directly; the other guards use the index snapshot.
You can run sh hooks/check-ci-guards.sh directly for its basic targeted checks. Without full mode it omits config drift, cargo-deny, and wrapper tests. Neither invocation runs every CI job or checks the full ci-success contract. Use Testing for the canonical local stages and Before pushing for push validation.
Installation and removal
The git-hooks task copies extensionless files from hooks/ into Git’s hook directory. Currently that means pre-commit, not the .sh helpers or .ini data. On Unix the installed copy is executable.
Before installing, inspect the destination:
git config --show-origin --get core.hooksPath
git rev-parse --git-common-dir
No output and status 1 from the first command means there is no override. Dotfiles honors core.hooksPath, resolving relative values from the repository root. Otherwise it uses the common Git directory’s hooks/, including for linked worktrees. A global/shared hooks path can affect other repositories.
When you deliberately want to deploy:
dotfiles update --root . --no-repo-update --only git-hooks --dry-run
dotfiles update --root . --no-repo-update --only git-hooks
Install can replace a differing destination hook. Preserve custom hooks first. A changed copied pre-commit needs reinstallation; helper-only changes take effect directly from the checkout.
To inspect removal, then apply only after review:
dotfiles remove --root . --only git-hooks --dry-run
dotfiles remove --root . --only git-hooks
Removal deletes only hooks matching the current managed source/state. Modified or mismatched hooks survive. If the configured destination is the source itself, including through a filesystem alias, install/removal leaves it intact. Neither operation resets core.hooksPath. Missing hooks/ or Git metadata makes the task inapplicable.
Application-wide templates
Git copies template entrypoints when initializing or cloning a repository. Their shared helpers resolve from the managed template directory at runtime. These templates are not installed by the repository’s git-hooks task.
Merged-branch cleanup
post-merge and post-rewrite after a rebase run the shared clean-merged-branches helper. It considers local branches merged into origin/HEAD, while preserving:
- The current branch and the default branch.
main,master,dev, anddevelop.- Branches checked out in any worktree.
- Any branch Git refuses to delete safely with
git branch -d.
Without origin/HEAD, cleanup does nothing. An amend does not trigger it. Existing repositories need a newly introduced template entrypoint, such as post-rewrite, installed explicitly; do not overwrite a repository-specific hook to do so.
Push protection
The template pre-push hook blocks branch updates to main, master, develop, and the destination remote’s default branch. It prefers that remote’s cached HEAD, then tries a read-only, noninteractive ls-remote --symref lookup. If discovery fails, the named protections still apply.
Add exact branch names—not globs—with:
git config --add dotfiles.protectedBranch release/stable
git config --add dotfiles.protectedBranch dev
GitHub remotes whose URL owner matches github.user (or github.username) are exempt. This is a local configured-name check, not a GitHub permission check. It is separate from both CI and the repository’s pre-push validation script.
Diagnosing a failure
| Symptom | Check next |
|---|---|
| “I fixed it, but it still fails” | Compare git diff and git diff --cached; stage only the intended fix |
| Formatting failure | Run cargo fmt in cli/, review the diff, and restage the appropriate hunks |
| Build fails only in the hook | Look for an unstaged or untracked source, dependency, or config needed by the staged code |
| A check was skipped | Read the missing-tool reason and run the required coverage separately |
| Entrypoint edits have no effect | Inspect the real hook destination; reinstall the copied entrypoint deliberately |
| No hook runs | Check core.hooksPath, the installed filename, and the Unix executable bit |
| Sensitive finding | Inspect the staged line locally; remove/rotate a real secret or test a narrow safe-span exception |
Changing the checks
| File | Responsibility |
|---|---|
pre-commit | POSIX sh orchestration |
check-sensitive.sh | Added-line scanning and redacted diagnostics |
sensitive-patterns.ini | Detection expressions |
sensitive-allowlist.ini | Safe-span exceptions |
check-rust.sh | Staged Rust and PowerShell checks |
check-toolchain.sh | Staged compiler-pin verification |
check-ci-guards.sh | Targeted configuration, dependency, shell, wrapper, and release guards |
Keep scripts POSIX sh compatible and preserve partially staged input, rename/deletion handling, unusual filenames, and empty-diff behavior. Installation lives in git/hooks.rs and resources/hook.rs.
Changing sensitive patterns
Use synthetic fixtures, never live credentials. Test both a detection and the intended safe case. Anchor an exception to its surrounding syntax, redact the smallest safe span, and prove that a secret elsewhere on the same line is still detected.
Existing exceptions include Action SHA pins, Cargo/PKGBUILD checksums, fixture addresses at test.local, and GitHub SSH transport syntax. Documentation addresses at example.com are not generally allowlisted.
Running the hook test suite
Follow Wrapper and hook tests. The full integration suite creates commits and can leave a fixture committed when a test fails to block it. Run it only in the prescribed fresh disposable repository, never this checkout, even when clean. A dirty-tree guard is not isolation.
Bypassing
git commit --no-verify skips the entire pre-commit safeguard, not just one false positive. It does not skip CI. Reserve it for a deliberately reviewed recovery from a broken hook, never to commit known sensitive data. Prefer fixing the check or adding a narrow, tested exception.