Skip to content

Commit bccf6b4

Browse files
authored
fix(rstack): honor worktree-scoped hooks path (#400)
1 parent c5f19e7 commit bccf6b4

2 files changed

Lines changed: 140 additions & 2 deletions

File tree

packages/rstack/src/setup/install.ts

Lines changed: 90 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ type GitContext = {
5252
projectPath: string;
5353
};
5454

55+
type GitConfigScopeOption = '--local' | '--worktree';
56+
5557
const fail = (reason: string, message: string): FailedInstallResult => ({
5658
status: 'failed',
5759
reason,
@@ -111,9 +113,78 @@ const gitFailure = (
111113
);
112114
};
113115

116+
const resolveHooksPathScope = (
117+
cwd: string,
118+
): GitConfigScopeOption | FailedInstallResult => {
119+
const configured = runGit(cwd, [
120+
'config',
121+
'--show-scope',
122+
'--get',
123+
'core.hooksPath',
124+
]);
125+
if (configured.error || configured.status === null) {
126+
return gitFailure(configured.error, configured.stderr);
127+
}
128+
129+
// Exit status 1 means core.hooksPath is not configured yet.
130+
if (configured.status === 1) {
131+
return '--local';
132+
}
133+
if (configured.status !== 0) {
134+
return fail(
135+
'git-config-failed',
136+
`Failed to resolve the core.hooksPath scope: ${configured.stderr.trim()}`,
137+
);
138+
}
139+
140+
const separator = configured.stdout.indexOf('\t');
141+
const scope = separator === -1 ? '' : configured.stdout.slice(0, separator);
142+
if (scope === 'worktree') {
143+
return '--worktree';
144+
}
145+
if (scope === 'command') {
146+
return fail(
147+
'hooks-path-command-scope',
148+
"Cannot configure core.hooksPath because it is set in Git's command scope. Remove the command-scoped override and rerun rs setup.",
149+
);
150+
}
151+
if (scope === 'system' || scope === 'global' || scope === 'local') {
152+
return '--local';
153+
}
154+
155+
return fail(
156+
'git-config-failed',
157+
'Failed to resolve the core.hooksPath scope.',
158+
);
159+
};
160+
161+
const resolveGitHooksPath = (cwd: string): string | FailedInstallResult => {
162+
const hooksDirectory = runGit(cwd, [
163+
'rev-parse',
164+
'--path-format=absolute',
165+
'--git-path',
166+
'hooks',
167+
]);
168+
if (hooksDirectory.error || hooksDirectory.status === null) {
169+
return gitFailure(hooksDirectory.error, hooksDirectory.stderr);
170+
}
171+
if (hooksDirectory.status !== 0) {
172+
return fail(
173+
'git-command-failed',
174+
`Failed to resolve the Git hooks path: ${hooksDirectory.stderr.trim()}`,
175+
);
176+
}
177+
178+
const resolvedDirectory = removeLineEnding(hooksDirectory.stdout);
179+
if (!resolvedDirectory) {
180+
return fail('git-command-failed', 'Failed to resolve the Git hooks path.');
181+
}
182+
return resolvedDirectory;
183+
};
184+
114185
const resolveGitContext = (cwd: string): GitContext | InstallResult => {
115186
// Resolve every repository path in one Git process. `--git-path hooks`
116-
// accounts for an existing local or global core.hooksPath configuration.
187+
// accounts for the effective core.hooksPath configuration across Git scopes.
117188
const repository = runGit(cwd, [
118189
'rev-parse',
119190
'--is-inside-work-tree',
@@ -330,6 +401,12 @@ export const installHooks = ({
330401
}
331402
}
332403

404+
// Preserve a worktree-scoped override instead of writing a shadowed local value.
405+
const configScope = hooksPathMatches ? '--local' : resolveHooksPathScope(cwd);
406+
if (typeof configScope !== 'string') {
407+
return configScope;
408+
}
409+
333410
const files = Object.entries(createHookFiles());
334411
try {
335412
mkdirSync(directory, { recursive: true });
@@ -370,7 +447,7 @@ export const installHooks = ({
370447
// Point Git at the generated directory only after every runtime file is ready.
371448
const configured = runGit(cwd, [
372449
'config',
373-
'--local',
450+
configScope,
374451
'core.hooksPath',
375452
hooksPath,
376453
]);
@@ -384,6 +461,17 @@ export const installHooks = ({
384461
);
385462
}
386463

464+
const configuredHooksPath = resolveGitHooksPath(cwd);
465+
if (typeof configuredHooksPath !== 'string') {
466+
return configuredHooksPath;
467+
}
468+
if (!isSamePath(configuredHooksPath, directory)) {
469+
return fail(
470+
'git-config-failed',
471+
`Failed to activate Rstack Git hooks: core.hooksPath resolves to "${displayPath(gitRoot, configuredHooksPath)}" instead of "${hooksPath}".`,
472+
);
473+
}
474+
387475
return {
388476
status: 'installed',
389477
hooksPath,

packages/rstack/tests/setup/install.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,56 @@ test('requires force to replace another Git hooks path', () => {
170170
});
171171
});
172172

173+
test('replaces a worktree-scoped hooks path at the same scope', () => {
174+
withRepository((cwd) => {
175+
runGit(cwd, ['config', '--local', 'extensions.worktreeConfig', 'true']);
176+
runGit(cwd, ['config', '--worktree', 'core.hooksPath', '.husky/_']);
177+
178+
expect(installHooks({ cwd, force: true }).status).toBe('installed');
179+
expect(
180+
runGit(cwd, ['config', '--show-scope', '--get', 'core.hooksPath']),
181+
).toBe(`worktree\t${hooksPath}`);
182+
expect(
183+
git(cwd, ['config', '--local', '--get', 'core.hooksPath']).status,
184+
).toBe(1);
185+
});
186+
});
187+
188+
test('rejects a command-scoped hooks path override', () => {
189+
withRepository((cwd) => {
190+
const originalCount = process.env.GIT_CONFIG_COUNT;
191+
const originalKey = process.env.GIT_CONFIG_KEY_0;
192+
const originalValue = process.env.GIT_CONFIG_VALUE_0;
193+
process.env.GIT_CONFIG_COUNT = '1';
194+
process.env.GIT_CONFIG_KEY_0 = 'core.hooksPath';
195+
process.env.GIT_CONFIG_VALUE_0 = '.husky/_';
196+
197+
try {
198+
expect(installHooks({ cwd, force: true })).toMatchObject({
199+
status: 'failed',
200+
reason: 'hooks-path-command-scope',
201+
});
202+
} finally {
203+
restoreEnv('GIT_CONFIG_COUNT', originalCount);
204+
restoreEnv('GIT_CONFIG_KEY_0', originalKey);
205+
restoreEnv('GIT_CONFIG_VALUE_0', originalValue);
206+
}
207+
});
208+
});
209+
210+
test('verifies the effective hooks path after configuring Git', () => {
211+
withRepository((cwd) => {
212+
const includedConfig = path.join(cwd, 'included.gitconfig');
213+
writeFileSync(includedConfig, '[core]\n\thooksPath = .husky/_\n');
214+
runGit(cwd, ['config', '--local', 'include.path', includedConfig]);
215+
216+
expect(installHooks({ cwd, force: true })).toMatchObject({
217+
status: 'failed',
218+
reason: 'git-config-failed',
219+
});
220+
});
221+
});
222+
173223
test('requires force to bypass existing Git hooks', () => {
174224
withRepository((cwd) => {
175225
const existingHook = path.join(cwd, '.git', 'hooks', 'pre-commit');

0 commit comments

Comments
 (0)