From 94418593b480edbeb0177979326c8a9c2c25ef38 Mon Sep 17 00:00:00 2001 From: neverland Date: Mon, 24 Aug 2026 17:53:46 +0800 Subject: [PATCH] fix(rstack): honor worktree-scoped hooks path --- packages/rstack/src/setup/install.ts | 92 ++++++++++++++++++++- packages/rstack/tests/setup/install.test.ts | 50 +++++++++++ 2 files changed, 140 insertions(+), 2 deletions(-) diff --git a/packages/rstack/src/setup/install.ts b/packages/rstack/src/setup/install.ts index 92a6584..a0dabd9 100644 --- a/packages/rstack/src/setup/install.ts +++ b/packages/rstack/src/setup/install.ts @@ -52,6 +52,8 @@ type GitContext = { projectPath: string; }; +type GitConfigScopeOption = '--local' | '--worktree'; + const fail = (reason: string, message: string): FailedInstallResult => ({ status: 'failed', reason, @@ -111,9 +113,78 @@ const gitFailure = ( ); }; +const resolveHooksPathScope = ( + cwd: string, +): GitConfigScopeOption | FailedInstallResult => { + const configured = runGit(cwd, [ + 'config', + '--show-scope', + '--get', + 'core.hooksPath', + ]); + if (configured.error || configured.status === null) { + return gitFailure(configured.error, configured.stderr); + } + + // Exit status 1 means core.hooksPath is not configured yet. + if (configured.status === 1) { + return '--local'; + } + if (configured.status !== 0) { + return fail( + 'git-config-failed', + `Failed to resolve the core.hooksPath scope: ${configured.stderr.trim()}`, + ); + } + + const separator = configured.stdout.indexOf('\t'); + const scope = separator === -1 ? '' : configured.stdout.slice(0, separator); + if (scope === 'worktree') { + return '--worktree'; + } + if (scope === 'command') { + return fail( + 'hooks-path-command-scope', + "Cannot configure core.hooksPath because it is set in Git's command scope. Remove the command-scoped override and rerun rs setup.", + ); + } + if (scope === 'system' || scope === 'global' || scope === 'local') { + return '--local'; + } + + return fail( + 'git-config-failed', + 'Failed to resolve the core.hooksPath scope.', + ); +}; + +const resolveGitHooksPath = (cwd: string): string | FailedInstallResult => { + const hooksDirectory = runGit(cwd, [ + 'rev-parse', + '--path-format=absolute', + '--git-path', + 'hooks', + ]); + if (hooksDirectory.error || hooksDirectory.status === null) { + return gitFailure(hooksDirectory.error, hooksDirectory.stderr); + } + if (hooksDirectory.status !== 0) { + return fail( + 'git-command-failed', + `Failed to resolve the Git hooks path: ${hooksDirectory.stderr.trim()}`, + ); + } + + const resolvedDirectory = removeLineEnding(hooksDirectory.stdout); + if (!resolvedDirectory) { + return fail('git-command-failed', 'Failed to resolve the Git hooks path.'); + } + return resolvedDirectory; +}; + const resolveGitContext = (cwd: string): GitContext | InstallResult => { // Resolve every repository path in one Git process. `--git-path hooks` - // accounts for an existing local or global core.hooksPath configuration. + // accounts for the effective core.hooksPath configuration across Git scopes. const repository = runGit(cwd, [ 'rev-parse', '--is-inside-work-tree', @@ -330,6 +401,12 @@ export const installHooks = ({ } } + // Preserve a worktree-scoped override instead of writing a shadowed local value. + const configScope = hooksPathMatches ? '--local' : resolveHooksPathScope(cwd); + if (typeof configScope !== 'string') { + return configScope; + } + const files = Object.entries(createHookFiles()); try { mkdirSync(directory, { recursive: true }); @@ -370,7 +447,7 @@ export const installHooks = ({ // Point Git at the generated directory only after every runtime file is ready. const configured = runGit(cwd, [ 'config', - '--local', + configScope, 'core.hooksPath', hooksPath, ]); @@ -384,6 +461,17 @@ export const installHooks = ({ ); } + const configuredHooksPath = resolveGitHooksPath(cwd); + if (typeof configuredHooksPath !== 'string') { + return configuredHooksPath; + } + if (!isSamePath(configuredHooksPath, directory)) { + return fail( + 'git-config-failed', + `Failed to activate Rstack Git hooks: core.hooksPath resolves to "${displayPath(gitRoot, configuredHooksPath)}" instead of "${hooksPath}".`, + ); + } + return { status: 'installed', hooksPath, diff --git a/packages/rstack/tests/setup/install.test.ts b/packages/rstack/tests/setup/install.test.ts index 4a32fa8..3418303 100644 --- a/packages/rstack/tests/setup/install.test.ts +++ b/packages/rstack/tests/setup/install.test.ts @@ -170,6 +170,56 @@ test('requires force to replace another Git hooks path', () => { }); }); +test('replaces a worktree-scoped hooks path at the same scope', () => { + withRepository((cwd) => { + runGit(cwd, ['config', '--local', 'extensions.worktreeConfig', 'true']); + runGit(cwd, ['config', '--worktree', 'core.hooksPath', '.husky/_']); + + expect(installHooks({ cwd, force: true }).status).toBe('installed'); + expect( + runGit(cwd, ['config', '--show-scope', '--get', 'core.hooksPath']), + ).toBe(`worktree\t${hooksPath}`); + expect( + git(cwd, ['config', '--local', '--get', 'core.hooksPath']).status, + ).toBe(1); + }); +}); + +test('rejects a command-scoped hooks path override', () => { + withRepository((cwd) => { + const originalCount = process.env.GIT_CONFIG_COUNT; + const originalKey = process.env.GIT_CONFIG_KEY_0; + const originalValue = process.env.GIT_CONFIG_VALUE_0; + process.env.GIT_CONFIG_COUNT = '1'; + process.env.GIT_CONFIG_KEY_0 = 'core.hooksPath'; + process.env.GIT_CONFIG_VALUE_0 = '.husky/_'; + + try { + expect(installHooks({ cwd, force: true })).toMatchObject({ + status: 'failed', + reason: 'hooks-path-command-scope', + }); + } finally { + restoreEnv('GIT_CONFIG_COUNT', originalCount); + restoreEnv('GIT_CONFIG_KEY_0', originalKey); + restoreEnv('GIT_CONFIG_VALUE_0', originalValue); + } + }); +}); + +test('verifies the effective hooks path after configuring Git', () => { + withRepository((cwd) => { + const includedConfig = path.join(cwd, 'included.gitconfig'); + writeFileSync(includedConfig, '[core]\n\thooksPath = .husky/_\n'); + runGit(cwd, ['config', '--local', 'include.path', includedConfig]); + + expect(installHooks({ cwd, force: true })).toMatchObject({ + status: 'failed', + reason: 'git-config-failed', + }); + }); +}); + test('requires force to bypass existing Git hooks', () => { withRepository((cwd) => { const existingHook = path.join(cwd, '.git', 'hooks', 'pre-commit');