https://github.com/block/lhm
Merges global and repo git hooks into a single lefthook config
https://github.com/block/lhm
Last synced: 10 days ago
JSON representation
Merges global and repo git hooks into a single lefthook config
- Host: GitHub
- URL: https://github.com/block/lhm
- Owner: block
- License: apache-2.0
- Created: 2026-02-10T18:13:58.000Z (4 months ago)
- Default Branch: main
- Last Pushed: 2026-03-23T00:10:04.000Z (2 months ago)
- Last Synced: 2026-03-27T17:50:36.162Z (2 months ago)
- Language: Rust
- Homepage:
- Size: 86.9 KB
- Stars: 1
- Watchers: 0
- Forks: 1
- Open Issues: 2
-
Metadata Files:
- Readme: README.md
- License: LICENSE
- Codeowners: CODEOWNERS
- Governance: GOVERNANCE.md
- Agents: AGENTS.md
Awesome Lists containing this project
README
# lhm - Merges global and repo lefthook configs
## Install
### Homebrew
```sh
brew install block/tap/lhm
```
### Shell script
```sh
curl -fsSL https://raw.githubusercontent.com/block/lefthookmerge/main/install.sh | sh
```
## Overview
This tool is designed to merge global lefthook config with per repo config. `lhm install` configures global
`core.hooksPath` to call `lhm` which dynamically merges the global and repo lefthook configs, if they exist,
using lefthooks' `extends` [mechanism](https://lefthook.dev/configuration/extends.html).
All standard lefthook config file names are supported: `lefthook.`, `.lefthook.` (and `.config/lefthook.`
for repo configs), where `` is `yml`, `yaml`, `json`, `jsonc`, or `toml`.
## How it works
### `lhm install`
- Creates shell wrapper scripts for all standard git hooks in `~/.local/libexec/lhm/hooks/`, each invoking `lhm run-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 and `chattr +i` semantics (`FS_IMMUTABLE_FL`) on Linux. macOS works for non-root user installs; Linux requires `CAP_LINUX_IMMUTABLE` (typically root) and is a silent no-op otherwise. Re-running `lhm install` clears and re-applies the flag.
- Sets `git config --global core.hooksPath ~/.local/libexec/lhm/hooks`
- Writes a default `~/.config/lefthook.yaml` if no user config exists
### `lhm disable`
Unsets `git config --global core.hooksPath`, disabling lhm. The hook scripts in `~/.local/libexec/lhm/hooks/` are left in place so `lhm install` can re-enable quickly.
### `lhm dry-run`
Prints the merged config that would be used for the current repo, then exits. Useful for verifying what hooks will run.
```sh
lhm dry-run
```
### Config overrides
The global and local (repo) config paths can be overridden via CLI flags or environment variables. CLI flags are available on `dry-run`; env vars work everywhere, including during hook invocations.
| Override | CLI flag | Environment variable |
|----------|----------|---------------------|
| Global config | `--global-config ` | `LHM_GLOBAL_CONFIG` |
| Local config | `--local-config ` | `LHM_LOCAL_CONFIG` |
CLI flags take precedence over env vars. When set, the override path is used directly instead of searching for `lefthook.` files.
```sh
lhm --global-config ~/custom-global.yaml dry-run
LHM_LOCAL_CONFIG=./other.yml git commit
```
### Hook execution
When git triggers a hook, it runs the wrapper script in the hooks directory. Each script calls `lhm run-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`.
0. **lefthook not in PATH**: falls back to executing `.git/hooks/` directly (if it exists), bypassing all config merging
1. **No config at all** (no underlay adapter, no global, no repo, no repo-fallback adapter): hook is skipped silently
2. **Configs exist**: merges all available layers in order (underlay, global, repo/adapter), runs `lefthook run ` with `LEFTHOOK_CONFIG` pointing to the merged temp file
Config is resolved as a three-layer merge, where later layers override earlier ones:
1. **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.
2. **User global** (`~/.config/lefthook.yaml`) — per-user defaults
3. **Repo** (`$REPO/lefthook.yaml` or 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 user-global config before merging. This prevents a global 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 global-only setups.
### Adapters
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 a low-priority layer beneath the user-global config, so the user or repo can still override anything they generate.
#### Repo-fallback adapters
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 `. 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/` (if script exists) |
| **hooks-dir** | `.hooks/` or `git-hooks/` directory | Runs `/` (if script exists and is executable) and all `/-*` prefixed executable scripts as parallel 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. ` ` 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.
#### Underlay adapters
| 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 "$@"` 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 `, the underlay will run LFS a second time alongside your script. Either remove the `git lfs ` line from your script (lhm now handles it) or override the underlay in your repo's `lefthook.yaml`:
```yaml
post-merge:
commands:
git-lfs:
skip: true
```
Because 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`.
### Environment
lhm sets `LHM=1` in the environment when it invokes lefthook (or a `.git/hooks/` fallback). Scripts can branch on it to detect whether they're running under lhm:
```sh
if [ "${LHM:-}" = "1" ]; then
# lhm is handling git-lfs hooks; skip our manual `git lfs ` call
fi
```
### Debugging
Enable debug logging with `--debug` or `LHM_DEBUG=1`:
```sh
lhm --debug install
LHM_DEBUG=1 git commit
```