Skip to content

Commit 16f8bd1

Browse files
committed
feat(rstack): add hooks command
1 parent 72180e8 commit 16f8bd1

9 files changed

Lines changed: 290 additions & 215 deletions

File tree

packages/rstack/src/cli/commandHelp.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ export type HelpTopic =
4545
| 'lint'
4646
| 'fmt'
4747
| 'staged'
48+
| 'hooks'
4849
| 'setup';
4950

5051
const CONFIG_OPTION: HelpItem = [
@@ -132,7 +133,7 @@ const HELP_DEFINITIONS = {
132133
['check', 'Run static checks, including lint and format'],
133134
['test', 'Run tests'],
134135
['staged', 'Run tasks on staged Git files'],
135-
['setup', 'Install Git hooks'],
136+
['hooks', 'Install Git hooks'],
136137
],
137138
},
138139
{
@@ -491,6 +492,23 @@ const HELP_DEFINITIONS = {
491492
},
492493
],
493494
},
495+
hooks: {
496+
usage: 'rs hooks [options]',
497+
description: 'Install Git hooks in the current repository',
498+
sections: [
499+
{
500+
title: 'Options',
501+
items: [
502+
['-f, --force', 'Install despite an existing Git hooks setup'],
503+
[
504+
'--hooks-dir <path>',
505+
'Specify hooks directory relative to the Git repository root',
506+
],
507+
HELP_OPTION,
508+
],
509+
},
510+
],
511+
},
494512
setup: {
495513
usage: 'rs setup [options]',
496514
description: 'Install Git hooks in the current repository',

packages/rstack/src/cli/commands.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -252,12 +252,12 @@ export async function setupCommands(): Promise<void> {
252252
return;
253253
}
254254

255-
if (command === 'setup') {
256-
const { runSetupCLI } = await import(
257-
/* rspackChunkName: 'setup' */
255+
if (command === 'hooks' || command === 'setup') {
256+
const { runHooksCLI } = await import(
257+
/* rspackChunkName: 'hooks' */
258258
'../setup/index.ts'
259259
);
260-
await runSetupCLI(args.slice(1));
260+
await runHooksCLI(args.slice(1), command);
261261
return;
262262
}
263263

packages/rstack/src/setup/hooks.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ rs_init="\${XDG_CONFIG_HOME:-$HOME/.config}/rstack/hooks-init.sh"
5050
IFS= read -r rs_project_path < "$rs_dir/.owner" || exit 1
5151
[ -n "$rs_project_path" ] || exit 1
5252
53-
# Fall back to the Node.js executable that ran rs setup when GUI clients omit
53+
# Fall back to the Node.js executable that ran rs hooks when GUI clients omit
5454
# it from PATH. Keep an existing Node.js environment ahead of this fallback.
5555
rs_node_fallback=${quoteShellPath(nodeExecutable)}
5656
if ! command -v node >/dev/null 2>&1 && [ -x "$rs_node_fallback" ]; then

packages/rstack/src/setup/index.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@ import { parseArgs } from '../cli/args.ts';
33
import { printCommandHelp } from '../cli/help.ts';
44
import { installHooks } from './install.ts';
55

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

2730
if (values.help) {
28-
await printCommandHelp('setup');
31+
await printCommandHelp(command);
2932
return;
3033
}
3134

@@ -68,7 +71,7 @@ export const runSetupCLI = async (args: string[]): Promise<void> => {
6871
result.reason === 'hooks-path-conflict'
6972
) {
7073
logger.info(
71-
`To continue, run ${color.yellow('rs setup --force')}. Existing hook files will be preserved but become inactive.`,
74+
`To continue, run ${color.yellow('rs hooks --force')}. Existing hook files will be preserved but become inactive.`,
7275
);
7376
}
7477
return;

packages/rstack/src/setup/install.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ const resolveHooksPathScope = (
145145
if (scope === 'command') {
146146
return fail(
147147
'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.",
148+
"Cannot configure core.hooksPath because it is set in Git's command scope. Remove the command-scoped override and rerun rs hooks.",
149149
);
150150
}
151151
if (scope === 'system' || scope === 'global' || scope === 'local') {

packages/rstack/tests/cli/__snapshots__/help.test.ts.snap

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -352,7 +352,7 @@ Commands:
352352
check Run static checks, including lint and format
353353
test Run tests
354354
staged Run tasks on staged Git files
355-
setup Install Git hooks
355+
hooks Install Git hooks
356356
357357
For command-specific options, run:
358358
$ rs <command> -h

packages/rstack/tests/cli/setup/__snapshots__/index.test.ts.snap renamed to packages/rstack/tests/cli/hooks/__snapshots__/index.test.ts.snap

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
// Rstest Snapshot v1
22

3-
exports[`displays setup help 1`] = `
3+
exports[`displays hooks help without installing hooks 1`] = `
44
"Rstack v<version>
55
66
Usage:
7-
$ rs setup [options]
7+
$ rs hooks [options]
88
99
Install Git hooks in the current repository
1010
Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
import { spawnSync } from 'node:child_process';
2+
import {
3+
chmodSync,
4+
existsSync,
5+
mkdirSync,
6+
mkdtempSync,
7+
rmSync,
8+
writeFileSync,
9+
} from 'node:fs';
10+
import path from 'node:path';
11+
import { afterEach, beforeEach } from 'rstack/test';
12+
import { normalizeHelpOutput, RSTACK_BIN_PATH, test } from '#test-helpers';
13+
14+
const hooksPath = '.rstack/hooks/_';
15+
16+
let cwd: string;
17+
let env: NodeJS.ProcessEnv;
18+
19+
const git = (args: string[]): string => {
20+
const result = spawnSync('git', args, { cwd, encoding: 'utf8', env });
21+
if (result.status !== 0) {
22+
throw new Error(result.stderr || `Git exited with status ${result.status}`);
23+
}
24+
return result.stdout.trim();
25+
};
26+
27+
const initRepository = (): void => {
28+
git(['init', '--quiet']);
29+
};
30+
31+
const runHooks = (args: string[], runCwd: string = cwd) =>
32+
spawnSync(process.execPath, [RSTACK_BIN_PATH, 'hooks', ...args], {
33+
cwd: runCwd,
34+
encoding: 'utf8',
35+
env,
36+
});
37+
38+
const runHooksSuccessfully = (args: string[], runCwd: string = cwd): string => {
39+
const result = runHooks(args, runCwd);
40+
if (result.status !== 0) {
41+
throw new Error(
42+
result.stderr || result.error?.message || `Exited with ${result.status}`,
43+
);
44+
}
45+
return `${result.stdout}${result.stderr}`;
46+
};
47+
48+
beforeEach(() => {
49+
cwd = mkdtempSync(path.join(import.meta.dirname, 'test-temp-rstack hooks '));
50+
env = {
51+
...process.env,
52+
// Keep Git from treating the fixture as part of this repository.
53+
GIT_CEILING_DIRECTORIES: import.meta.dirname,
54+
GIT_CONFIG_GLOBAL: path.join(cwd, 'global.gitconfig'),
55+
GIT_CONFIG_NOSYSTEM: '1',
56+
};
57+
});
58+
59+
afterEach(() => {
60+
rmSync(cwd, { force: true, recursive: true });
61+
});
62+
63+
test('displays hooks help without installing hooks', ({ execCli, expect }) => {
64+
initRepository();
65+
const output = execCli('hooks --help', { cwd, env });
66+
67+
expect(execCli('hooks -h', { cwd, env })).toBe(output);
68+
expect(normalizeHelpOutput(output)).toMatchSnapshot();
69+
expect(existsSync(path.join(cwd, '.rstack'))).toBe(false);
70+
});
71+
72+
test('rejects unknown hooks positionals and options', ({ execCli, expect }) => {
73+
expect(() => execCli('hooks install', { cwd })).toThrow();
74+
expect(() => execCli('hooks uninstall', { cwd })).toThrow();
75+
expect(() => execCli('hooks --unknown', { cwd })).toThrow();
76+
expect(() => execCli('hooks --dir custom-hooks', { cwd })).toThrow();
77+
expect(() => execCli('hooks -d custom-hooks', { cwd })).toThrow();
78+
});
79+
80+
test('reports missing and repeated hooks directory options', ({ expect }) => {
81+
const missing = runHooks(['--hooks-dir']);
82+
expect(missing.status).toBe(1);
83+
expect(missing.stderr).toContain('--hooks-dir');
84+
85+
const repeated = runHooks(['--hooks-dir', 'first', '--hooks-dir', 'second']);
86+
expect(repeated.status).toBe(1);
87+
expect(repeated.stderr).toContain(
88+
'The --hooks-dir option cannot be specified more than once.',
89+
);
90+
});
91+
92+
test('rejects invalid hooks directory options', ({ expect }) => {
93+
const empty = runHooks(['--hooks-dir', '']);
94+
expect(empty.status).toBe(1);
95+
expect(empty.stderr).toContain('Git hooks directory must not be empty.');
96+
97+
const absolute = runHooks(['--hooks-dir', path.join(cwd, 'hooks')]);
98+
expect(absolute.status).toBe(1);
99+
expect(absolute.stderr).toContain(
100+
'Git hooks directory must be relative to the Git repository root.',
101+
);
102+
103+
const parent = runHooks(['--hooks-dir', '../hooks']);
104+
expect(parent.status).toBe(1);
105+
expect(parent.stderr).toContain('Git hooks directory must not contain "..".');
106+
});
107+
108+
test('installs hooks silently without loading Rstack config', ({
109+
execCli,
110+
expect,
111+
}) => {
112+
initRepository();
113+
writeFileSync(
114+
path.join(cwd, 'rstack.config.ts'),
115+
'throw new Error("must not load");\n',
116+
);
117+
118+
expect(execCli('hooks', { cwd, env })).toBe('');
119+
expect(git(['config', '--local', '--get', 'core.hooksPath'])).toBe(hooksPath);
120+
expect(existsSync(path.join(cwd, hooksPath, 'runner'))).toBe(true);
121+
expect(existsSync(path.join(cwd, '.rstack', 'hooks', 'pre-commit'))).toBe(
122+
false,
123+
);
124+
125+
expect(execCli('hooks', { cwd, env })).toBe('');
126+
});
127+
128+
test('guides and forces installation while preserving existing hooks', ({
129+
expect,
130+
}) => {
131+
initRepository();
132+
const existingHook = path.join(cwd, '.git', 'hooks', 'pre-commit');
133+
writeFileSync(
134+
existingHook,
135+
"#!/usr/bin/env sh\nprintf 'ran\\n' > old-hook-ran\n",
136+
);
137+
chmodSync(existingHook, 0o755);
138+
139+
const skippedOutput = runHooksSuccessfully([]);
140+
expect(skippedOutput).toContain(
141+
'Git hooks setup skipped: existing Git hooks were found: pre-commit.',
142+
);
143+
expect(skippedOutput).toContain(
144+
'To continue, run rs hooks --force. Existing hook files will be preserved but become inactive.',
145+
);
146+
147+
const forcedOutput = runHooksSuccessfully(['--force']);
148+
expect(forcedOutput).toContain(
149+
'info Rstack now manages Git hooks at ".rstack/hooks/_".',
150+
);
151+
expect(forcedOutput).toContain(
152+
'Existing hooks in ".git/hooks" were preserved but will no longer run: pre-commit.',
153+
);
154+
expect(forcedOutput).toContain('Unset core.hooksPath to restore them.');
155+
expect(git(['config', '--local', '--get', 'core.hooksPath'])).toBe(hooksPath);
156+
157+
git(['hook', 'run', 'pre-commit']);
158+
expect(existsSync(path.join(cwd, 'old-hook-ran'))).toBe(false);
159+
160+
git(['config', '--local', '--unset', 'core.hooksPath']);
161+
git(['hook', 'run', 'pre-commit']);
162+
expect(existsSync(path.join(cwd, 'old-hook-ran'))).toBe(true);
163+
164+
expect(runHooksSuccessfully(['-f'])).toContain(
165+
'Existing hooks in ".git/hooks" were preserved but will no longer run: pre-commit.',
166+
);
167+
});
168+
169+
test('reports how to restore a replaced hooks path', ({ expect }) => {
170+
initRepository();
171+
const existingDirectory = path.join(cwd, '.husky', '_');
172+
mkdirSync(existingDirectory, { recursive: true });
173+
writeFileSync(
174+
path.join(existingDirectory, 'pre-commit'),
175+
'#!/usr/bin/env sh\n',
176+
);
177+
git(['config', '--local', 'core.hooksPath', '.husky/_']);
178+
179+
const output = runHooksSuccessfully(['--force']);
180+
expect(output).toContain(
181+
'info Rstack now manages Git hooks at ".rstack/hooks/_".',
182+
);
183+
expect(output).toContain(
184+
'Existing hooks in ".husky/_" were preserved but will no longer run: pre-commit.',
185+
);
186+
expect(output).toContain(
187+
'Set core.hooksPath back to ".husky/_" to restore them.',
188+
);
189+
});
190+
191+
test('installs root-relative hooks and reports owner conflicts', ({
192+
execCli,
193+
expect,
194+
}) => {
195+
initRepository();
196+
const frontend = path.join(cwd, 'frontend');
197+
const docs = path.join(cwd, 'docs');
198+
mkdirSync(frontend);
199+
mkdirSync(docs);
200+
201+
expect(
202+
execCli('hooks --hooks-dir "custom hooks"', { cwd: frontend, env }),
203+
).toBe('');
204+
expect(git(['config', '--local', '--get', 'core.hooksPath'])).toBe(
205+
'custom hooks/_',
206+
);
207+
expect(existsSync(path.join(cwd, 'custom hooks', '_', 'runner'))).toBe(true);
208+
209+
expect(runHooksSuccessfully(['--hooks-dir', 'custom hooks'], docs)).toContain(
210+
'Git hooks are already managed by Rstack project "frontend"',
211+
);
212+
});
213+
214+
test('skips non-Git directories without creating files', ({
215+
execCli,
216+
expect,
217+
}) => {
218+
expect(execCli('hooks', { cwd, env })).toContain(
219+
'info Git hooks setup skipped: not a Git repository.',
220+
);
221+
expect(existsSync(path.join(cwd, '.rstack'))).toBe(false);
222+
});
223+
224+
test('skips installation when hooks are disabled', ({ execCli, expect }) => {
225+
const output = execCli('hooks', {
226+
cwd,
227+
env: { ...env, RSTACK_HOOKS: '0' },
228+
});
229+
230+
expect(output).toContain(
231+
'info Git hooks setup skipped: disabled by RSTACK_HOOKS.',
232+
);
233+
expect(existsSync(path.join(cwd, '.rstack'))).toBe(false);
234+
});
235+
236+
test('exits with an error when Git is unavailable', ({ expect }) => {
237+
const result = spawnSync(process.execPath, [RSTACK_BIN_PATH, 'hooks'], {
238+
cwd,
239+
encoding: 'utf8',
240+
env: { ...env, PATH: '', Path: '' },
241+
});
242+
243+
expect(result.status).toBe(1);
244+
expect(result.stderr).toContain('Git command not found.');
245+
});

0 commit comments

Comments
 (0)