Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/worktree-scoped-hooks-path.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"simple-git-hooks": patch
---

fix: respect `core.hooksPath` set at worktree scope (`git config --worktree`), while still ignoring global and system config
48 changes: 35 additions & 13 deletions simple-git-hooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -245,22 +245,44 @@ async function setHooksFromConfig(projectRootPath=process.cwd(), argv=process.ar
*/
function _getHooksDirPath(projectRoot, gitRoot) {
const defaultHooksDirPath = path.join(gitRoot, 'hooks')
try {
const customHooksDirPath = execSync('git config --local core.hooksPath', {
cwd: projectRoot,
encoding: 'utf8'
}).trim()

if (!customHooksDirPath) {
return defaultHooksDirPath
}
const customHooksDirPath = _getRepoScopedHooksPath(projectRoot)

return path.isAbsolute(customHooksDirPath)
? customHooksDirPath
: path.resolve(projectRoot, customHooksDirPath)
} catch {
if (!customHooksDirPath) {
return defaultHooksDirPath
}

return path.isAbsolute(customHooksDirPath)
? customHooksDirPath
: path.resolve(projectRoot, customHooksDirPath)
}

/**
* Reads core.hooksPath from the repository-level config scopes only, in the order git applies them:
* worktree scope (config.worktree) first, then local scope (.git/config).
* Global and system scopes are deliberately ignored, so a machine-wide core.hooksPath
* does not make one project overwrite hooks shared with other projects.
*
* @param {string} projectRoot - The absolute path to the working directory
* @returns {string | undefined} - The configured value, or undefined if it is not set
* @private
*/
function _getRepoScopedHooksPath(projectRoot) {
for (const scope of ['--worktree', '--local']) {
try {
const value = execSync(`git config ${scope} --get core.hooksPath`, {
cwd: projectRoot,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore']
}).trim()

if (value) {
return value
}
} catch {
// --worktree fails in a linked worktree unless extensions.worktreeConfig is enabled,
// and --get exits with 1 when the key is not set. Both mean "try the next scope"
}
}
}

/**
Expand Down
60 changes: 60 additions & 0 deletions simple-git-hooks.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -695,6 +695,66 @@ describe("Simple Git Hooks tests", () => {
const installedHooks = getInstalledGitHooks(SHARED_HOOKS_DIR);
expect(isEqual(installedHooks, {})).toBe(true);
});

describe("core.hooksPath", () => {
const CUSTOM_HOOKS_DIR = path.join(WORKTREE_TEST_DIR, "custom-hooks");
const worktreeGit = (cmd) => execSync(`git ${cmd}`, { cwd: LINKED_WORKTREE, stdio: "ignore" });

it("creates git hooks in a core.hooksPath set at worktree scope", async () => {
worktreeGit("config extensions.worktreeConfig true");
worktreeGit(`config --worktree core.hooksPath "${CUSTOM_HOOKS_DIR}"`);
// worktree scope takes precedence over local scope
worktreeGit(`config --local core.hooksPath "${path.join(WORKTREE_TEST_DIR, "local-hooks")}"`);

await simpleGitHooks.setHooksFromConfig(LINKED_WORKTREE);

expect(isEqual(getInstalledGitHooks(CUSTOM_HOOKS_DIR), COMMON_GIT_HOOKS)).toBe(true);
expect(isEqual(getInstalledGitHooks(SHARED_HOOKS_DIR), {})).toBe(true);
});

it("resolves a relative core.hooksPath set at worktree scope against the worktree", async () => {
worktreeGit("config extensions.worktreeConfig true");
worktreeGit("config --worktree core.hooksPath .hooks");

await simpleGitHooks.setHooksFromConfig(LINKED_WORKTREE);

const hooksDir = path.join(LINKED_WORKTREE, ".hooks");
expect(isEqual(getInstalledGitHooks(hooksDir), COMMON_GIT_HOOKS)).toBe(true);
expect(isEqual(getInstalledGitHooks(SHARED_HOOKS_DIR), {})).toBe(true);
});

it("creates git hooks in a local core.hooksPath when extensions.worktreeConfig is off", async () => {
worktreeGit(`config --local core.hooksPath "${CUSTOM_HOOKS_DIR}"`);

await simpleGitHooks.setHooksFromConfig(LINKED_WORKTREE);

expect(isEqual(getInstalledGitHooks(CUSTOM_HOOKS_DIR), COMMON_GIT_HOOKS)).toBe(true);
expect(isEqual(getInstalledGitHooks(SHARED_HOOKS_DIR), {})).toBe(true);
});

it("creates git hooks in the default directory when extensions.worktreeConfig is on but core.hooksPath is not set", async () => {
worktreeGit("config extensions.worktreeConfig true");

await simpleGitHooks.setHooksFromConfig(LINKED_WORKTREE);

expect(isEqual(getInstalledGitHooks(SHARED_HOOKS_DIR), COMMON_GIT_HOOKS)).toBe(true);
});

it("ignores a global core.hooksPath", () => {
const globalConfig = path.join(WORKTREE_TEST_DIR, "global.gitconfig");
fs.writeFileSync(globalConfig, `[core]\n\thooksPath = ${CUSTOM_HOOKS_DIR}\n`);

// run the CLI in a child process, so that git picks up the global config from the environment
execSync(`node ${require.resolve("./cli")}`, {
cwd: LINKED_WORKTREE,
stdio: "ignore",
env: { ...process.env, GIT_CONFIG_GLOBAL: globalConfig },
});

expect(fs.existsSync(CUSTOM_HOOKS_DIR)).toBe(false);
expect(isEqual(getInstalledGitHooks(SHARED_HOOKS_DIR), COMMON_GIT_HOOKS)).toBe(true);
});
});
});

describe("CLI tests", () => {
Expand Down