Skip to content

Commit c5f19e7

Browse files
authored
feat(rstack): add force option to setup (#399)
1 parent a80c5e3 commit c5f19e7

6 files changed

Lines changed: 216 additions & 19 deletions

File tree

packages/rstack/src/cli/commandHelp.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -498,6 +498,7 @@ const HELP_DEFINITIONS = {
498498
{
499499
title: 'Options',
500500
items: [
501+
['-f, --force', 'Install despite an existing Git hooks setup'],
501502
[
502503
'--hooks-dir <path>',
503504
'Specify hooks directory relative to the Git repository root',

packages/rstack/src/setup/index.ts

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export const runSetupCLI = async (args: string[]): Promise<void> => {
77
const { values } = parseArgs({
88
args,
99
options: {
10+
force: { type: 'boolean', short: 'f' },
1011
help: { type: 'boolean', short: 'h' },
1112
'hooks-dir': { type: 'string', multiple: true },
1213
},
@@ -28,15 +29,47 @@ export const runSetupCLI = async (args: string[]): Promise<void> => {
2829
return;
2930
}
3031

31-
const result = installHooks({ hooksDir });
32+
const result = installHooks({ force: values.force, hooksDir });
3233

33-
if (result.status === 'installed' || result.status === 'unchanged') {
34+
if (result.status === 'installed') {
35+
// Warn when `--force` preserves an existing hooks setup but makes it inactive.
36+
if (result.inactiveHooks) {
37+
const { hooks, path, restore } = result.inactiveHooks;
38+
const hooksMessage = hooks.length
39+
? `: ${color.yellow(hooks.join(', '))}`
40+
: '';
41+
logger.warn(
42+
`The previous Git hooks path "${color.yellow(path)}" is now inactive${hooksMessage}.`,
43+
);
44+
45+
if (restore === 'unset') {
46+
logger.info(
47+
`The existing files were preserved and will become active again if ${color.yellow('core.hooksPath')} is unset.`,
48+
);
49+
} else {
50+
logger.info(
51+
`The existing files were preserved. Set ${color.yellow('core.hooksPath')} back to this path to use them again.`,
52+
);
53+
}
54+
}
55+
return;
56+
}
57+
58+
if (result.status === 'unchanged') {
3459
return;
3560
}
3661

3762
if (result.status === 'skipped') {
3863
if (result.message) {
3964
logger.warn(`Git hooks setup skipped: ${color.yellow(result.message)}.`);
65+
if (
66+
result.reason === 'existing-git-hooks' ||
67+
result.reason === 'hooks-path-conflict'
68+
) {
69+
logger.info(
70+
`To continue, run ${color.yellow('rs setup --force')}. Existing hook files will be preserved but become inactive.`,
71+
);
72+
}
4073
return;
4174
}
4275

packages/rstack/src/setup/install.ts

Lines changed: 40 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,16 @@ const gitignore = '*\n';
1717

1818
type InstallHooksOptions = {
1919
cwd?: string;
20+
force?: boolean;
2021
hooksDir?: string;
2122
};
2223

24+
type InactiveHooks = {
25+
hooks: string[];
26+
path: string;
27+
restore: 'configure' | 'unset';
28+
};
29+
2330
type FailedInstallResult = {
2431
status: 'failed';
2532
reason: string;
@@ -33,7 +40,7 @@ type SkippedInstallResult = {
3340
};
3441

3542
type InstallResult =
36-
| { status: 'installed'; hooksPath: string }
43+
| { status: 'installed'; hooksPath: string; inactiveHooks?: InactiveHooks }
3744
| { status: 'unchanged'; hooksPath: string }
3845
| SkippedInstallResult
3946
| FailedInstallResult;
@@ -252,6 +259,7 @@ const findExistingHooks = (directory: string): string[] =>
252259

253260
export const installHooks = ({
254261
cwd = process.cwd(),
262+
force = false,
255263
hooksDir = defaultHooksDir,
256264
}: InstallHooksOptions = {}): InstallResult => {
257265
if (process.env.RSTACK_HOOKS === '0') {
@@ -282,27 +290,43 @@ export const installHooks = ({
282290
effectiveHooksDirectory,
283291
defaultHooksDirectory,
284292
);
293+
let inactiveHooks: InactiveHooks | undefined;
285294

286295
if (!hooksPathMatches && !usesDefaultHooks) {
287296
const activeOwner = readOwner(effectiveHooksDirectory);
288297
if (!activeOwner) {
289-
return skip(
290-
'hooks-path-conflict',
291-
`Git hooks are already configured at "${displayPath(gitRoot, effectiveHooksDirectory)}"`,
292-
);
293-
}
294-
if (activeOwner !== projectPath) {
298+
if (!force) {
299+
return skip(
300+
'hooks-path-conflict',
301+
`Git hooks are already configured at "${displayPath(gitRoot, effectiveHooksDirectory)}"`,
302+
);
303+
}
304+
305+
inactiveHooks = {
306+
hooks: findExistingHooks(effectiveHooksDirectory),
307+
path: displayPath(gitRoot, effectiveHooksDirectory),
308+
restore: 'configure',
309+
};
310+
} else if (activeOwner !== projectPath) {
295311
return ownerConflict(activeOwner);
296312
}
297313
}
298314

299315
if (usesDefaultHooks) {
300316
const existingHooks = findExistingHooks(defaultHooksDirectory);
301317
if (existingHooks.length > 0) {
302-
return skip(
303-
'existing-git-hooks',
304-
`existing Git hooks were found: ${existingHooks.join(', ')}`,
305-
);
318+
if (!force) {
319+
return skip(
320+
'existing-git-hooks',
321+
`existing Git hooks were found: ${existingHooks.join(', ')}`,
322+
);
323+
}
324+
325+
inactiveHooks = {
326+
hooks: existingHooks,
327+
path: displayPath(gitRoot, defaultHooksDirectory),
328+
restore: 'unset',
329+
};
306330
}
307331
}
308332

@@ -360,5 +384,9 @@ export const installHooks = ({
360384
);
361385
}
362386

363-
return { status: 'installed', hooksPath };
387+
return {
388+
status: 'installed',
389+
hooksPath,
390+
...(inactiveHooks ? { inactiveHooks } : {}),
391+
};
364392
};

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ Usage:
99
Install Git hooks in the current repository
1010
1111
Options:
12+
-f, --force Install despite an existing Git hooks setup
1213
--hooks-dir <path> Specify hooks directory relative to the Git repository root
1314
-h, --help Display this help message
1415
"

packages/rstack/tests/cli/setup/index.test.ts

Lines changed: 71 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { spawnSync } from 'node:child_process';
22
import {
3+
chmodSync,
34
existsSync,
45
mkdirSync,
56
mkdtempSync,
@@ -34,6 +35,16 @@ const runSetup = (args: string[], runCwd: string = cwd) =>
3435
env,
3536
});
3637

38+
const runSetupSuccessfully = (args: string[], runCwd: string = cwd): string => {
39+
const result = runSetup(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+
3748
beforeEach(() => {
3849
cwd = mkdtempSync(path.join(import.meta.dirname, 'test-temp-rstack setup '));
3950
env = {
@@ -108,6 +119,65 @@ test('installs hooks silently without loading Rstack config', ({
108119
expect(execCli('setup', { cwd, env })).toBe('');
109120
});
110121

122+
test('guides and forces setup while preserving existing hooks', ({
123+
expect,
124+
}) => {
125+
initRepository();
126+
const existingHook = path.join(cwd, '.git', 'hooks', 'pre-commit');
127+
writeFileSync(
128+
existingHook,
129+
"#!/usr/bin/env sh\nprintf 'ran\\n' > old-hook-ran\n",
130+
);
131+
chmodSync(existingHook, 0o755);
132+
133+
const skippedOutput = runSetupSuccessfully([]);
134+
expect(skippedOutput).toContain(
135+
'Git hooks setup skipped: existing Git hooks were found: pre-commit.',
136+
);
137+
expect(skippedOutput).toContain(
138+
'To continue, run rs setup --force. Existing hook files will be preserved but become inactive.',
139+
);
140+
141+
const forcedOutput = runSetupSuccessfully(['--force']);
142+
expect(forcedOutput).toContain(
143+
'The previous Git hooks path ".git/hooks" is now inactive: pre-commit.',
144+
);
145+
expect(forcedOutput).toContain(
146+
'The existing files were preserved and will become active again if core.hooksPath is unset.',
147+
);
148+
expect(git(['config', '--local', '--get', 'core.hooksPath'])).toBe(hooksPath);
149+
150+
git(['hook', 'run', 'pre-commit']);
151+
expect(existsSync(path.join(cwd, 'old-hook-ran'))).toBe(false);
152+
153+
git(['config', '--local', '--unset', 'core.hooksPath']);
154+
git(['hook', 'run', 'pre-commit']);
155+
expect(existsSync(path.join(cwd, 'old-hook-ran'))).toBe(true);
156+
157+
expect(runSetupSuccessfully(['-f'])).toContain(
158+
'The previous Git hooks path ".git/hooks" is now inactive: pre-commit.',
159+
);
160+
});
161+
162+
test('reports how to restore a replaced hooks path', ({ expect }) => {
163+
initRepository();
164+
const existingDirectory = path.join(cwd, '.husky', '_');
165+
mkdirSync(existingDirectory, { recursive: true });
166+
writeFileSync(
167+
path.join(existingDirectory, 'pre-commit'),
168+
'#!/usr/bin/env sh\n',
169+
);
170+
git(['config', '--local', 'core.hooksPath', '.husky/_']);
171+
172+
const output = runSetupSuccessfully(['--force']);
173+
expect(output).toContain(
174+
'The previous Git hooks path ".husky/_" is now inactive: pre-commit.',
175+
);
176+
expect(output).toContain(
177+
'The existing files were preserved. Set core.hooksPath back to this path to use them again.',
178+
);
179+
});
180+
111181
test('installs root-relative hooks and reports owner conflicts', ({
112182
execCli,
113183
expect,
@@ -126,9 +196,7 @@ test('installs root-relative hooks and reports owner conflicts', ({
126196
);
127197
expect(existsSync(path.join(cwd, 'custom hooks', '_', 'runner'))).toBe(true);
128198

129-
const conflict = runSetup(['--hooks-dir', 'custom hooks'], docs);
130-
expect(conflict.status).toBe(0);
131-
expect(`${conflict.stdout}${conflict.stderr}`).toContain(
199+
expect(runSetupSuccessfully(['--hooks-dir', 'custom hooks'], docs)).toContain(
132200
'Git hooks are already managed by Rstack project "frontend"',
133201
);
134202
});

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

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -137,8 +137,12 @@ test('reports Git configuration failures without changing hooksPath', () => {
137137
});
138138
});
139139

140-
test('does not replace another Git hooks path', () => {
140+
test('requires force to replace another Git hooks path', () => {
141141
withRepository((cwd) => {
142+
const existingDirectory = path.join(cwd, '.husky', '_');
143+
const existingHook = path.join(existingDirectory, 'pre-commit');
144+
mkdirSync(existingDirectory, { recursive: true });
145+
writeFileSync(existingHook, '#!/usr/bin/env sh\n');
142146
runGit(cwd, ['config', '--local', 'core.hooksPath', '.husky/_']);
143147

144148
expect(installHooks({ cwd })).toMatchObject({
@@ -149,10 +153,24 @@ test('does not replace another Git hooks path', () => {
149153
'.husky/_',
150154
);
151155
expect(existsSync(path.join(cwd, hooksPath))).toBe(false);
156+
157+
expect(installHooks({ cwd, force: true })).toEqual({
158+
status: 'installed',
159+
hooksPath,
160+
inactiveHooks: {
161+
hooks: ['pre-commit'],
162+
path: '.husky/_',
163+
restore: 'configure',
164+
},
165+
});
166+
expect(runGit(cwd, ['config', '--local', '--get', 'core.hooksPath'])).toBe(
167+
hooksPath,
168+
);
169+
expect(readFileSync(existingHook, 'utf8')).toBe('#!/usr/bin/env sh\n');
152170
});
153171
});
154172

155-
test('does not bypass existing Git hooks', () => {
173+
test('requires force to bypass existing Git hooks', () => {
156174
withRepository((cwd) => {
157175
const existingHook = path.join(cwd, '.git', 'hooks', 'pre-commit');
158176
writeFileSync(existingHook, '#!/usr/bin/env sh\n');
@@ -165,6 +183,54 @@ test('does not bypass existing Git hooks', () => {
165183
expect(
166184
git(cwd, ['config', '--local', '--get', 'core.hooksPath']).status,
167185
).toBe(1);
186+
187+
expect(installHooks({ cwd, force: true })).toEqual({
188+
status: 'installed',
189+
hooksPath,
190+
inactiveHooks: {
191+
hooks: ['pre-commit'],
192+
path: '.git/hooks',
193+
restore: 'unset',
194+
},
195+
});
196+
expect(runGit(cwd, ['config', '--local', '--get', 'core.hooksPath'])).toBe(
197+
hooksPath,
198+
);
168199
expect(readFileSync(existingHook, 'utf8')).toBe('#!/usr/bin/env sh\n');
169200
});
170201
});
202+
203+
test('force does not replace hooks owned by another Rstack project', () => {
204+
withRepository((cwd) => {
205+
const frontend = path.join(cwd, 'frontend');
206+
const docs = path.join(cwd, 'docs');
207+
mkdirSync(frontend);
208+
mkdirSync(docs);
209+
210+
expect(installHooks({ cwd: frontend }).status).toBe('installed');
211+
expect(installHooks({ cwd: docs, force: true })).toMatchObject({
212+
status: 'skipped',
213+
reason: 'owned-by-another-project',
214+
});
215+
expect(readFileSync(path.join(cwd, hooksPath, '.owner'), 'utf8')).toBe(
216+
'frontend\n',
217+
);
218+
});
219+
});
220+
221+
test('force does not replace an invalid Rstack hooks directory', () => {
222+
withRepository((cwd) => {
223+
const directory = path.join(cwd, hooksPath);
224+
mkdirSync(directory, { recursive: true });
225+
writeFileSync(path.join(directory, '.owner'), 'invalid');
226+
227+
expect(installHooks({ cwd, force: true })).toMatchObject({
228+
status: 'skipped',
229+
reason: 'hooks-directory-conflict',
230+
});
231+
expect(
232+
git(cwd, ['config', '--local', '--get', 'core.hooksPath']).status,
233+
).toBe(1);
234+
expect(existsSync(path.join(directory, 'runner'))).toBe(false);
235+
});
236+
});

0 commit comments

Comments
 (0)