This toolkit provides a simple, yet powerful, way to manage your Git hooks. It's designed to be composable, version-controlled, and easy to maintain. Stop letting your Git automation live in scattered shell scripts and start treating it like the code it is.
With this toolkit, you can:
- Keep your hooks in your repository: No more scattered scripts on different machines.
- Compose hooks from smaller parts: Build complex workflows from simple, reusable scripts.
- Run hooks in a predictable order: Parts are executed in a deterministic, lexical order.
- Stay safe: The runner comes with robust logging, stdin handling, and awareness of worktrees and bare repositories.
- Enjoy zero hard dependencies: All you need is a POSIX-compliant shell and Git.
The toolkit installs a universal hook runner that executes any number of "hook parts" you place in a dedicated directory. These parts are just executable shell scripts, and they run in a predictable order based on their filenames.
When you install the toolkit, it creates a small "stub" file for each hook you want to manage in your .git/hooks directory. This stub file is a simple shell script that does one thing: it executes the shared runner script, which is located in .githooks/_runner.sh.
The shared runner then takes over and does the following:
- It determines which hook is being run (e.g.,
pre-commit,post-merge, etc.). - It looks for a corresponding directory in
.githooks(e.g.,.githooks/pre-commit.d). - It executes all the executable scripts it finds in that directory, in lexical order.
This approach has several advantages:
- Your
.git/hooksdirectory stays clean: You only have a small stub file for each hook, instead of a large, monolithic script. - Your hooks are version-controlled: The actual hook logic lives in the
.githooksdirectory, which is part of your repository. - Your hooks are composable: You can easily add, remove, or reorder hook parts without having to modify a single, large script.
Here is a diagram that illustrates the process:
graph TD
subgraph "Git Event"
direction TB
A["Developer action (e.g., 'git commit')"] --> B{"Git hook ('pre-commit')"}
end
subgraph "Runner Toolkit"
direction TB
C["Stub: '.git/hooks/pre-commit'"] -->|"exec"| D(("Shared runner: '.githooks/_runner.sh'"))
D -->|"sources"| E["Helpers: '.githooks/lib/common.sh'"]
D -->|"enumerates"| F["Parts directory: '.githooks/pre-commit.d/'"]
F -->|"lexical order"| G["'10-lint.sh'"]
F -->|"lexical order"| H["'20-test.sh'"]
end
B -->|"calls"| C
G -->|"runs"| I["Action(s)"]
H -->|"runs"| J["Action(s)"]
Note: If you install in Ephemeral Mode, the runner lives under
.git/.githooks/instead, and the runner dynamically overlays ephemeral parts with any versioned.githooksparts. See Advanced Usage → Ephemeral Mode below and the detailed Ephemeral Mode guide.
You can add the toolkit to your project in two ways: as a direct clone or as a Git submodule.
This is the simplest method and is recommended for most users. It integrates the toolkit directly into your project, making hook management straightforward.
-
Clone the repository:
git clone https://github.com/DevGuyRash/git-hooks-runner-toolkit.git .githooks
-
Integrate it into your project: By removing the toolkit's
.gitdirectory, you can track all its files within your main repository.rm -rf .githooks/.git
-
Commit the toolkit:
git add .githooks git commit -m "chore: vendor git-hooks-runner toolkit"
With this approach, any custom hooks you add to .githooks/ are simply part of your project and can be committed directly. The trade-off is that updating the toolkit requires a manual merge from the upstream repository.
Shortcut: If you already have a checkout of the toolkit elsewhere, you can vendor it into a repo in one step:
/path/to/git-hooks-runner-toolkit/install.sh --repo /path/to/repo bootstrapIf you want to keep the toolkit's history separate and update it easily, you can use a submodule. However, this is an advanced workflow that is considerably more complex, especially for teams.
Requirement: Fork the Toolkit:
To track custom hooks in a submodule across a team, you must first fork the git-hooks-runner-toolkit repository. You cannot use the original repository directly, because your team will need a shared remote to push custom hook changes to.
-
Fork the repository on GitHub.
-
Add your fork as a submodule: Use the URL of your fork.
git submodule add <URL_OF_YOUR_FORK> .githooks git commit -m "chore: add forked git-hooks-runner as submodule"
When you add or edit hooks, they are modified inside the .githooks submodule. To share these changes, you must commit and push them to your fork, and then commit the updated submodule reference in your main repository.
Workflow for updating hooks in a submodule:
# 1. Stage your hook part into the submodule
.githooks/install.sh stage add hooks
# 2. Navigate into the submodule, commit, and push the change to your fork
(cd .githooks && git add . && git commit -m "feat: add my-custom-hook" && git push)
# 3. Return to your project and commit the updated submodule reference
git add .githooks
git commit -m "chore: update hooks submodule with my-custom-hook"Because this workflow requires managing a separate forked repository and involves multiple steps, we strongly recommend the Direct Clone method unless you have a specific need for the submodule approach.
If repository policy or workflow discourages committing tooling, you can install the runner under .git/.githooks/ instead of tracking files in the repo. You may use a shared checkout of the toolkit (e.g. ~/.cache/git-hooks-runner-toolkit) to install into any target repository.
# Fetch (or update) the toolkit locally once
git clone https://github.com/DevGuyRash/git-hooks-runner-toolkit.git \
"$HOME/.cache/git-hooks-runner-toolkit"
# Inside the repository that should receive hooks:
cd /path/to/your-repo
"$HOME/.cache/git-hooks-runner-toolkit/install.sh" install \
--mode ephemeral \
--hooks pre-commit,post-merge \
--overlay ephemeral-firstYou can still keep hook parts versioned in .githooks/ (recommended), while the runner and stubs live ephemerally under .git/.githooks/. See the Ephemeral Mode guide for details.
Run the installer to set up stubs and the shared runner:
.githooks/install.sh installBy default this installs a curated subset of Git hooks. To explicitly control which hooks receive managed stubs, pass a comma-separated list:
.githooks/install.sh install --hooks pre-commit,post-mergeTo see what was installed, you can run:
ls .git/hooks
ls .githooksYou can inspect command-specific help at any time, for example:
.githooks/install.sh help stage
.githooks/install.sh stage help addIf you have a vendored copy at .githooks/, you can still choose Ephemeral Mode to place the active runner under .git/.githooks/ and optionally overlay it with your versioned parts:
# from your vendored toolkit
.githooks/install.sh install --mode ephemeral \
--hooks pre-commit,post-merge \
--overlay versioned-first # or: ephemeral-first (default), mergeCheck the resolved configuration:
.githooks/install.sh config showTip: Uninstall later with:
.githooks/install.sh uninstall --mode ephemeral
Now, let's create a simple hook part. For this example, we'll create a pre-commit hook that runs a linter.
Create a new file named 10-lint.sh in the .githooks/pre-commit.d/ directory:
cat > .githooks/pre-commit.d/10-lint.sh <<'SH'
#!/usr/bin/env bash
set -euo pipefail
# Example: run eslint if available; otherwise just log and exit 0
if command -v eslint >/dev/null 2>&1; then
echo "[hook] INFO: running eslint"
eslint .
else
echo "[hook] WARN: eslint not found; skipping"
fi
SHMake the script executable:
chmod +x .githooks/pre-commit.d/10-lint.shNow, whenever you make a commit, this script will run automatically.
The installer can automatically add hook parts for you from a source directory.
There are two ways to tell the installer which hook a script belongs to:
-
Metadata Comment: Add a special comment to your script to specify the target hook(s). You can specify multiple hooks by separating them with commas.
# githooks-stage: pre-commit, post-merge -
Directory Structure: Place your script in a directory named after the hook. For example, a script placed in
hooks/pre-commit/will be automatically associated with thepre-commithook.
Then, you can stage parts with the stage add subcommand:
.githooks/install.sh stage add <your-scripts-directory>You can also add the included examples:
.githooks/install.sh stage add examplesLimit staging to one or more filenames with --name. The filter accepts shell-style globs and automatically matches .sh extensions:
.githooks/install.sh stage add examples --name 'metadata-*'You can easily create and install your own custom hooks. The recommended way to do this is to place your hook scripts in the hooks/ directory, and then use the stage add subcommand to install them.
For example, let's say you want to create a pre-push hook that runs your test suite. You would create a file named hooks/pre-push/10-run-tests.sh with the following content:
#!/usr/bin/env bash
set -euo pipefail
# githooks-stage: pre-push
echo "Running tests..."
npm testThen, you would make the script executable:
chmod +x hooks/pre-push/10-run-tests.shFinally, you would stage the directory:
.githooks/install.sh stage add hooksThis will copy your script to .githooks/pre-push.d/10-run-tests.sh, and it will be executed automatically before every push.
List everything that is currently staged:
.githooks/install.sh stage listYou can scope the listing to a single hook:
.githooks/install.sh stage list pre-commitTo remove a specific part, provide the hook and name (the .sh suffix is optional):
.githooks/install.sh stage remove pre-commit git-crypt-enforceTo clear every part for a hook, combine the hook with --all:
.githooks/install.sh stage remove pre-commit --allTo reverse a staging operation based on the source files, use stage unstage.
It scans the source directory, resolves hook targets from metadata comments or
directory placement, and removes any matching staged scripts:
.githooks/install.sh stage unstage examples --name 'dependency-sync'You can reuse the same filters as stage add, including --hook, --name, and
--dry-run, to scope the unstaging plan without touching unrelated parts.
For a high-level summary of hooks, stubs, and part counts, run:
.githooks/install.sh hooks listAll of these commands accept -n/--dry-run so you can preview actions before making changes.
After pulling new versions of the toolkit (or tweaking example scripts locally), run the update subcommand to rewrite the shared runner, refresh managed stubs, and restage any example-based parts in place:
.githooks/install.sh update # standard installs
.githooks/install.sh update --mode ephemeralPass --force if you want to overwrite staged parts even when their contents already match the source.
Use config show to review derived paths (including any Git core.hooksPath overrides):
.githooks/install.sh config showIf you need to relocate the hooks path, point Git at the shared runner directory:
.githooks/install.sh config set hooks-path .githooksThe installer will emit the Git commands it runs, and you can combine these subcommands with --dry-run during experimentation.
The install.sh script provides several commands to customize its behavior:
| Command | Description |
|---|---|
install |
Install the toolkit and create hook stubs. Supports --hooks, --all-hooks, --force, and --mode ephemeral (installs the runner under .git/.githooks/; see below). |
update |
Refresh runner assets, managed stubs, and staged parts. Supports --force, --dry-run, and --mode ephemeral to target .git/.githooks/ installs. |
stage add SOURCE |
Copy hook parts from a source directory. Supports --hook (alias: --for-hook), --name (globs, extension optional), --force, and --dry-run. |
stage unstage SOURCE |
Remove staged hook parts that match a source directory. Supports --hook, --name, and --dry-run. |
stage remove HOOK [--name PART | --all] |
Remove one part by name (extension optional) or purge all parts for a hook. |
stage list [HOOK] |
Show staged parts for all hooks or a specific hook. |
hooks list [HOOK] |
Summarize installed stubs and staged parts. |
config show / config set hooks-path PATH |
Inspect or update toolkit configuration. |
help [COMMAND [SUBCOMMAND]] |
Display MAN-style manuals for commands and subcommands. |
uninstall |
Remove runner artifacts and managed stubs while leaving checked-in toolkit files intact. |
Global Flags:
| Flag | Description |
|---|---|
-n, --dry-run |
Print planned actions without touching the filesystem. |
-h, --help |
Show the global help message. You can also target subcommands (e.g. --help stage). |
-V, --version |
Print the toolkit version. |
Prefer a menu-driven workflow? Launch the POSIX shell TUI, which exposes all CLI operations (install/update/stage/hooks/config/uninstall) plus contextual help:
./tui/githooks-tui.shUse Settings inside the TUI to toggle --dry-run and set your default mode
(standard or ephemeral). You can exit at any time without changing your repo.
The toolkit comes with several examples in the examples/ directory. Each has a
companion guide under docs/examples/. Stage them with:
.githooks/install.sh stage add examplesdependency-sync.sh(guide): Watches common manifests across Node, Python (pip/Poetry/Pipenv/uv/PDM), Ruby, PHP (Composer), Go, Rust, Elixir, .NET, Java (Maven/Gradle), Swift, Dart/Flutter, Bun, and CocoaPods and runs the matching install/sync command when those files change.watch-configured-actions.sh/watch-configured-actions-pre-commit.sh(guide): Run custom commands when specific files change after merges or before commits, all driven by a shared YAML/JSON config.metadata-apply.sh(guide): Restores file permissions and other metadata usingmetastore.git-crypt-enforce.sh(guide): Ensures that files that should be encrypted withgit-cryptare not committed in plaintext.
Note: Staging either watch-configured-actions script also installs
config/watch-configured-actions.ymlunder your hooks root (.githooks/config/for persistent installs,.git/.githooks/config/for ephemeral mode) so both the post-event and pre-commit variants share one source of truth. If staging detects a legacy.githooks/watch-config*.ymlfile it prints a migration warning; runtime loads still succeed but also emit a warning until you move to the centralized location, and missing configs trigger a hint before exiting 0.
Ephemeral Mode installs the runner and stubs under .git/.githooks/, so you can enable hooks without committing toolkit files. You may still keep your hook parts versioned in .githooks/ and choose how ephemeral parts and versioned parts are layered via an overlay mode:
ephemeral-first(default) — run ephemeral parts before any versioned partsversioned-first— run versioned parts before any ephemeral partsmerge— keep both roots active without changing their default ordering
graph TD
classDef ephemeral fill:#eef6ff,stroke:#6aa0ff,stroke-width:1px
classDef versioned fill:#fff7e6,stroke:#ffb21e,stroke-width:1px
classDef runner fill:#eef,stroke:#88f,stroke-width:1px
subgraph roots ["Hook Roots:"]
direction TB
E[".git/.githooks (ephemeral)"]:::ephemeral
V[".githooks (versioned)"]:::versioned
end
H["Hook: 'pre-commit'"] -->|"dispatch"| R(("Runner"))
R:::runner -->|"ephemeral-first"| E
R:::runner -->|"versioned-first"| V
R:::runner -->|"merge"| E
R:::runner -->|"merge"| V
E -->|"lexical order"| EP[".git/.githooks/pre-commit.d/*"]:::ephemeral
V -->|"lexical order"| VP[".githooks/pre-commit.d/*"]:::versioned
Quick commands:
# Install Ephemeral Mode from a vendored copy
.githooks/install.sh install --mode ephemeral --hooks pre-commit,post-merge --overlay ephemeral-first
# OR install from a shared checkout without vendoring:
"$HOME/.cache/git-hooks-runner-toolkit/install.sh" install --mode ephemeral --hooks pre-commit
# Inspect current paths and precedence:
.githooks/install.sh config show
# Uninstall Ephemeral Mode and restore previous hooksPath:
.githooks/install.sh uninstall --mode ephemeralFor a deeper walk‑through, see the dedicated Ephemeral Mode guide.