Skip to content

lefthook install bakes install-time absolute path into hook shim, breaking git worktrees #1398

@softmarshmallow

Description

@softmarshmallow

Summary

lefthook install generates a hook shim in .git/hooks/<name> that contains a hardcoded absolute path to the specific node_modules/ directory that existed at install time. Because .git/hooks/ is shared across all git worktrees of a repository, the shim installed by one worktree leaks into all other worktrees — and if the original worktree is ever deleted (or its pnpm content-addressed store changes), the shim silently falls through to a no-op in every other worktree.

Environment

  • lefthook v2.1.5 (installed via pnpm, lefthook-darwin-arm64@2.1.5)
  • pnpm 10.24.0
  • macOS, arm64
  • Repo uses git worktrees (git worktree add ...)

Repro

  1. In a repo with lefthook wired up via \"prepare\": \"lefthook install --force\" and installed as an npm dev dependency:
    git worktree add ../wt-a -b feature/a
    cd ../wt-a && pnpm install     # runs `lefthook install --force` → writes hooks into shared .git/hooks/
  2. Create a second worktree later:
    git worktree add ../wt-b -b feature/b
    cd ../wt-b                     # no pnpm install yet — this is the critical state
  3. Delete or invalidate the first worktree's node_modules/ (e.g. rm -rf ../wt-a, or prune the pnpm store, or simply use a worktree created before pnpm install has run there):
    rm -rf ../wt-a
  4. Commit in the second worktree:
    cd ../wt-b
    git commit -am 'test'
  5. Observe stderr: Can't find lefthook in PATH — commit succeeds with exit 0, and pre-commit jobs are silently skipped.

Root cause

The generated shim at .git/hooks/pre-commit contains (verbatim, from our repo):

call_lefthook()
{
  if test -n \"\$LEFTHOOK_BIN\"
  then
    \"\$LEFTHOOK_BIN\" \"\$@\"
  elif lefthook -h >/dev/null 2>&1
  then
    lefthook \"\$@\"
  elif /Users/<user>/…/worktrees/wt-a/node_modules/.pnpm/lefthook-darwin-arm64@2.1.5/node_modules/lefthook-darwin-arm64/bin/lefthook -h >/dev/null 2>&1
  then
    /Users/<user>/…/worktrees/wt-a/node_modules/.pnpm/lefthook-darwin-arm64@2.1.5/node_modules/lefthook-darwin-arm64/bin/lefthook \"\$@\"
  else
    dir=\"\$(git rev-parse --show-toplevel)\"
    # … relative \$dir/node_modules/<variants> fallbacks …
  fi
}

Two problems compound:

  1. The third branch is a hardcoded absolute path captured at lefthook install time (specifically, the node_modules/.pnpm/... path of whichever worktree happened to run the install). This path is brittle by construction — it's tied to a single working tree, a single pnpm hash, and a single OS/arch.
  2. .git/hooks/ is shared across all worktrees, so this install-time-captured absolute path is inherited by every other worktree of the same repository — even worktrees that have their own perfectly valid node_modules/lefthook-<os>-<arch>/bin/lefthook, which the shim would find via the existing relative fallback in the else branch if it were reached.

The existing \$dir/node_modules/... fallback (using git rev-parse --show-toplevel) is already correct for multi-worktree setups. The hardcoded absolute branch just shadows it in a broken way.

Proposed fix

Remove the absolute-path branch from the generated shim. The \$dir/node_modules/lefthook-\${osArch}-\${cpuArch}/bin/lefthook fallback (and the other \$dir-relative fallbacks) already cover the same case correctly, per-worktree, without any install-time snapshotting.

This is a codegen-only change — the shim logic for single-checkout repos is unaffected (the relative fallback finds the same binary). It eliminates the multi-worktree failure mode entirely.

Happy to send a PR if you're open to this direction.

Related but out of scope for this issue

When the shim does fall through every branch, the terminal echo \"Can't find lefthook in PATH\" exits 0 rather than 1, which is why this bug was invisible for a long time (unformatted commits silently landed and only failed at CI). I understand that's a deliberate posture to not block contributors without lefthook installed, so I'm not proposing to change it here — just flagging it as the reason the absolute-path bug above is especially dangerous in practice.

related: #1384

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions