brew install block/tap/lhmcurl -fsSL https://raw.githubusercontent.com/block/lefthookmerge/main/install.sh | shThis tool is designed to merge system-wide, user, and per-repo lefthook configs. lhm install configures global
core.hooksPath to call lhm, which dynamically merges whichever of the system, user, and repo lefthook configs exist,
using lefthooks' extends mechanism.
All standard lefthook config file names are supported: lefthook.<ext>, .lefthook.<ext> (and .config/lefthook.<ext>
for repo configs), where <ext> is yml, yaml, json, jsonc, or toml.
- Creates shell wrapper scripts for all standard git hooks in
~/.local/libexec/lhm/hooks/, each invokinglhm run-hook <hook> - Marks each wrapper script as immutable (best-effort) so other tools that try to overwrite it fail loudly instead of silently replacing it. Uses
chflags(UF_IMMUTABLE)on macOS/BSD andchattr +isemantics (FS_IMMUTABLE_FL) on Linux. macOS works for non-root user installs; Linux requiresCAP_LINUX_IMMUTABLE(typically root), and without it lhm prints a warning that the scripts couldn't be marked immutable. Re-runninglhm installclears and re-applies the flag. - Sets
git config --global core.hooksPath ~/.local/libexec/lhm/hooks - Writes a default
~/.config/lefthook.yamlif no user config exists
Unsets git config --global core.hooksPath, uninstalling lhm. The hook scripts in ~/.local/libexec/lhm/hooks/ are left in place so lhm install can re-enable quickly.
lhm disable suppresses repo-specific hooks (the repo's own lefthook.yaml and any repo-fallback adapter like pre-commit/husky/hooks-dir) for the current repo. The system and user configs and underlay adapters (e.g. git-lfs) still run. lhm enable reverses it.
The disabled set is keyed by the repo's origin remote URL and persisted in ~/.config/lhm.yaml:
disabled_repos:
- git@github.com:foo/bar.git
- https://github.com/baz/quxOrigin URLs are used verbatim — no normalization between git@ and https:// forms. If a repo has no origin remote, lhm disable errors out.
If a repo has a local core.hooksPath set (commonly by husky v9+, which sets it to .husky/_), git uses that path instead of the global core.hooksPath that lhm install configures — so lhm is bypassed entirely and disable has no effect on the hooks git actually runs.
lhm disable warns when this is the case. Pass --force to also unset the repo-local core.hooksPath, so lhm becomes the active hook dispatcher:
lhm disable --forcelhm dry-run emits the same warning, since the merged config it prints is only informational when lhm isn't actually being invoked.
Prints the merged config that would be used for the current repo, then exits. Useful for verifying what hooks will run.
lhm dry-runIf the repo has a local core.hooksPath set, dry-run emits a warning: the printed config is informational only, since git will bypass lhm entirely. See Local core.hooksPath overrides.
Writes the repo-fallback adapter's generated config to .lefthook.yaml in the current repo, so you can switch from pre-commit/husky/hooks-dir to a native lefthook config and edit it directly. Only the adapter output is written — the system configs, user config, and underlay adapters are not included.
Errors if a lefthook config already exists in the repo (any of lefthook.<ext>, .lefthook.<ext>, .config/lefthook.<ext>) or if no repo-fallback adapter is detected.
lhm importConfig locations can be overridden via CLI flags or environment variables. Precedence is CLI flag > environment variable > default. Environment variables apply everywhere, including during git-triggered hook runs (CLI flags are only present when you invoke lhm directly).
| Override | CLI flag | Environment variable |
|---|---|---|
| User config | --user-config <path> |
LHM_USER_CONFIG |
| Local (repo) config | --local-config <path> |
LHM_LOCAL_CONFIG |
| System config dirs | --system-config <dir> |
LHM_SYSTEM_CONFIG |
| Debug logging | --debug |
LHM_DEBUG |
For --user-config / --local-config, the override path is used directly instead of searching for lefthook.<ext> files. --global-config is accepted as a deprecated alias for --user-config.
--system-config takes directories (each searched for a standard lefthook.<ext> name), is repeatable, and is ordered lowest priority first; when given it replaces the /etc and /usr/local/etc defaults. LHM_SYSTEM_CONFIG accepts multiple directories separated by the platform's PATH separator (: on Unix).
lhm --user-config ~/custom-user.yaml dry-run
LHM_LOCAL_CONFIG=./other.yml git commit
lhm --system-config /etc --system-config /opt/etc dry-runWhen git triggers a hook, it runs the wrapper script in the hooks directory. Each script calls lhm run-hook <hook>, where the hook name is baked into the script content — making it immune to filename renaming by other tools that inject themselves into core.hooksPath.
- lefthook not in PATH: falls back to executing
.git/hooks/<hook>directly (if it exists), bypassing all config merging - No config at all (no underlay adapter, no system, no user, no repo, no repo-fallback adapter): hook is skipped silently
- Configs exist: merges all available layers in order (underlay, system, user, repo/adapter), runs
lefthook run <hook> --no-auto-install(so lefthook never tries to reinstall its own hooks into lhm's hooks dir) withLEFTHOOK_CONFIGpointing to the merged temp file
Config is resolved as a layered merge, where later layers override earlier ones:
- Underlay adapters — always-on baselines for tools that need their hooks to run regardless of the repo's own configuration (e.g.
git-lfs). See Adapters below. - System (
/etc/lefthook.<ext>, then/usr/local/etc/lefthook.<ext>) — machine-wide defaults. The directories searched are configurable; see Config overrides. - User (
~/.config/lefthook.yaml) — per-user defaults - Repo (
$REPO/lefthook.yamlor repo-fallback adapter) — per-repo overrides
Any layer may be absent. When a repo has no lefthook config, the repo-fallback adapter system is used in its place (see below).
When a repo or adapter config is present, the no_tty setting is automatically stripped from the system and user layers before merging. This prevents a machine- or user-wide config from disabling TTY for all repos — each repo should opt into no_tty explicitly. When there is no local layer, no_tty is kept so it still takes effect for system/user-only setups.
lhm has two categories of adapters:
- Repo-fallback adapters stand in for a missing
lefthook.yaml. Only the first detected one is used. - Underlay adapters detect always-on tools and merge into the lowest-priority layer, beneath the system, user, and repo configs, so any of them can still override anything they generate.
When a repo has no lefthook.yaml, lhm checks for other git hook managers and transparently adapts them. The generated adapter config is merged with ~/.config/lefthook.yaml using the standard merging system, so global hooks still apply.
Tried in this order (first match wins):
| Adapter | Detects | Behavior |
|---|---|---|
| pre-commit | .pre-commit-config.yaml and pre-commit in PATH |
Parses config to determine which stages have hooks, then delegates to pre-commit run --hook-stage <stage>. All hook types (local and remote) are supported. When no stages or default_stages is set, defaults to the pre-commit stage. If pre-commit isn't installed, the adapter declines and lhm falls through to the next adapter. |
| husky | .husky/ directory |
Runs .husky/<hook> via sh -e (if script exists), mirroring husky v9 so non-executable hook files still run |
| hooks-dir | .hooks/ or git-hooks/ directory |
Runs <dir>/<hook> (if script exists and is executable) and all <dir>/<hook>-* prefixed executable scripts as separate lefthook commands. Non-executable files are silently ignored. Checked in order (first match wins). .git/hooks/ is intentionally excluded to avoid double-executing hooks already handled by dedicated adapters or lhm itself. |
For the husky and hooks-dir adapters, git's hook arguments (e.g. <remote-name> <remote-url> for pre-push, the message file path for commit-msg) are forwarded to the script via lefthook's {0} template, so scripts receive them positionally just as git would deliver. The pre-commit adapter does not forward positional args because pre-commit run --hook-stage does not consume them.
| Adapter | Detects | Behavior |
|---|---|---|
| git-lfs | git-lfs in PATH and the repo uses LFS (root .gitattributes declares filter=lfs, or the repo's git config has any lfs.* entry) |
Injects git lfs <hook> "$@" commands for pre-push, post-checkout, post-commit, and post-merge. Detection is per-repo: non-LFS repos pay no cost. The user or repo can override or skip these by defining a command named git-lfs in their own lefthook.yaml. |
Lefthook has its own built-in LFS support that fires for those four hooks whenever skip_lfs isn't set, but it runs on every repo regardless of whether the repo actually uses LFS, which is noticeably slow. Our default global config sets skip_lfs: true to opt out, and the git-lfs underlay adapter then re-introduces the LFS commands only in repos that actually use LFS.
If you have a repo-local hooks-dir or husky script (e.g. .hooks/post-merge, .husky/pre-push) that already shells out to git lfs <hook>, the underlay will run LFS a second time alongside your script. Either remove the git lfs <hook> line from your script (lhm now handles it) or override the underlay in your repo's lefthook.yaml:
post-merge:
commands:
git-lfs:
skip: trueBecause lhm now owns core.hooksPath, don't run plain git lfs install (it would try to write hook scripts into lhm's hooks dir, which is marked immutable, and fail with Operation not permitted). Run git lfs install --skip-repo instead to set up just the global LFS smudge/clean filters — lhm handles the hooks. lhm install prints a hint about this when it detects git-lfs in PATH without the filters configured.
Stdin from git is plumbed through to scripts for hooks where git pipes data on stdin: pre-push (ref info) and post-rewrite (the rewritten-commit list). Each command in those hooks gets use_stdin: true in the merged config; lefthook caches stdin and replays it to every command, so this works correctly under parallel: true.
lhm sets LHM=1 in the environment when it invokes lefthook (or a .git/hooks/<hook> fallback). Scripts can branch on it to detect whether they're running under lhm:
if [ "${LHM:-}" = "1" ]; then
# lhm is handling git-lfs hooks; skip our manual `git lfs <hook>` call
fiEnable debug logging with --debug or LHM_DEBUG=1:
lhm --debug install
LHM_DEBUG=1 git commit