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
20 changes: 19 additions & 1 deletion packages/rstack/src/cli/commandHelp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export type HelpTopic =
| 'lint'
| 'fmt'
| 'staged'
| 'hooks'
| 'setup';

const CONFIG_OPTION: HelpItem = [
Expand Down Expand Up @@ -132,7 +133,7 @@ const HELP_DEFINITIONS = {
['check', 'Run static checks, including lint and format'],
['test', 'Run tests'],
['staged', 'Run tasks on staged Git files'],
['setup', 'Install Git hooks'],
['hooks', 'Install Git hooks'],
Comment thread
chenjiahan marked this conversation as resolved.
],
},
{
Expand Down Expand Up @@ -491,6 +492,23 @@ const HELP_DEFINITIONS = {
},
],
},
hooks: {
usage: 'rs hooks [options]',
description: 'Install Git hooks in the current repository',
sections: [
{
title: 'Options',
items: [
['-f, --force', 'Install despite an existing Git hooks setup'],
[
'--hooks-dir <path>',
'Specify hooks directory relative to the Git repository root',
],
HELP_OPTION,
],
},
],
},
setup: {
usage: 'rs setup [options]',
description: 'Install Git hooks in the current repository',
Expand Down
8 changes: 4 additions & 4 deletions packages/rstack/src/cli/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,12 +252,12 @@ export async function setupCommands(): Promise<void> {
return;
}

if (command === 'setup') {
const { runSetupCLI } = await import(
/* rspackChunkName: 'setup' */
if (command === 'hooks' || command === 'setup') {
const { runHooksCLI } = await import(
/* rspackChunkName: 'hooks' */
'../setup/index.ts'
);
await runSetupCLI(args.slice(1));
await runHooksCLI(args.slice(1), command);
return;
}

Expand Down
2 changes: 1 addition & 1 deletion packages/rstack/src/setup/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ rs_init="\${XDG_CONFIG_HOME:-$HOME/.config}/rstack/hooks-init.sh"
IFS= read -r rs_project_path < "$rs_dir/.owner" || exit 1
[ -n "$rs_project_path" ] || exit 1

# Fall back to the Node.js executable that ran rs setup when GUI clients omit
# Fall back to the Node.js executable that ran rs hooks when GUI clients omit
# it from PATH. Keep an existing Node.js environment ahead of this fallback.
rs_node_fallback=${quoteShellPath(nodeExecutable)}
if ! command -v node >/dev/null 2>&1 && [ -x "$rs_node_fallback" ]; then
Expand Down
9 changes: 6 additions & 3 deletions packages/rstack/src/setup/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ import { parseArgs } from '../cli/args.ts';
import { printCommandHelp } from '../cli/help.ts';
import { installHooks } from './install.ts';

export const runSetupCLI = async (args: string[]): Promise<void> => {
export const runHooksCLI = async (
args: string[],
command: 'hooks' | 'setup' = 'hooks',
): Promise<void> => {
const { values } = parseArgs({
args,
options: {
Expand All @@ -25,7 +28,7 @@ export const runSetupCLI = async (args: string[]): Promise<void> => {
const hooksDir = hooksDirs?.[0];

if (values.help) {
await printCommandHelp('setup');
await printCommandHelp(command);
return;
}

Expand Down Expand Up @@ -68,7 +71,7 @@ export const runSetupCLI = async (args: string[]): Promise<void> => {
result.reason === 'hooks-path-conflict'
) {
logger.info(
`To continue, run ${color.yellow('rs setup --force')}. Existing hook files will be preserved but become inactive.`,
`To continue, run ${color.yellow('rs hooks --force')}. Existing hook files will be preserved but become inactive.`,
);
}
return;
Expand Down
2 changes: 1 addition & 1 deletion packages/rstack/src/setup/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ const resolveHooksPathScope = (
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.",
"Cannot configure core.hooksPath because it is set in Git's command scope. Remove the command-scoped override and rerun rs hooks.",
);
}
if (scope === 'system' || scope === 'global' || scope === 'local') {
Expand Down
2 changes: 1 addition & 1 deletion packages/rstack/tests/cli/__snapshots__/help.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -352,7 +352,7 @@ Commands:
check Run static checks, including lint and format
test Run tests
staged Run tasks on staged Git files
setup Install Git hooks
hooks Install Git hooks

For command-specific options, run:
$ rs <command> -h
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
// Rstest Snapshot v1

exports[`displays setup help 1`] = `
exports[`displays hooks help without installing hooks 1`] = `
"Rstack v<version>

Usage:
$ rs setup [options]
$ rs hooks [options]

Install Git hooks in the current repository

Expand Down
245 changes: 245 additions & 0 deletions packages/rstack/tests/cli/hooks/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
import { spawnSync } from 'node:child_process';
import {
chmodSync,
existsSync,
mkdirSync,
mkdtempSync,
rmSync,
writeFileSync,
} from 'node:fs';
import path from 'node:path';
import { afterEach, beforeEach } from 'rstack/test';
import { normalizeHelpOutput, RSTACK_BIN_PATH, test } from '#test-helpers';

const hooksPath = '.rstack/hooks/_';

let cwd: string;
let env: NodeJS.ProcessEnv;

const git = (args: string[]): string => {
const result = spawnSync('git', args, { cwd, encoding: 'utf8', env });
if (result.status !== 0) {
throw new Error(result.stderr || `Git exited with status ${result.status}`);
}
return result.stdout.trim();
};

const initRepository = (): void => {
git(['init', '--quiet']);
};

const runHooks = (args: string[], runCwd: string = cwd) =>
spawnSync(process.execPath, [RSTACK_BIN_PATH, 'hooks', ...args], {
cwd: runCwd,
encoding: 'utf8',
env,
});

const runHooksSuccessfully = (args: string[], runCwd: string = cwd): string => {
const result = runHooks(args, runCwd);
if (result.status !== 0) {
throw new Error(
result.stderr || result.error?.message || `Exited with ${result.status}`,
);
}
return `${result.stdout}${result.stderr}`;
};

beforeEach(() => {
cwd = mkdtempSync(path.join(import.meta.dirname, 'test-temp-rstack hooks '));
env = {
...process.env,
// Keep Git from treating the fixture as part of this repository.
GIT_CEILING_DIRECTORIES: import.meta.dirname,
GIT_CONFIG_GLOBAL: path.join(cwd, 'global.gitconfig'),
GIT_CONFIG_NOSYSTEM: '1',
};
});

afterEach(() => {
rmSync(cwd, { force: true, recursive: true });
});

test('displays hooks help without installing hooks', ({ execCli, expect }) => {
initRepository();
const output = execCli('hooks --help', { cwd, env });

expect(execCli('hooks -h', { cwd, env })).toBe(output);
expect(normalizeHelpOutput(output)).toMatchSnapshot();
expect(existsSync(path.join(cwd, '.rstack'))).toBe(false);
});

test('rejects unknown hooks positionals and options', ({ execCli, expect }) => {
expect(() => execCli('hooks install', { cwd })).toThrow();
expect(() => execCli('hooks uninstall', { cwd })).toThrow();
expect(() => execCli('hooks --unknown', { cwd })).toThrow();
expect(() => execCli('hooks --dir custom-hooks', { cwd })).toThrow();
expect(() => execCli('hooks -d custom-hooks', { cwd })).toThrow();
});

test('reports missing and repeated hooks directory options', ({ expect }) => {
const missing = runHooks(['--hooks-dir']);
expect(missing.status).toBe(1);
expect(missing.stderr).toContain('--hooks-dir');

const repeated = runHooks(['--hooks-dir', 'first', '--hooks-dir', 'second']);
expect(repeated.status).toBe(1);
expect(repeated.stderr).toContain(
'The --hooks-dir option cannot be specified more than once.',
);
});

test('rejects invalid hooks directory options', ({ expect }) => {
const empty = runHooks(['--hooks-dir', '']);
expect(empty.status).toBe(1);
expect(empty.stderr).toContain('Git hooks directory must not be empty.');

const absolute = runHooks(['--hooks-dir', path.join(cwd, 'hooks')]);
expect(absolute.status).toBe(1);
expect(absolute.stderr).toContain(
'Git hooks directory must be relative to the Git repository root.',
);

const parent = runHooks(['--hooks-dir', '../hooks']);
expect(parent.status).toBe(1);
expect(parent.stderr).toContain('Git hooks directory must not contain "..".');
});

test('installs hooks silently without loading Rstack config', ({
execCli,
expect,
}) => {
initRepository();
writeFileSync(
path.join(cwd, 'rstack.config.ts'),
'throw new Error("must not load");\n',
);

expect(execCli('hooks', { cwd, env })).toBe('');
expect(git(['config', '--local', '--get', 'core.hooksPath'])).toBe(hooksPath);
expect(existsSync(path.join(cwd, hooksPath, 'runner'))).toBe(true);
expect(existsSync(path.join(cwd, '.rstack', 'hooks', 'pre-commit'))).toBe(
false,
);

expect(execCli('hooks', { cwd, env })).toBe('');
});

test('guides and forces installation while preserving existing hooks', ({
expect,
}) => {
initRepository();
const existingHook = path.join(cwd, '.git', 'hooks', 'pre-commit');
writeFileSync(
existingHook,
"#!/usr/bin/env sh\nprintf 'ran\\n' > old-hook-ran\n",
);
chmodSync(existingHook, 0o755);

const skippedOutput = runHooksSuccessfully([]);
expect(skippedOutput).toContain(
'Git hooks setup skipped: existing Git hooks were found: pre-commit.',
);
expect(skippedOutput).toContain(
'To continue, run rs hooks --force. Existing hook files will be preserved but become inactive.',
);

const forcedOutput = runHooksSuccessfully(['--force']);
expect(forcedOutput).toContain(
'info Rstack now manages Git hooks at ".rstack/hooks/_".',
);
expect(forcedOutput).toContain(
'Existing hooks in ".git/hooks" were preserved but will no longer run: pre-commit.',
);
expect(forcedOutput).toContain('Unset core.hooksPath to restore them.');
expect(git(['config', '--local', '--get', 'core.hooksPath'])).toBe(hooksPath);

git(['hook', 'run', 'pre-commit']);
expect(existsSync(path.join(cwd, 'old-hook-ran'))).toBe(false);

git(['config', '--local', '--unset', 'core.hooksPath']);
git(['hook', 'run', 'pre-commit']);
expect(existsSync(path.join(cwd, 'old-hook-ran'))).toBe(true);

expect(runHooksSuccessfully(['-f'])).toContain(
'Existing hooks in ".git/hooks" were preserved but will no longer run: pre-commit.',
);
});

test('reports how to restore a replaced hooks path', ({ expect }) => {
initRepository();
const existingDirectory = path.join(cwd, '.husky', '_');
mkdirSync(existingDirectory, { recursive: true });
writeFileSync(
path.join(existingDirectory, 'pre-commit'),
'#!/usr/bin/env sh\n',
);
git(['config', '--local', 'core.hooksPath', '.husky/_']);

const output = runHooksSuccessfully(['--force']);
expect(output).toContain(
'info Rstack now manages Git hooks at ".rstack/hooks/_".',
);
expect(output).toContain(
'Existing hooks in ".husky/_" were preserved but will no longer run: pre-commit.',
);
expect(output).toContain(
'Set core.hooksPath back to ".husky/_" to restore them.',
);
});

test('installs root-relative hooks and reports owner conflicts', ({
execCli,
expect,
}) => {
initRepository();
const frontend = path.join(cwd, 'frontend');
const docs = path.join(cwd, 'docs');
mkdirSync(frontend);
mkdirSync(docs);

expect(
execCli('hooks --hooks-dir "custom hooks"', { cwd: frontend, env }),
).toBe('');
expect(git(['config', '--local', '--get', 'core.hooksPath'])).toBe(
'custom hooks/_',
);
expect(existsSync(path.join(cwd, 'custom hooks', '_', 'runner'))).toBe(true);

expect(runHooksSuccessfully(['--hooks-dir', 'custom hooks'], docs)).toContain(
'Git hooks are already managed by Rstack project "frontend"',
);
});

test('skips non-Git directories without creating files', ({
execCli,
expect,
}) => {
expect(execCli('hooks', { cwd, env })).toContain(
'info Git hooks setup skipped: not a Git repository.',
);
expect(existsSync(path.join(cwd, '.rstack'))).toBe(false);
});

test('skips installation when hooks are disabled', ({ execCli, expect }) => {
const output = execCli('hooks', {
cwd,
env: { ...env, RSTACK_HOOKS: '0' },
});

expect(output).toContain(
'info Git hooks setup skipped: disabled by RSTACK_HOOKS.',
);
expect(existsSync(path.join(cwd, '.rstack'))).toBe(false);
});

test('exits with an error when Git is unavailable', ({ expect }) => {
const result = spawnSync(process.execPath, [RSTACK_BIN_PATH, 'hooks'], {
cwd,
encoding: 'utf8',
env: { ...env, PATH: '', Path: '' },
});

expect(result.status).toBe(1);
expect(result.stderr).toContain('Git command not found.');
});
Loading