Skip to content
Merged
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
92 changes: 90 additions & 2 deletions packages/rstack/src/setup/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ type GitContext = {
projectPath: string;
};

type GitConfigScopeOption = '--local' | '--worktree';

const fail = (reason: string, message: string): FailedInstallResult => ({
status: 'failed',
reason,
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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,
]);
Expand All @@ -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,
Expand Down
50 changes: 50 additions & 0 deletions packages/rstack/tests/setup/install.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down