From 8c86f5e971a09d5c959feb0690498d3e318748e4 Mon Sep 17 00:00:00 2001 From: Mayeu Date: Sat, 9 May 2026 19:09:50 +0200 Subject: [PATCH] fix: install lefthook hooks via git --git-path hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous worktree guard skipped hook installation entirely from linked worktrees, leaving them to rely on stale symlinks installed earlier from the main checkout — which broke once those store paths were GC'd. Per gitrepository-layout(5), git always looks up hooks in the common gitdir, so a single install from any worktree wires up hooks for every worktree. Resolve the hooks dir with `git rev-parse --git-path hooks` (returns /hooks from any worktree) instead of the literal `.git/hooks`, which fails in worktrees where `.git` is a file. --- src/lib/cfg/lefthook.md | 72 ++++++++++++++++------------------------ src/lib/cfg/lefthook.nix | 33 +++++++++--------- 2 files changed, 45 insertions(+), 60 deletions(-) diff --git a/src/lib/cfg/lefthook.md b/src/lib/cfg/lefthook.md index 626b42ff..68f7d957 100644 --- a/src/lib/cfg/lefthook.md +++ b/src/lib/cfg/lefthook.md @@ -22,51 +22,35 @@ is just the template/scaffold, the real config data gets merged in by consumers (e.g. in `src/local/configs.nix` or wherever this is used). Nixago deep-merges `data` from the caller. -`hook.extra` is a Nixago hook hat runs whenever the generated `lefthook.yml` is -materialized. It receives the final merged `config` (the full lefthook YAML -content as a Nix attrset) and produces extra shell commands. Here's the pipeline -step by step: +`hook.extra` is a Nixago hook that runs whenever the generated `lefthook.yml` +is materialized. It receives the final merged `config` (the full lefthook YAML +content as a Nix attrset) and emits a shell snippet that symlinks a Nix-built +wrapper script into the repo's hooks directory for each configured stage. The +wrapper runs `lefthook run "" "$@"` unless `$LEFTHOOK == "0"` (escape +hatch). -```nix -hook.extra = config: - let - commands = lib.pipe config [ - # 1. Strip non-stage keys (colors, extends, etc.) - # e.g. { pre-commit = {...}; commit-msg = {...}; colors = true; } - # becomes { pre-commit = {...}; commit-msg = {...}; } - toStagesConfig - - # 2. Extract just the attribute names → ["pre-commit" "commit-msg"] - lib.attrNames - - # 3. For each stage, create a symlink command: - # ln -sf "/nix/store/...-lefthook-pre-commit" ".git/hooks/pre-commit" - # The target is a Nix-built script (mkScript) that runs: - # lefthook run "pre-commit" "$@" - # (unless $LEFTHOOK == "0", which disables it) - (lib.map (stage: ''ln -sf "${mkScript stage}" ".git/hooks/${stage}"'')) - - # 4. Prepend "mkdir -p .git/hooks" IF there are any stages - # ["mkdir -p .git/hooks" "ln -sf ..." "ln -sf ..."] - (stages: - lib.optional (stages != []) "mkdir -p .git/hooks" - ++ stages) - - # 5. Join into a single newline-separated shell script string - (lib.concatStringsSep "\n") - ]; - in '' - # Only install hooks in the main repo, not in worktrees. - # In worktrees, .git is a file pointing to the main repo's .git dir, - # so mkdir -p .git/hooks would fail. - if test "$(git rev-parse --git-dir)" = "$(git rev-parse --git-common-dir)"; then - ${commands} - fi - ''; -``` +#### Worktree behavior + +The hooks directory is resolved via `git rev-parse --git-path hooks` rather +than the literal `.git/hooks`. Two reasons: + +1. In a linked worktree, `.git` is a *file* (not a directory) pointing at + `
/.git/worktrees/`, so `mkdir -p .git/hooks` would fail. +2. Per [`gitrepository-layout(5)`][layout]: *"This directory is ignored if + `$GIT_COMMON_DIR` is set and `$GIT_COMMON_DIR/hooks` will be used + instead."* In other words, git always looks up hooks in the **common** + gitdir, regardless of which worktree you commit from. + +`--git-path hooks` returns `/hooks` from any worktree, so a single +install (from main checkout or any worktree) wires up hooks for every +worktree. + +[layout]: https://git-scm.com/docs/gitrepository-layout + +#### Lifecycle When you run the Nixago hook (e.g. via `direnv allow` or `std` commands), it: -1. Writes `lefthook.yml` to your project root -2. Runs this `hook.extra` script, which symlinks Nix-built wrapper scripts into - `.git/hooks/` (only in the main repo, not in worktrees) +1. Writes `lefthook.yml` to your project root. +2. Runs `hook.extra`, which symlinks Nix-built wrapper scripts into the + resolved hooks directory. diff --git a/src/lib/cfg/lefthook.nix b/src/lib/cfg/lefthook.nix index 6b8e9cd8..b9a7b491 100644 --- a/src/lib/cfg/lefthook.nix +++ b/src/lib/cfg/lefthook.nix @@ -21,21 +21,22 @@ in { format = "yaml"; output = "lefthook.yml"; packages = [nixpkgs.lefthook]; - # Add an extra hook for adding required stages whenever the file changes. - # Skip hook installation in git worktrees where .git is a file, not a directory. + # Install symlinks into the resolved hooks dir, so this works from both the + # main checkout and linked worktrees. `git rev-parse --git-path hooks` + # returns `/hooks` (per gitrepository-layout(5): hooks live in + # the common gitdir, not the per-worktree private gitdir), so a single + # install from any worktree wires up hooks for every worktree. hook.extra = config: let - commands = lib.pipe config [ - toStagesConfig - lib.attrNames - (lib.map (stage: ''ln -sf "${mkScript stage}" ".git/hooks/${stage}"'')) - (stages: - lib.optional (stages != []) "mkdir -p .git/hooks" - ++ stages) - (lib.concatStringsSep "\n") - ]; - in '' - if test "$(${lib.getExe nixpkgs.git} rev-parse --git-dir 2>/dev/null)" = "$(${lib.getExe nixpkgs.git} rev-parse --git-common-dir 2>/dev/null)"; then - ${commands} - fi - ''; + stages = lib.pipe config [toStagesConfig lib.attrNames]; + links = + lib.concatMapStringsSep "\n" + (stage: ''ln -sf "${mkScript stage}" "$_std_hooks_dir/${stage}"'') + stages; + in + lib.optionalString (stages != []) '' + _std_hooks_dir="$(${lib.getExe nixpkgs.git} rev-parse --git-path hooks)" + mkdir -p "$_std_hooks_dir" + ${links} + unset _std_hooks_dir + ''; }