Skip to content

git hooks fail to read/write hooks-cache when .git is a file (git worktrees and submodules) #11500

@batpigandme

Description

@batpigandme

Description

Encountered an error when running the pre-commit hook from a git worktree (a checkout created via git worktree add). The hook attempts to write its run report to ${root}/.git/hooks-cache/pre_commit_report.yml, but in a worktree ${root}/.git is a file containing gitdir: <path>, not a directory, so every mkdir / touch / redirect against that path fails with Not a directory.

The same failure mode applies to git submodules. Anywhere the repository is checked out in a form where ${root}/.git is a gitfile rather than a directory, the hook will fail to write its cache.

The hook does not abort (proceeds when the lint checks), but it emits spurious error lines for every run, and the pre-commit report it is supposed to leave behind is never written. commit-msg then tries to read that report from the same path, so the read side fails silently in the same environments.

The assumption that ${root}/.git is a directory appears in three hooks (pre-commit, pre-push, and commit-msg:

# Define the path to a directory for caching hook results:
cache_dir="${root}/.git/hooks-cache"

# Define the path to a directory for caching hook results:
cache_dir="${root}/.git/hooks-cache"

# Define the path to a report generated during the pre-commit hook:
pre_commit_report="${root}/.git/hooks-cache/pre_commit_report.yml"

All three derive root from git rev-parse --show-toplevel, which correctly returns the working-tree root in every repo layout. But for the path join to .git — that path is only a directory in a regular top-level checkout.

Proposed fix

The portable fix is to ask git directly for the git directory (with git_dir=$(git rev-parse --absolute-git-dir)) instead of path-joining onto ${root}/.git. This would mirror the existing root=$(git rev-parse --show-toplevel) line already present in each hook. So, we could add a sibling git_dir in the prologue, then reference it at the call site:

# Determine root directory:
root=$(git rev-parse --show-toplevel)

# Determine git directory (correct in regular checkouts, worktrees, and submodules):
git_dir=$(git rev-parse --absolute-git-dir)
# At each affected line:
cache_dir="${git_dir}/hooks-cache"                                # pre-commit:84
cache_dir="${git_dir}/hooks-cache"                                # pre-push:63
pre_commit_report="${git_dir}/hooks-cache/pre_commit_report.yml"  # commit-msg:40

git rev-parse --absolute-git-dir resolves to the correct absolute path in all three repo layouts:

  • <root>/.git for a regular top-level checkout
  • <main>/.git/worktrees/<name> for a linked worktree
  • <superproject>/.git/modules/<name> for a submodule

Plain --git-dir returns .git (relative) in a regular checkout and absolute paths in worktrees/submodules. Relative-vs-absolute today is inconsistent. --absolute-git-dir normalizes to always-absolute, which matches the current ${root}/.git behavior exactly on the happy path — so a reviewer can verify this is a strict superset: same result in a regular checkout, correct result where the old code broke.

This uses the root=$(git rev-parse --show-toplevel) idiom applied to git's other canonical rev-parse query. --show-toplevel gives you the working-tree root (right for resolving source paths); --absolute-git-dir gives you the metadata directory (right for caches and reports).

No side-effect risks were found: (1) no hook changes CWD during execution (no cd/pushd in any of pre-commit, pre-push, commit-msg, post-merge); (2) per-worktree cache is the correct semantic — the cache is transient state for a single in-flight commit, so two concurrent commits in two worktrees need separate caches, not a shared one (--git-common-dir would be wrong here).

Related Issues

No.

Questions

No.

Demo

No response

Reproduction

Worktree case (verified):

git clone https://github.com/stdlib-js/stdlib.git
cd stdlib
# Create a worktree for any branch
git worktree add ../stdlib-wt -b scratch-branch
cd ../stdlib-wt
# Stage any trivial change and commit
echo "// test" >> CONTRIBUTING.md
git add CONTRIBUTING.md
git commit -m "chore: worktree hook repro"

Submodule case (same failure mode — .git is a file):

# In any superproject
git submodule add https://github.com/stdlib-js/stdlib.git vendor/stdlib
cd vendor/stdlib
# Confirm .git is a file, not a directory
test -f .git && echo "gitfile (will trigger bug)"
# Stage any trivial change and commit from within the submodule
echo "// test" >> CONTRIBUTING.md
git add CONTRIBUTING.md
git commit -m "chore: submodule hook repro"

Expected Results

The commit completes without hook-cache write errors, and ${git-dir}/hooks-cache/pre_commit_report.yml is written successfully.

Actual Results

mkdir: <worktree>/.git: Not a directory
touch: <worktree>/.git/hooks-cache/pre_commit_report.yml: Not a directory
tools/git/hooks/pre-commit: line 120: <worktree>/.git/hooks-cache/pre_commit_report.yml: Not a directory
...
(one "Not a directory" line per lint phase)

Version

develop @ d3427df39ee (2026-04-17)

Environments

N/A

Browser Version

No response

Node.js / npm Version

No response

Platform

macOS (arm64); the bug is platform-independent — any worktree hits it.

Checklist

  • Read and understood the Code of Conduct.
  • Searched for existing issues and pull requests.

Assisted-by: Claude Opus 4.7 (but I read and understand the whole thing)

Metadata

Metadata

Assignees

No one assigned

    Labels

    BugSomething isn't working.

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions