{"id":45836305,"url":"https://github.com/block/lhm","last_synced_at":"2026-06-11T02:02:37.358Z","repository":{"id":337678756,"uuid":"1154739373","full_name":"block/lhm","owner":"block","description":"Merges global and repo git hooks into a single lefthook config","archived":false,"fork":false,"pushed_at":"2026-03-23T00:10:04.000Z","size":89,"stargazers_count":1,"open_issues_count":2,"forks_count":1,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-03-27T17:50:36.162Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":"","language":"Rust","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"apache-2.0","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/block.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":"CODEOWNERS","security":null,"support":null,"governance":"GOVERNANCE.md","roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":"AGENTS.md","dco":null,"cla":null}},"created_at":"2026-02-10T18:13:58.000Z","updated_at":"2026-03-23T00:10:07.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/block/lhm","commit_stats":null,"previous_names":["block/lefthookmerge","block/lhm"],"tags_count":11,"template":false,"template_full_name":"block/oss-project-template","purl":"pkg:github/block/lhm","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/block%2Flhm","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/block%2Flhm/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/block%2Flhm/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/block%2Flhm/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/block","download_url":"https://codeload.github.com/block/lhm/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/block%2Flhm/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":31100710,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-03-28T13:02:48.574Z","status":"ssl_error","status_checked_at":"2026-03-28T13:01:23.430Z","response_time":79,"last_error":"SSL_connect returned=1 errno=0 peeraddr=140.82.121.6:443 state=error: unexpected eof while reading","robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":false,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2026-02-26T23:04:16.586Z","updated_at":"2026-06-11T02:02:37.349Z","avatar_url":"https://github.com/block.png","language":"Rust","funding_links":[],"categories":[],"sub_categories":[],"readme":"# lhm - Merges global and repo lefthook configs\n\n## Install\n\n### Homebrew\n\n```sh\nbrew install block/tap/lhm\n```\n\n### Shell script\n\n```sh\ncurl -fsSL https://raw.githubusercontent.com/block/lefthookmerge/main/install.sh | sh\n```\n\n## Overview\n\nThis tool is designed to merge global lefthook config with per repo config. `lhm install` configures global\n`core.hooksPath` to call `lhm` which dynamically merges the global and repo lefthook configs, if they exist,\nusing lefthooks' `extends` [mechanism](https://lefthook.dev/configuration/extends.html).\n\nAll standard lefthook config file names are supported: `lefthook.\u003cext\u003e`, `.lefthook.\u003cext\u003e` (and `.config/lefthook.\u003cext\u003e`\nfor repo configs), where `\u003cext\u003e` is `yml`, `yaml`, `json`, `jsonc`, or `toml`.\n\n## How it works\n\n### `lhm install`\n\n- Creates shell wrapper scripts for all standard git hooks in `~/.local/libexec/lhm/hooks/`, each invoking `lhm run-hook \u003chook\u003e`\n- 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.\n- Sets `git config --global core.hooksPath ~/.local/libexec/lhm/hooks`\n- Writes a default `~/.config/lefthook.yaml` if no user config exists\n\n### `lhm uninstall`\n\nUnsets `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.\n\n### `lhm disable` / `lhm enable`\n\n`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 user-global config and underlay adapters (e.g. `git-lfs`) still run. `lhm enable` reverses it.\n\nThe disabled set is keyed by the repo's `origin` remote URL and persisted in `~/.config/lhm.yaml`:\n\n```yaml\ndisabled_repos:\n  - git@github.com:foo/bar.git\n  - https://github.com/baz/qux\n```\n\nOrigin URLs are used verbatim — no normalization between `git@` and `https://` forms. If a repo has no `origin` remote, `lhm disable` errors out.\n\n### `lhm dry-run`\n\nPrints the merged config that would be used for the current repo, then exits. Useful for verifying what hooks will run.\n\n```sh\nlhm dry-run\n```\n\n### `lhm import`\n\nWrites 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 user-global config and underlay adapters are not included.\n\nErrors if a lefthook config already exists in the repo (any of `lefthook.\u003cext\u003e`, `.lefthook.\u003cext\u003e`, `.config/lefthook.\u003cext\u003e`) or if no repo-fallback adapter is detected.\n\n```sh\nlhm import\n```\n\n### Config overrides\n\nThe 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.\n\n| Override | CLI flag | Environment variable |\n|----------|----------|---------------------|\n| Global config | `--global-config \u003cpath\u003e` | `LHM_GLOBAL_CONFIG` |\n| Local config | `--local-config \u003cpath\u003e` | `LHM_LOCAL_CONFIG` |\n\nCLI flags take precedence over env vars. When set, the override path is used directly instead of searching for `lefthook.\u003cext\u003e` files.\n\n```sh\nlhm --global-config ~/custom-global.yaml dry-run\nLHM_LOCAL_CONFIG=./other.yml git commit\n```\n\n### Hook execution\n\nWhen git triggers a hook, it runs the wrapper script in the hooks directory. Each script calls `lhm run-hook \u003chook\u003e`, 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`.\n\n0. **lefthook not in PATH**: falls back to executing `.git/hooks/\u003chook\u003e` directly (if it exists), bypassing all config merging\n1. **No config at all** (no underlay adapter, no global, no repo, no repo-fallback adapter): hook is skipped silently\n2. **Configs exist**: merges all available layers in order (underlay, global, repo/adapter), runs `lefthook run \u003chook\u003e` with `LEFTHOOK_CONFIG` pointing to the merged temp file\n\nConfig is resolved as a three-layer merge, where later layers override earlier ones:\n\n1. **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.\n2. **User global** (`~/.config/lefthook.yaml`) — per-user defaults\n3. **Repo** (`$REPO/lefthook.yaml` or repo-fallback adapter) — per-repo overrides\n\nAny layer may be absent. When a repo has no lefthook config, the repo-fallback adapter system is used in its place (see below).\n\nWhen 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.\n\n### Adapters\n\nlhm has two categories of adapters:\n\n- **Repo-fallback adapters** stand in for a missing `lefthook.yaml`. Only the first detected one is used.\n- **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.\n\n#### Repo-fallback adapters\n\nWhen 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.\n\nTried in this order (first match wins):\n\n| Adapter | Detects | Behavior |\n|---------|---------|----------|\n| **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 \u003cstage\u003e`. 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. |\n| **husky** | `.husky/` directory | Runs `.husky/\u003chook\u003e` (if script exists) |\n| **hooks-dir** | `.hooks/` or `git-hooks/` directory | Runs `\u003cdir\u003e/\u003chook\u003e` (if script exists and is executable) and all `\u003cdir\u003e/\u003chook\u003e-*` 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. |\n\nFor the `husky` and `hooks-dir` adapters, git's hook arguments (e.g. `\u003cremote-name\u003e \u003cremote-url\u003e` 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.\n\n#### Underlay adapters\n\n| Adapter | Detects | Behavior |\n|---------|---------|----------|\n| **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 \u003chook\u003e \"$@\"` 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`. |\n\nLefthook 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.\n\nIf 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 \u003chook\u003e`, the underlay will run LFS a second time alongside your script. Either remove the `git lfs \u003chook\u003e` line from your script (lhm now handles it) or override the underlay in your repo's `lefthook.yaml`:\n\n```yaml\npost-merge:\n  commands:\n    git-lfs:\n      skip: true\n```\n\nBecause 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.\n\nStdin 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`.\n\n### Environment\n\nlhm sets `LHM=1` in the environment when it invokes lefthook (or a `.git/hooks/\u003chook\u003e` fallback). Scripts can branch on it to detect whether they're running under lhm:\n\n```sh\nif [ \"${LHM:-}\" = \"1\" ]; then\n  # lhm is handling git-lfs hooks; skip our manual `git lfs \u003chook\u003e` call\nfi\n```\n\n### Debugging\n\nEnable debug logging with `--debug` or `LHM_DEBUG=1`:\n\n```sh\nlhm --debug install\nLHM_DEBUG=1 git commit\n```\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fblock%2Flhm","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fblock%2Flhm","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fblock%2Flhm/lists"}