Skip to content

Commit b6c2dba

Browse files
committed
feat(rstack): add hooks uninstall command
1 parent 6841faf commit b6c2dba

9 files changed

Lines changed: 658 additions & 182 deletions

File tree

packages/rstack/src/cli/commandHelp.ts

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ export type HelpTopic =
4646
| 'fmt'
4747
| 'staged'
4848
| 'hooks'
49+
| 'hooks uninstall'
4950
| 'setup';
5051

5152
const CONFIG_OPTION: HelpItem = [
@@ -133,7 +134,7 @@ const HELP_DEFINITIONS = {
133134
['check', 'Run static checks, including lint and format'],
134135
['test', 'Run tests'],
135136
['staged', 'Run tasks on staged Git files'],
136-
['hooks', 'Install Git hooks'],
137+
['hooks', 'Manage Git hooks'],
137138
],
138139
},
139140
{
@@ -493,9 +494,17 @@ const HELP_DEFINITIONS = {
493494
],
494495
},
495496
hooks: {
496-
usage: 'rs hooks [options]',
497-
description: 'Install Git hooks in the current repository',
497+
usage: 'rs hooks [command] [options]',
498+
description: 'Manage Git hooks in the current repository',
498499
sections: [
500+
{
501+
title: 'Commands',
502+
items: [
503+
['[options]', 'Install or update Git hooks (default)'],
504+
['uninstall', 'Uninstall Git hooks'],
505+
],
506+
},
507+
commandHint('hooks'),
499508
{
500509
title: 'Options',
501510
items: [
@@ -509,6 +518,16 @@ const HELP_DEFINITIONS = {
509518
},
510519
],
511520
},
521+
'hooks uninstall': {
522+
usage: 'rs hooks uninstall [options]',
523+
description: 'Uninstall Git hooks from the current repository',
524+
sections: [
525+
{
526+
title: 'Options',
527+
items: [HELP_OPTION],
528+
},
529+
],
530+
},
512531
setup: {
513532
usage: 'rs setup [options]',
514533
description: 'Install Git hooks in the current repository',

packages/rstack/src/setup/git.ts

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
import { type SpawnSyncReturns, spawnSync } from 'node:child_process';
2+
import { readFileSync } from 'node:fs';
3+
import path from 'node:path';
4+
5+
export const generatedDirectoryName = '_';
6+
export const ownerFileName = '.owner';
7+
8+
export type FailedHooksResult = {
9+
status: 'failed';
10+
reason: string;
11+
message: string;
12+
};
13+
14+
export type GitContext = {
15+
defaultHooksDirectory: string;
16+
effectiveHooksDirectory: string;
17+
gitRoot: string;
18+
projectPath: string;
19+
};
20+
21+
export type HooksPathScope =
22+
'command' | 'worktree' | 'local' | 'global' | 'system';
23+
24+
export const fail = (reason: string, message: string): FailedHooksResult => ({
25+
status: 'failed',
26+
reason,
27+
message,
28+
});
29+
30+
export const runGit = (cwd: string, args: string[]): SpawnSyncReturns<string> =>
31+
spawnSync('git', args, { cwd, encoding: 'utf8' });
32+
33+
const removeLineEnding = (value: string): string =>
34+
value.replace(/\r?\n$/u, '');
35+
36+
export const gitFailure = (
37+
error: NodeJS.ErrnoException | undefined,
38+
stderr: string,
39+
): FailedHooksResult => {
40+
if (error?.code === 'ENOENT') {
41+
return fail('git-not-found', 'Git command not found.');
42+
}
43+
44+
return fail(
45+
'git-command-failed',
46+
`Failed to run Git: ${error?.message || stderr.trim()}`,
47+
);
48+
};
49+
50+
export const resolveHooksPathScope = (
51+
cwd: string,
52+
): HooksPathScope | FailedHooksResult | undefined => {
53+
const configured = runGit(cwd, [
54+
'config',
55+
'--show-scope',
56+
'--get',
57+
'core.hooksPath',
58+
]);
59+
if (configured.error || configured.status === null) {
60+
return gitFailure(configured.error, configured.stderr);
61+
}
62+
63+
// Exit status 1 means core.hooksPath is not configured yet.
64+
if (configured.status === 1) {
65+
return undefined;
66+
}
67+
if (configured.status !== 0) {
68+
return fail(
69+
'git-config-failed',
70+
`Failed to resolve the core.hooksPath scope: ${configured.stderr.trim()}`,
71+
);
72+
}
73+
74+
const separator = configured.stdout.indexOf('\t');
75+
const scope = separator === -1 ? '' : configured.stdout.slice(0, separator);
76+
if (
77+
scope === 'command' ||
78+
scope === 'worktree' ||
79+
scope === 'local' ||
80+
scope === 'global' ||
81+
scope === 'system'
82+
) {
83+
return scope;
84+
}
85+
86+
return fail(
87+
'git-config-failed',
88+
'Failed to resolve the core.hooksPath scope.',
89+
);
90+
};
91+
92+
export const resolveGitHooksPath = (
93+
cwd: string,
94+
): string | FailedHooksResult => {
95+
const hooksDirectory = runGit(cwd, [
96+
'rev-parse',
97+
'--path-format=absolute',
98+
'--git-path',
99+
'hooks',
100+
]);
101+
if (hooksDirectory.error || hooksDirectory.status === null) {
102+
return gitFailure(hooksDirectory.error, hooksDirectory.stderr);
103+
}
104+
if (hooksDirectory.status !== 0) {
105+
return fail(
106+
'git-command-failed',
107+
`Failed to resolve the Git hooks path: ${hooksDirectory.stderr.trim()}`,
108+
);
109+
}
110+
111+
const resolvedDirectory = removeLineEnding(hooksDirectory.stdout);
112+
if (!resolvedDirectory) {
113+
return fail('git-command-failed', 'Failed to resolve the Git hooks path.');
114+
}
115+
return resolvedDirectory;
116+
};
117+
118+
export const resolveGitContext = (
119+
cwd: string,
120+
):
121+
| GitContext
122+
| FailedHooksResult
123+
| { status: 'skipped'; reason: 'not-git-repository' } => {
124+
// Resolve every repository path in one Git process. `--git-path hooks`
125+
// accounts for the effective core.hooksPath configuration across Git scopes.
126+
const repository = runGit(cwd, [
127+
'rev-parse',
128+
'--is-inside-work-tree',
129+
'--path-format=absolute',
130+
'--show-toplevel',
131+
'--show-prefix',
132+
'--git-common-dir',
133+
'--git-path',
134+
'hooks',
135+
]);
136+
if (repository.error || repository.status === null) {
137+
return gitFailure(repository.error, repository.stderr);
138+
}
139+
140+
const [
141+
insideWorkTree = '',
142+
gitRoot = '',
143+
repositoryPrefix = '',
144+
gitCommonDirectory = '',
145+
effectiveHooksDirectory = '',
146+
] = removeLineEnding(repository.stdout).split(/\r?\n/u);
147+
148+
if (insideWorkTree !== 'true') {
149+
return { status: 'skipped', reason: 'not-git-repository' };
150+
}
151+
152+
if (repository.status !== 0) {
153+
return fail(
154+
'git-command-failed',
155+
`Failed to resolve the Git repository paths: ${repository.stderr.trim()}`,
156+
);
157+
}
158+
159+
if (!gitRoot || !gitCommonDirectory || !effectiveHooksDirectory) {
160+
return fail(
161+
'git-command-failed',
162+
'Failed to resolve the Git repository paths.',
163+
);
164+
}
165+
166+
return {
167+
defaultHooksDirectory: path.join(gitCommonDirectory, 'hooks'),
168+
effectiveHooksDirectory,
169+
gitRoot,
170+
projectPath:
171+
repositoryPrefix.replaceAll('\\', '/').replace(/\/$/u, '') || '.',
172+
};
173+
};
174+
175+
export const isSamePath = (first: string, second: string): boolean =>
176+
path.resolve(first) === path.resolve(second);
177+
178+
export const readOwner = (directory: string): string | undefined => {
179+
try {
180+
const content = readFileSync(path.join(directory, ownerFileName), 'utf8');
181+
const owner = removeLineEnding(content);
182+
return content === `${owner}\n` &&
183+
owner.length > 0 &&
184+
!/[\r\n]/u.test(owner)
185+
? owner
186+
: undefined;
187+
} catch {
188+
return undefined;
189+
}
190+
};
191+
192+
export const displayPath = (gitRoot: string, filePath: string): string => {
193+
const relativePath = path.relative(gitRoot, filePath).replaceAll('\\', '/');
194+
return relativePath.length > 0 && !relativePath.startsWith('../')
195+
? relativePath
196+
: filePath;
197+
};

packages/rstack/src/setup/index.ts

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,11 @@ import { color, logger } from 'rslog';
22
import { parseArgs } from '../cli/args.ts';
33
import { printCommandHelp } from '../cli/help.ts';
44
import { installHooks } from './install.ts';
5+
import { uninstallHooks } from './uninstall.ts';
56

6-
export const runHooksCLI = async (
7+
const runInstallCLI = async (
78
args: string[],
8-
command: 'hooks' | 'setup' = 'hooks',
9+
command: 'hooks' | 'setup',
910
): Promise<void> => {
1011
const { values } = parseArgs({
1112
args,
@@ -87,3 +88,48 @@ export const runHooksCLI = async (
8788

8889
throw new Error(result.message);
8990
};
91+
92+
const runUninstallCLI = async (args: string[]): Promise<void> => {
93+
const { values } = parseArgs({
94+
args,
95+
options: {
96+
help: { type: 'boolean', short: 'h' },
97+
},
98+
allowPositionals: false,
99+
strict: true,
100+
});
101+
102+
if (values.help) {
103+
await printCommandHelp('hooks uninstall');
104+
return;
105+
}
106+
107+
const result = uninstallHooks();
108+
if (result.status === 'uninstalled') {
109+
logger.info(
110+
`Rstack Git hooks uninstalled from "${color.yellow(result.hooksPath)}".`,
111+
);
112+
logger.info(
113+
`Remove ${color.yellow('rs hooks')} from the ${color.yellow('prepare')} script in package.json to keep hooks uninstalled.`,
114+
);
115+
return;
116+
}
117+
118+
if (result.status === 'unchanged') {
119+
return;
120+
}
121+
122+
throw new Error(result.message);
123+
};
124+
125+
export const runHooksCLI = async (
126+
args: string[],
127+
command: 'hooks' | 'setup' = 'hooks',
128+
): Promise<void> => {
129+
if (command === 'hooks' && args[0] === 'uninstall') {
130+
await runUninstallCLI(args.slice(1));
131+
return;
132+
}
133+
134+
await runInstallCLI(args, command);
135+
};

0 commit comments

Comments
 (0)