Skip to content

Follow-up to #149: worktree-scoped core.hooksPath is invisible to --local, so hooks install where git never reads #150

Description

@thomastaillefer

Summary

_getHooksDirPath() reads core.hooksPath with git config --local. Git also
has a worktree config scope (.git/worktrees/<name>/config.worktree, gated
by extensions.worktreeConfig), and --local cannot see it.

When core.hooksPath is set at worktree scope, the install reports success but
writes the hook to the default directory, while git reads hooks from the
configured one. The hook silently never runs.

This is currently masked by #148. On 2.13.1 the repro below dies with
ENOTDIR before reaching this code. #149 fixes the ENOTDIR and keeps
git config --local, so once it lands this becomes the observable behaviour.
Filing it as a follow-up rather than a comment on #149, since it needs a
separate change.

Tested against #149's approach (default path derived from gitRoot), git 2.50.1.

How to reproduce

Requires #149 applied. On unmodified 2.13.1 you get #148's ENOTDIR instead.

git init main && cd main
git config user.email t@t.t && git config user.name t
echo x > f && git add -A && git commit -m init
git worktree add ../wt -b feat
cd ../wt

# hooks live somewhere custom, configured at worktree scope
mkdir -p ../customhooks
git config extensions.worktreeConfig true
git config --worktree core.hooksPath "$(cd ../customhooks && pwd)"

# a hook that must block the commit
echo '{"name":"t","simple-git-hooks":{"pre-commit":"echo HOOK_FIRED; exit 1"}}' > package.json
npx simple-git-hooks

git config user.email t@t.t && git config user.name t
echo y > g && git add g && git commit -m "should be blocked"

With #149 applied:

[INFO] Successfully set the pre-commit with command: echo HOOK_FIRED; exit 1
[feat 829bc19] should be blocked
 1 file changed, 1 insertion(+)

The commit is not blocked and HOOK_FIRED never prints. The hook went to
main/.git/hooks/pre-commit; git reads from customhooks/, which stays empty.
Nothing reports a problem.

On unmodified 2.13.1 the same steps give:

ENOTDIR: not a directory, mkdir '.../wt/.git/hooks'

which is #148.

Cause

Git resolves core.hooksPath from the effective config, and worktree scope
outranks local. From inside the worktree above:

$ git config --get core.hooksPath            # effective, what git uses
/tmp/x/customhooks
$ git config --worktree --get core.hooksPath
/tmp/x/customhooks
$ git config --local --get core.hooksPath    # what simple-git-hooks reads
                                             # (empty)
$ git config --show-origin --get core.hooksPath
file:/tmp/x/main/.git/worktrees/wt/config.worktree      /tmp/x/customhooks

_getHooksDirPath() sees an empty string from --local, reads that as
"not configured", and returns the default path.

Two fixes that look right but are not

Dropping --local. This reintroduces #130: a global core.hooksPath
would redirect a project's hooks into a shared directory and clobber hooks for
every other project on the machine. I confirmed the regression by setting a
global core.hooksPath through GIT_CONFIG_GLOBAL and watching the hook land
there instead of in the repo. --local needs to stay.

git rev-parse --git-path hooks. Tempting, and it returns the right answer
for both worktree scope and the #148 default-path case. But it resolves the
effective config, so it honours a global core.hooksPath too and hits the
same #130 problem. It can also return a relative path (.git/hooks when run
from the repo root), which would need resolving.

Suggested fix

Consult the two repo-level scopes in precedence order, never global or system:

function _getRepoScopedHooksPath(projectRoot) {
    const opts = { cwd: projectRoot, encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'] }
    for (const scope of ['--worktree', '--local']) {
        try {
            const value = execSync(`git config ${scope} --get core.hooksPath`, opts).trim()
            if (value) return value
        } catch {
            // --worktree fails unless extensions.worktreeConfig is enabled, and
            // `--get` exits 1 when the key is unset; both mean "try the next scope"
        }
    }
    return ''
}

then use it in place of the current execSync call, keeping the existing
absolute/relative handling and #149's gitRoot-based default.

I checked this against five cases: nothing set, worktree with the extension
off, global-only (correctly ignored, so #130 stays fixed), local-only, and
worktree overriding local. The last matches git config --get core.hooksPath.

One note on the --worktree branch: it exits non-zero with

fatal: --worktree cannot be used with multiple working trees unless the config
extension worktreeConfig is enabled.

when the extension is off, which is the common case, so it has to be caught
rather than treated as an error.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions