Beyond Git Worktree
Git WorktreesLong read

Managing Shared Git Hooks Across Multiple Worktrees

Hook scripts must use `git rev-parse --git-common-dir` to work correctly across all worktrees.

Contributing Editor · · 11 min read
Cover illustration for “Managing Shared Git Hooks Across Multiple Worktrees”
Git Worktrees · September 15, 2026 · 11 min read · 2,521 words

Git worktrees let a developer check out multiple branches into separate directories from a single repository, but they all point back to one .git folder, and that includes the hooks directory. This creates a specific, solvable problem: hooks written without worktrees in mind will misbehave the moment they run from anywhere other than the main working tree. The fix runs through core.hooksPath, a version-controlled hooks directory, and scripts that resolve paths using git rev-parse --git-common-dir instead of assumptions about where they're standing.

A worktree, in short, is an additional working directory linked to the same .git, sharing all history, branches, config, and hooks with the main checkout. That sharing is deliberate. Git stores hook scripts in .git/hooks/, and because linked worktrees all reference the same underlying .git, they share exactly one hooks directory. There's no native per-worktree override baked into Git's design.

That structural fact has a consequence to state before anything else: Git changes its working directory before running most hooks, to the root of the working tree for a non-bare repo, or to $GIT_DIR for a bare one. Push-side hooks (pre-receive, update, post-receive, post-update, push-to-checkout) are the exception; they always execute in $GIT_DIR regardless. A hook script that assumes it's running from a fixed working-tree root will silently misbehave the instant it fires from a linked worktree whose root sits in a completely different directory.

None of this means the shared hooks directory is a design flaw. It's a feature, and a sensible one: one set of hooks, one source of truth, no drift between worktrees. The actual problem is narrower and more fixable, that most hooks aren't written with worktree-awareness in mind. Two commands underpin the entire fix. git rev-parse --git-dir returns a worktree-specific path when called from a linked worktree. git rev-parse --git-common-dir always returns the main .git, no matter which worktree invoked it. That second command is the anchor everything downstream depends on.

Why hooks committed to the repo and installed via core.hooksPath are the right starting point

By default, .git/hooks/ is never committed and never cloned. It's local to each individual clone, which means sharing hooks across a team, let alone across worktrees, requires an explicit decision and a small amount of setup.

The pattern that solves this is not complicated. Keep hook scripts in a tracked directory, .githooks/ at the repo root is the common convention, and point Git at it with:

git config core.hooksPath .githooks

Once that's set, hooks become version-controlled code. They get reviewed in pull requests, diffed like anything else in the repo, and rolled back with git revert if a change breaks something. Every worktree tied to that repo inherits the hooks automatically, with no separate installation step per worktree. Update the script once, and every worktree picks up the new behavior automatically.

The relative-path behavior matters here. When core.hooksPath is set to a relative value like .githooks, Git resolves it relative to the working directory from which the hook runs. That means .githooks resolves correctly from any worktree, main or linked, as long as the scripts inside don't themselves assume a fixed absolute location.

A practical setup looks like this: create .githooks/, move the scripts in, make them executable, then run the git config command above. Document the requirement in the README or an onboarding script, because new clones need to run that config command once, it isn't inherited from the repository itself. Teams that want to remove the "don't forget" step often wire it into a Makefile target or a bootstrap script that runs on first setup.

One limitation deserves honest treatment: git config core.hooksPath is a local write to .git/config. It does not travel with the repository through git clone or git pull. Every clone, and this includes every teammate's machine, needs to run that command once before hooks activate.

Writing hooks that resolve paths correctly regardless of which worktree calls them

Most worktree-related hook failures trace back to one root cause: a script that hardcodes a path, or calls --git-dir to locate a shared resource, and gets back a worktree-specific path instead of the common .git root it actually needed.

The beads project ran into exactly this in mid-January 2026. Chained hooks, specifically a pre-commit.old file meant to be called from within a newer pre-commit script, had problems. The bd hooks install command installed to the wrong directory. bd hooks list reported incorrect status. Both symptoms traced to the same mistake: the tooling used a git-dir-style resolution (GetGitDir()) instead of the common-dir equivalent, so anything invoked from a linked worktree looked in the wrong place.

The fix is a small pattern that every hook script should open with:

GIT_COMMON_DIR=$(git rev-parse --git-common-dir)

From there, $GIT_COMMON_DIR is the right anchor for anything shared: hook chain files, shared config, lock files. For paths relative to the actual working tree, the files a developer is editing, configs committed alongside the source, git rev-parse --show-toplevel is the correct call instead.

Chained hooks deserve specific attention. If a pre-commit script execs a pre-commit.old as part of a chain (a common pattern when adding a new hook without clobbering an existing one), the chained script has to be located via $GIT_COMMON_DIR/hooks/. A relative path assumption will work fine from the main worktree and then quietly fail from a linked one, because the relative path simply doesn't resolve to anything there.

The push-hook exception carries its own trap. pre-receive, update, post-receive, post-update, and push-to-checkout always run in $GIT_DIR, and in a linked worktree, $GIT_DIR is the worktree-private directory, not the common one. Any script written for these hooks needs separate handling; the --git-common-dir pattern that fixes everything else does not apply here in the same way.

The testing discipline that catches all of this early is simple to state, if easy to skip: after writing or editing any hook, run it from the main worktree, then run it again from a freshly added linked worktree, before merging the change into the shared hooks directory.

How core.hooksPath can be silently overwritten across all worktrees at once

Here's the structural risk that makes all of the above fragile if left unguarded. A plain git config core.hooksPath <value>, run from inside any linked worktree, with no --worktree flag and no extensions.worktreeConfig enabled, writes directly to the shared $GIT_COMMON_DIR/config. That write takes effect immediately, for the main repository and for every other linked worktree at once. There's no isolation by default.

This is not a hypothetical. A team running core.hooksPath=hooks for committed pre-push quality gates had those gates silently disabled across an entire clone for several days before anyone caught it. The cause was Claude Code's worktree creation process overwriting the shared core.hooksPath value in the repo's shared .git/config. Nobody chose to disable the quality gates; a tool did it as a side effect of creating a worktree. The workaround the team shipped was a set of forwarding shim scripts placed directly in .git/hooks/, each one simply exec-ing the corresponding committed hook, so that even if the config pointer got hijacked, the shims still ran the real logic.

The correct scoped write does exist. git config --worktree writes to a worktree-private config.worktree file rather than the shared config, but this only works if extensions.worktreeConfig = true has been set beforehand. Skip that step, and --worktree will not scope the write correctly.

A newer partial fix has surfaced since, Claude Code updated its behavior to write an absolute path into config.worktree instead of touching the shared .git/config, which does leave the shared value intact. But the absolute-path approach carries its own failure mode: it introduces its own failure mode when the path on disk no longer matches what was recorded.

The takeaway worth sitting with is that the shared-config architecture means any process without explicit scope awareness, whether a tool, a setup script, or an installer wizard, that runs git config inside a worktree can disable quality gates for the entire repository without anyone noticing until something ships broken. This is an active operational risk for teams running automation or agent tooling against worktrees, not a corner case.

Detecting and recovering from core.hooksPath overrides in practice

Detection starts with one command:

git config --show-origin core.hooksPath

This shows every layer where the key is set and exactly which file each value came from, which makes it possible to confirm whether an override landed in the shared config or stayed properly scoped to a worktree-private file.

Manual remediation, once an override is confirmed, is a two-step process from the main worktree: git config --unset core.hooksPath clears the bad value from shared config, and then re-running git config core.hooksPath .githooks restores the intended one.

Teams running heavier automation have built watchdog patterns for this. One documented approach uses a launchd job with WatchPaths set on the affected repositories' .git/config files, giving near-instant reaction when the file changes, backed by a 15-minute polling interval as a fallback. The watchdog detects the override, unsets it, pushes any commits that went local-only while the gates were shadowed, and posts an alert to a team Slack channel. That's a heavyweight solution, and it fits teams running many worktrees under continuous automation where a silent gate failure is expensive.

Most teams don't need that much machinery. A single CI step that asserts git config core.hooksPath equals the expected value, and fails the build if it doesn't, catches the problem before anything ships, without requiring a local daemon running on every developer's machine.

There's also a defensive layer worth stacking underneath either approach: forwarding shim scripts placed directly in .git/hooks/, each one exec-ing the corresponding script in the committed hooks directory. Even if core.hooksPath gets silently overwritten to point back at .git/hooks, the shims still preserve the chain to the real logic. Belt and suspenders, but for a quality gate that blocks bad commits from reaching a shared branch, that redundancy is cheap insurance.

For anyone building installer or tooling logic around hooks (the Prek project's approach to this is instructive), the install logic itself needs to handle --local versus --worktree scope explicitly, and it needs rollback on failure, so an install that errors partway through leaves Git config exactly as it found it rather than half-modified.

Sharing untracked files that hooks depend on across all worktrees

core.hooksPath solves the problem of sharing hook scripts. It does nothing for the untracked files those hooks often depend on: environment overrides, local tool configs, secrets, anything that by design should never be committed but still needs to exist consistently in every worktree.

The git-worktree-share tool addresses this directly. It stores shared untracked files centrally in .git/shared/ and symlinks them into each worktree, using git rev-parse --git-common-dir under the hood to make sure the shared directory resolves correctly no matter which worktree is asking.

The workflow has three commands. git worktree-share add <file> moves a file into .git/shared/ and symlinks it back into place everywhere it's needed. git worktree-share sync is idempotent, safe to run any time, and reconciles symlinks across all worktrees. git worktree-share hook install sets up a post-checkout hook so that running git worktree add automatically syncs shared files into the new worktree without a manual step.

The safety behavior does this specifically: if a real, non-symlinked file already exists in a worktree at sync time, it gets backed up as <file>.shared-backup before the symlink takes its place. Nothing gets silently clobbered.

Files that fit this pattern well include local tool-version pins, environment config files that hooks read at runtime, and cache directories that hooks write into. And when a worktree gets deleted, only its symlinks disappear, the real files stay put in .git/shared/, untouched and still backing every other worktree.

When to use a hooks framework and which one fits which repo

Hand-written hooks are the lowest-risk option on the table. No external dependency, nothing to audit beyond the shell script itself, and no supply-chain surface to worry about. For teams wary of adding a dependency purely for hook management, this is the sound default.

Husky defines hooks through package.json or dedicated config files and distributes them through version control. It's a legitimate choice, but only where a package.json already exists for other reasons. Adding one purely to get hooks working drags npm infrastructure into a Java, Go, or Rust project, and that's friction new contributors and CI systems will trip over for no real benefit.

pre-commit takes a different approach: hooks are declared in a .pre-commit-config.yaml file with pinned revisions, and the tool automatically clones and checks out any hook repository not already present locally. It suits polyglot repos well, ones running linters or formatters across several language ecosystems at once. The tradeoff is that it brings Python along as a dependency, which is a cost for teams that otherwise have none.

Lefthook ships as a single binary, installable via Homebrew, npm, a package manager, or direct download, with no language runtime required to run it. It supports parallel hook execution, which can show up as a real speed difference on larger hook sets. Because it isn't tied to Node or any other runtime, it works cleanly in any repo type without forcing a package.json into existence. For polyglot or non-JS repos that want the convenience of a framework without committing to an ecosystem's baggage, Lefthook is often the sensible default.

One caveat applies across all three frameworks equally: any tool that installs its runner by writing to .git/hooks/ or setting core.hooksPath needs to be initialized from the main worktree, or initialized separately in every worktree if per-worktree runners are actually needed. Check each framework's documentation for explicit worktree support before adopting it into a repo that already runs multiple worktrees; not all of them document this clearly, and the failure mode, silent hook non-execution, is hard to notice until something slips through that shouldn't have.

Using worktrees for parallel agent execution and what that demands from hooks

Worktrees have found a second life as the isolation mechanism for running multiple AI coding agents against the same repository in parallel. Each agent gets its own worktree, its own working directory, its own checked-out branch, all while sharing one .git and, critically, one hooks directory underneath.

That arrangement raises the stakes on everything covered above rather than introducing new mechanics. A hook that mishandles --git-dir versus --git-common-dir doesn't just misbehave for one careless developer working in a side branch anymore; it misbehaves for every agent running concurrently, potentially several at once, each hitting the same broken path resolution at the same time. A core.hooksPath override triggered by one agent's worktree-creation step, the exact failure mode described earlier with Claude Code's worktree handling, now has the capacity to disable quality gates across every other agent's worktree simultaneously, silently, mid-run.

The defenses are the same ones already laid out: hooks anchored to $GIT_COMMON_DIR rather than assumptions about location, a CI check that verifies core.hooksPath hasn't drifted, forwarding shims as a backstop, and shared untracked files kept in sync through a tool like git-worktree-share rather than copied by hand into each new agent's workspace. It amounts to nothing exotic. What changes with parallel agent execution is the cost of skipping it: a hook failure that used to mean one developer's afternoon gets lost now scales to however many worktrees are running unattended, and unattended is precisely when nobody's watching to catch it.

Sources

  1. GitHub - keithamus/git-worktree-share: Share untracked files across git worktrees easily
  2. Git hooks don't work in worktrees · Issue #1127 · gastownhall/beads
  3. Git - git-worktree Documentation
  4. Make git hook installation worktree-safe and transactional · Issue #1672 · j178/prek
  5. Tip: Share a Git Hooks Directory Across Your Repositories
  6. github.com
  7. andymadge.com
Filed underGit Worktrees

More in Git Worktrees