From b6c2dba3d4bc3d9597fd230b63df4a4da34a2e91 Mon Sep 17 00:00:00 2001 From: neverland Date: Tue, 25 Aug 2026 13:30:28 +0800 Subject: [PATCH] feat(rstack): add hooks uninstall command --- packages/rstack/src/cli/commandHelp.ts | 25 ++- packages/rstack/src/setup/git.ts | 197 ++++++++++++++++++ packages/rstack/src/setup/index.ts | 50 ++++- packages/rstack/src/setup/install.ts | 196 ++--------------- packages/rstack/src/setup/uninstall.ts | 146 +++++++++++++ .../tests/cli/__snapshots__/help.test.ts.snap | 2 +- .../hooks/__snapshots__/index.test.ts.snap | 24 ++- packages/rstack/tests/cli/hooks/index.test.ts | 35 +++- packages/rstack/tests/setup/uninstall.test.ts | 165 +++++++++++++++ 9 files changed, 658 insertions(+), 182 deletions(-) create mode 100644 packages/rstack/src/setup/git.ts create mode 100644 packages/rstack/src/setup/uninstall.ts create mode 100644 packages/rstack/tests/setup/uninstall.test.ts diff --git a/packages/rstack/src/cli/commandHelp.ts b/packages/rstack/src/cli/commandHelp.ts index b11daab..550fb81 100644 --- a/packages/rstack/src/cli/commandHelp.ts +++ b/packages/rstack/src/cli/commandHelp.ts @@ -46,6 +46,7 @@ export type HelpTopic = | 'fmt' | 'staged' | 'hooks' + | 'hooks uninstall' | 'setup'; const CONFIG_OPTION: HelpItem = [ @@ -133,7 +134,7 @@ const HELP_DEFINITIONS = { ['check', 'Run static checks, including lint and format'], ['test', 'Run tests'], ['staged', 'Run tasks on staged Git files'], - ['hooks', 'Install Git hooks'], + ['hooks', 'Manage Git hooks'], ], }, { @@ -493,9 +494,17 @@ const HELP_DEFINITIONS = { ], }, hooks: { - usage: 'rs hooks [options]', - description: 'Install Git hooks in the current repository', + usage: 'rs hooks [command] [options]', + description: 'Manage Git hooks in the current repository', sections: [ + { + title: 'Commands', + items: [ + ['[options]', 'Install or update Git hooks (default)'], + ['uninstall', 'Uninstall Git hooks'], + ], + }, + commandHint('hooks'), { title: 'Options', items: [ @@ -509,6 +518,16 @@ const HELP_DEFINITIONS = { }, ], }, + 'hooks uninstall': { + usage: 'rs hooks uninstall [options]', + description: 'Uninstall Git hooks from the current repository', + sections: [ + { + title: 'Options', + items: [HELP_OPTION], + }, + ], + }, setup: { usage: 'rs setup [options]', description: 'Install Git hooks in the current repository', diff --git a/packages/rstack/src/setup/git.ts b/packages/rstack/src/setup/git.ts new file mode 100644 index 0000000..0805a3a --- /dev/null +++ b/packages/rstack/src/setup/git.ts @@ -0,0 +1,197 @@ +import { type SpawnSyncReturns, spawnSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; + +export const generatedDirectoryName = '_'; +export const ownerFileName = '.owner'; + +export type FailedHooksResult = { + status: 'failed'; + reason: string; + message: string; +}; + +export type GitContext = { + defaultHooksDirectory: string; + effectiveHooksDirectory: string; + gitRoot: string; + projectPath: string; +}; + +export type HooksPathScope = + 'command' | 'worktree' | 'local' | 'global' | 'system'; + +export const fail = (reason: string, message: string): FailedHooksResult => ({ + status: 'failed', + reason, + message, +}); + +export const runGit = (cwd: string, args: string[]): SpawnSyncReturns => + spawnSync('git', args, { cwd, encoding: 'utf8' }); + +const removeLineEnding = (value: string): string => + value.replace(/\r?\n$/u, ''); + +export const gitFailure = ( + error: NodeJS.ErrnoException | undefined, + stderr: string, +): FailedHooksResult => { + if (error?.code === 'ENOENT') { + return fail('git-not-found', 'Git command not found.'); + } + + return fail( + 'git-command-failed', + `Failed to run Git: ${error?.message || stderr.trim()}`, + ); +}; + +export const resolveHooksPathScope = ( + cwd: string, +): HooksPathScope | FailedHooksResult | undefined => { + 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 undefined; + } + 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 === 'command' || + scope === 'worktree' || + scope === 'local' || + scope === 'global' || + scope === 'system' + ) { + return scope; + } + + return fail( + 'git-config-failed', + 'Failed to resolve the core.hooksPath scope.', + ); +}; + +export const resolveGitHooksPath = ( + cwd: string, +): string | FailedHooksResult => { + 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; +}; + +export const resolveGitContext = ( + cwd: string, +): + | GitContext + | FailedHooksResult + | { status: 'skipped'; reason: 'not-git-repository' } => { + // Resolve every repository path in one Git process. `--git-path hooks` + // accounts for the effective core.hooksPath configuration across Git scopes. + const repository = runGit(cwd, [ + 'rev-parse', + '--is-inside-work-tree', + '--path-format=absolute', + '--show-toplevel', + '--show-prefix', + '--git-common-dir', + '--git-path', + 'hooks', + ]); + if (repository.error || repository.status === null) { + return gitFailure(repository.error, repository.stderr); + } + + const [ + insideWorkTree = '', + gitRoot = '', + repositoryPrefix = '', + gitCommonDirectory = '', + effectiveHooksDirectory = '', + ] = removeLineEnding(repository.stdout).split(/\r?\n/u); + + if (insideWorkTree !== 'true') { + return { status: 'skipped', reason: 'not-git-repository' }; + } + + if (repository.status !== 0) { + return fail( + 'git-command-failed', + `Failed to resolve the Git repository paths: ${repository.stderr.trim()}`, + ); + } + + if (!gitRoot || !gitCommonDirectory || !effectiveHooksDirectory) { + return fail( + 'git-command-failed', + 'Failed to resolve the Git repository paths.', + ); + } + + return { + defaultHooksDirectory: path.join(gitCommonDirectory, 'hooks'), + effectiveHooksDirectory, + gitRoot, + projectPath: + repositoryPrefix.replaceAll('\\', '/').replace(/\/$/u, '') || '.', + }; +}; + +export const isSamePath = (first: string, second: string): boolean => + path.resolve(first) === path.resolve(second); + +export const readOwner = (directory: string): string | undefined => { + try { + const content = readFileSync(path.join(directory, ownerFileName), 'utf8'); + const owner = removeLineEnding(content); + return content === `${owner}\n` && + owner.length > 0 && + !/[\r\n]/u.test(owner) + ? owner + : undefined; + } catch { + return undefined; + } +}; + +export const displayPath = (gitRoot: string, filePath: string): string => { + const relativePath = path.relative(gitRoot, filePath).replaceAll('\\', '/'); + return relativePath.length > 0 && !relativePath.startsWith('../') + ? relativePath + : filePath; +}; diff --git a/packages/rstack/src/setup/index.ts b/packages/rstack/src/setup/index.ts index bfdf077..6d7c9ef 100644 --- a/packages/rstack/src/setup/index.ts +++ b/packages/rstack/src/setup/index.ts @@ -2,10 +2,11 @@ import { color, logger } from 'rslog'; import { parseArgs } from '../cli/args.ts'; import { printCommandHelp } from '../cli/help.ts'; import { installHooks } from './install.ts'; +import { uninstallHooks } from './uninstall.ts'; -export const runHooksCLI = async ( +const runInstallCLI = async ( args: string[], - command: 'hooks' | 'setup' = 'hooks', + command: 'hooks' | 'setup', ): Promise => { const { values } = parseArgs({ args, @@ -87,3 +88,48 @@ export const runHooksCLI = async ( throw new Error(result.message); }; + +const runUninstallCLI = async (args: string[]): Promise => { + const { values } = parseArgs({ + args, + options: { + help: { type: 'boolean', short: 'h' }, + }, + allowPositionals: false, + strict: true, + }); + + if (values.help) { + await printCommandHelp('hooks uninstall'); + return; + } + + const result = uninstallHooks(); + if (result.status === 'uninstalled') { + logger.info( + `Rstack Git hooks uninstalled from "${color.yellow(result.hooksPath)}".`, + ); + logger.info( + `Remove ${color.yellow('rs hooks')} from the ${color.yellow('prepare')} script in package.json to keep hooks uninstalled.`, + ); + return; + } + + if (result.status === 'unchanged') { + return; + } + + throw new Error(result.message); +}; + +export const runHooksCLI = async ( + args: string[], + command: 'hooks' | 'setup' = 'hooks', +): Promise => { + if (command === 'hooks' && args[0] === 'uninstall') { + await runUninstallCLI(args.slice(1)); + return; + } + + await runInstallCLI(args, command); +}; diff --git a/packages/rstack/src/setup/install.ts b/packages/rstack/src/setup/install.ts index e0d4409..bd0bb40 100644 --- a/packages/rstack/src/setup/install.ts +++ b/packages/rstack/src/setup/install.ts @@ -1,4 +1,3 @@ -import { spawnSync } from 'node:child_process'; import { chmodSync, existsSync, @@ -8,11 +7,23 @@ import { writeFileSync, } from 'node:fs'; import path from 'node:path'; +import { + displayPath, + fail, + type FailedHooksResult, + generatedDirectoryName, + gitFailure, + isSamePath, + ownerFileName, + readOwner, + resolveGitContext, + resolveGitHooksPath, + resolveHooksPathScope, + runGit, +} from './git.ts'; import { createHookFiles, hookNames } from './hooks.ts'; const defaultHooksDir = '.rstack/hooks'; -const generatedDirectoryName = '_'; -const ownerFileName = '.owner'; const gitignore = '*\n'; type InstallHooksOptions = { @@ -27,11 +38,7 @@ type InactiveHooks = { restore: 'configure' | 'unset'; }; -type FailedInstallResult = { - status: 'failed'; - reason: string; - message: string; -}; +type FailedInstallResult = FailedHooksResult; type SkippedInstallResult = { status: 'skipped'; @@ -45,21 +52,8 @@ type InstallResult = | SkippedInstallResult | FailedInstallResult; -type GitContext = { - defaultHooksDirectory: string; - effectiveHooksDirectory: string; - gitRoot: string; - projectPath: string; -}; - type GitConfigScopeOption = '--local' | '--worktree'; -const fail = (reason: string, message: string): FailedInstallResult => ({ - status: 'failed', - reason, - message, -}); - const skip = (reason: string, message?: string): SkippedInstallResult => ({ status: 'skipped', reason, @@ -93,52 +87,13 @@ const resolveHooksDir = (hooksDir: string): string | FailedInstallResult => { return resolvedDir; }; -const runGit = (cwd: string, args: string[]) => - spawnSync('git', args, { cwd, encoding: 'utf8' }); - -const removeLineEnding = (value: string): string => - value.replace(/\r?\n$/u, ''); - -const gitFailure = ( - error: NodeJS.ErrnoException | undefined, - stderr: string, -): FailedInstallResult => { - if (error?.code === 'ENOENT') { - return fail('git-not-found', 'Git command not found.'); - } - - return fail( - 'git-command-failed', - `Failed to run Git: ${error?.message || stderr.trim()}`, - ); -}; - -const resolveHooksPathScope = ( +const resolveInstallConfigScope = ( 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'; + const scope = resolveHooksPathScope(cwd); + if (typeof scope === 'object') { + return scope; } - 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'; } @@ -148,90 +103,7 @@ const resolveHooksPathScope = ( "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') { - 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 the effective core.hooksPath configuration across Git scopes. - const repository = runGit(cwd, [ - 'rev-parse', - '--is-inside-work-tree', - '--path-format=absolute', - '--show-toplevel', - '--show-prefix', - '--git-common-dir', - '--git-path', - 'hooks', - ]); - if (repository.error || repository.status === null) { - return gitFailure(repository.error, repository.stderr); - } - - const [ - insideWorkTree = '', - gitRoot = '', - repositoryPrefix = '', - gitCommonDirectory = '', - effectiveHooksDirectory = '', - ] = removeLineEnding(repository.stdout).split(/\r?\n/u); - - if (insideWorkTree !== 'true') { - return skip('not-git-repository'); - } - - if (repository.status !== 0) { - return fail( - 'git-command-failed', - `Failed to resolve the Git repository paths: ${repository.stderr.trim()}`, - ); - } - - if (!gitRoot || !gitCommonDirectory || !effectiveHooksDirectory) { - return fail( - 'git-command-failed', - 'Failed to resolve the Git repository paths.', - ); - } - - return { - defaultHooksDirectory: path.join(gitCommonDirectory, 'hooks'), - effectiveHooksDirectory, - gitRoot, - projectPath: - repositoryPrefix.replaceAll('\\', '/').replace(/\/$/u, '') || '.', - }; + return '--local'; }; const isCurrentFile = ( @@ -252,30 +124,6 @@ const isCurrentFile = ( } }; -const isSamePath = (first: string, second: string): boolean => - path.resolve(first) === path.resolve(second); - -const readOwner = (directory: string): string | undefined => { - try { - const content = readFileSync(path.join(directory, ownerFileName), 'utf8'); - const owner = removeLineEnding(content); - return content === `${owner}\n` && - owner.length > 0 && - !/[\r\n]/u.test(owner) - ? owner - : undefined; - } catch { - return undefined; - } -}; - -const displayPath = (gitRoot: string, filePath: string): string => { - const relativePath = path.relative(gitRoot, filePath).replaceAll('\\', '/'); - return relativePath.length > 0 && !relativePath.startsWith('../') - ? relativePath - : filePath; -}; - const ownerConflict = (project: string): SkippedInstallResult => skip( 'owned-by-another-project', @@ -402,7 +250,9 @@ export const installHooks = ({ } // Preserve a worktree-scoped override instead of writing a shadowed local value. - const configScope = hooksPathMatches ? '--local' : resolveHooksPathScope(cwd); + const configScope = hooksPathMatches + ? '--local' + : resolveInstallConfigScope(cwd); if (typeof configScope !== 'string') { return configScope; } diff --git a/packages/rstack/src/setup/uninstall.ts b/packages/rstack/src/setup/uninstall.ts new file mode 100644 index 0000000..39eb9fe --- /dev/null +++ b/packages/rstack/src/setup/uninstall.ts @@ -0,0 +1,146 @@ +import { rmSync } from 'node:fs'; +import path from 'node:path'; +import { + displayPath, + fail, + type FailedHooksResult, + generatedDirectoryName, + gitFailure, + isSamePath, + readOwner, + resolveGitContext, + resolveGitHooksPath, + resolveHooksPathScope, + runGit, +} from './git.ts'; + +export type UninstallHooksOptions = { + cwd?: string; +}; + +export type UninstallHooksResult = + | { status: 'uninstalled'; hooksPath: string } + | { + status: 'unchanged'; + reason: 'not-git-repository' | 'not-installed'; + } + | FailedHooksResult; + +const isInsideRepository = (gitRoot: string, directory: string): boolean => { + const relativePath = path.relative(gitRoot, directory); + return ( + relativePath.length > 0 && + relativePath !== '..' && + !relativePath.startsWith(`..${path.sep}`) && + !path.isAbsolute(relativePath) + ); +}; + +const unmanagedDirectory = ( + gitRoot: string, + directory: string, +): FailedHooksResult => + fail( + 'hooks-directory-conflict', + `Cannot uninstall Git hooks because "${displayPath(gitRoot, directory)}" is not managed by Rstack. No files were removed.`, + ); + +export const uninstallHooks = ({ + cwd = process.cwd(), +}: UninstallHooksOptions = {}): UninstallHooksResult => { + const context = resolveGitContext(cwd); + if ('status' in context) { + return context.status === 'skipped' + ? { status: 'unchanged', reason: 'not-git-repository' } + : context; + } + + const { + defaultHooksDirectory, + effectiveHooksDirectory, + gitRoot, + projectPath, + } = context; + if (isSamePath(effectiveHooksDirectory, defaultHooksDirectory)) { + return { status: 'unchanged', reason: 'not-installed' }; + } + + if ( + path.basename(effectiveHooksDirectory) !== generatedDirectoryName || + !isInsideRepository(gitRoot, effectiveHooksDirectory) + ) { + return unmanagedDirectory(gitRoot, effectiveHooksDirectory); + } + + const owner = readOwner(effectiveHooksDirectory); + if (!owner) { + return unmanagedDirectory(gitRoot, effectiveHooksDirectory); + } + if (owner !== projectPath) { + return fail( + 'owned-by-another-project', + `Cannot uninstall Git hooks because "${displayPath(gitRoot, effectiveHooksDirectory)}" is owned by Rstack project "${owner}". No files were removed.`, + ); + } + + const scope = resolveHooksPathScope(cwd); + if (typeof scope === 'object') { + return scope; + } + if (scope === 'command') { + return fail( + 'hooks-path-command-scope', + "Cannot uninstall Git hooks because core.hooksPath is set in Git's command scope. Remove the command-scoped override and rerun rs hooks uninstall.", + ); + } + if (scope !== 'local' && scope !== 'worktree') { + const scopeName = scope ? `Git's ${scope} scope` : 'an unknown Git scope'; + return fail( + 'hooks-path-scope-conflict', + `Cannot uninstall Git hooks because core.hooksPath is set in ${scopeName}. No files were removed.`, + ); + } + + const configScope = scope === 'worktree' ? '--worktree' : '--local'; + const unset = runGit(cwd, [ + 'config', + configScope, + '--unset', + 'core.hooksPath', + ]); + if (unset.error || unset.status === null) { + return gitFailure(unset.error, unset.stderr); + } + if (unset.status !== 0) { + return fail( + 'git-config-failed', + `Failed to unset core.hooksPath in Git's ${scope} scope: ${unset.stderr.trim() || `Git exited with status ${unset.status}`}`, + ); + } + + const remainingHooksDirectory = resolveGitHooksPath(cwd); + if (typeof remainingHooksDirectory !== 'string') { + return remainingHooksDirectory; + } + if (isSamePath(remainingHooksDirectory, effectiveHooksDirectory)) { + return fail( + 'git-config-failed', + `Failed to deactivate Rstack Git hooks because core.hooksPath still resolves to "${displayPath(gitRoot, effectiveHooksDirectory)}". Generated files were preserved.`, + ); + } + + try { + rmSync(effectiveHooksDirectory, { recursive: true }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return fail( + 'remove-failed', + `Failed to remove generated Git hook files: ${message}`, + ); + } + + return { + status: 'uninstalled', + hooksPath: displayPath(gitRoot, effectiveHooksDirectory), + }; +}; diff --git a/packages/rstack/tests/cli/__snapshots__/help.test.ts.snap b/packages/rstack/tests/cli/__snapshots__/help.test.ts.snap index 3bb29f5..65e3a48 100644 --- a/packages/rstack/tests/cli/__snapshots__/help.test.ts.snap +++ b/packages/rstack/tests/cli/__snapshots__/help.test.ts.snap @@ -352,7 +352,7 @@ Commands: check Run static checks, including lint and format test Run tests staged Run tasks on staged Git files - hooks Install Git hooks + hooks Manage Git hooks For command-specific options, run: $ rs -h diff --git a/packages/rstack/tests/cli/hooks/__snapshots__/index.test.ts.snap b/packages/rstack/tests/cli/hooks/__snapshots__/index.test.ts.snap index 2945e48..ae0b783 100644 --- a/packages/rstack/tests/cli/hooks/__snapshots__/index.test.ts.snap +++ b/packages/rstack/tests/cli/hooks/__snapshots__/index.test.ts.snap @@ -4,9 +4,16 @@ exports[`displays hooks help without installing hooks 1`] = ` "Rstack v Usage: - $ rs hooks [options] + $ rs hooks [command] [options] -Install Git hooks in the current repository +Manage Git hooks in the current repository + +Commands: + [options] Install or update Git hooks (default) + uninstall Uninstall Git hooks + +For command-specific options, run: + $ rs hooks -h Options: -f, --force Install despite an existing Git hooks setup @@ -14,3 +21,16 @@ Options: -h, --help Display this help message " `; + +exports[`displays uninstall help 1`] = ` +"Rstack v + +Usage: + $ rs hooks uninstall [options] + +Uninstall Git hooks from the current repository + +Options: + -h, --help Display this help message +" +`; diff --git a/packages/rstack/tests/cli/hooks/index.test.ts b/packages/rstack/tests/cli/hooks/index.test.ts index 7d627fd..563bd04 100644 --- a/packages/rstack/tests/cli/hooks/index.test.ts +++ b/packages/rstack/tests/cli/hooks/index.test.ts @@ -71,12 +71,28 @@ test('displays hooks help without installing hooks', ({ execCli, expect }) => { 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('displays uninstall help', ({ execCli, expect }) => { + const output = execCli('hooks uninstall --help', { cwd }); + + expect(execCli('hooks uninstall -h', { cwd })).toBe(output); + expect(normalizeHelpOutput(output)).toMatchSnapshot(); +}); + +test('rejects unsupported uninstall arguments', ({ expect }) => { + for (const args of [ + ['extra'], + ['--force'], + ['--hooks-dir', 'custom-hooks'], + ]) { + expect(runHooks(['uninstall', ...args]).status).toBe(1); + } +}); + test('reports missing and repeated hooks directory options', ({ expect }) => { const missing = runHooks(['--hooks-dir']); expect(missing.status).toBe(1); @@ -125,6 +141,23 @@ test('installs hooks silently without loading Rstack config', ({ expect(execCli('hooks', { cwd, env })).toBe(''); }); +test('uninstalls hooks and prints the prepare reminder', ({ + execCli, + expect, +}) => { + initRepository(); + expect(execCli('hooks', { cwd, env })).toBe(''); + + const output = execCli('hooks uninstall', { cwd, env }); + expect(output).toContain( + 'info Rstack Git hooks uninstalled from ".rstack/hooks/_".', + ); + expect(output).toContain( + 'info Remove rs hooks from the prepare script in package.json to keep hooks uninstalled.', + ); + expect(existsSync(path.join(cwd, hooksPath))).toBe(false); +}); + test('guides and forces installation while preserving existing hooks', ({ expect, }) => { diff --git a/packages/rstack/tests/setup/uninstall.test.ts b/packages/rstack/tests/setup/uninstall.test.ts new file mode 100644 index 0000000..30985e6 --- /dev/null +++ b/packages/rstack/tests/setup/uninstall.test.ts @@ -0,0 +1,165 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { expect, test } from 'rstack/test'; +import { installHooks } from '../../src/setup/install.ts'; +import { uninstallHooks } from '../../src/setup/uninstall.ts'; +import { + git, + hooksPath, + restoreEnv, + runGit, + withDirectory, + withRepository, +} from './helpers.ts'; + +test.each([ + ['default', undefined, hooksPath], + ['custom', 'config/custom hooks', 'config/custom hooks/_'], +] as const)( + 'uninstalls a %s installation and preserves user hooks', + (_, hooksDir, installedHooksPath) => { + withRepository((cwd) => { + const userHook = path.join( + cwd, + path.dirname(installedHooksPath), + 'pre-commit', + ); + mkdirSync(path.dirname(userHook), { recursive: true }); + writeFileSync(userHook, 'echo user hook\n'); + + expect( + installHooks({ cwd, ...(hooksDir ? { hooksDir } : {}) }).status, + ).toBe('installed'); + const originalValue = process.env.RSTACK_HOOKS; + process.env.RSTACK_HOOKS = '0'; + + try { + expect(uninstallHooks({ cwd })).toEqual({ + status: 'uninstalled', + hooksPath: installedHooksPath, + }); + } finally { + restoreEnv('RSTACK_HOOKS', originalValue); + } + + expect( + git(cwd, ['config', '--local', '--get', 'core.hooksPath']).status, + ).toBe(1); + expect(existsSync(path.join(cwd, installedHooksPath))).toBe(false); + expect(readFileSync(userHook, 'utf8')).toBe('echo user hook\n'); + expect(uninstallHooks({ cwd }).status).toBe('unchanged'); + }); + }, +); + +test('uninstalls one linked worktree without affecting another', () => { + withRepository((cwd) => { + runGit(cwd, [ + '-c', + 'user.name=Rstack', + '-c', + 'user.email=rstack@example.com', + 'commit', + '--allow-empty', + '--quiet', + '-m', + 'initial', + ]); + runGit(cwd, ['config', '--local', 'extensions.worktreeConfig', 'true']); + expect(installHooks({ cwd }).status).toBe('installed'); + const mainHooksDirectory = path.join(cwd, hooksPath); + runGit(cwd, ['config', '--local', 'core.hooksPath', mainHooksDirectory]); + + const linkedWorktree = path.join(cwd, 'linked'); + runGit(cwd, [ + 'worktree', + 'add', + '--quiet', + '--detach', + linkedWorktree, + 'HEAD', + ]); + runGit(linkedWorktree, [ + 'config', + '--worktree', + 'core.hooksPath', + hooksPath, + ]); + expect(installHooks({ cwd: linkedWorktree }).status).toBe('installed'); + + expect(uninstallHooks({ cwd: linkedWorktree }).status).toBe('uninstalled'); + expect( + git(linkedWorktree, ['config', '--worktree', '--get', 'core.hooksPath']) + .status, + ).toBe(1); + expect(runGit(cwd, ['config', '--local', '--get', 'core.hooksPath'])).toBe( + mainHooksDirectory, + ); + expect(existsSync(mainHooksDirectory)).toBe(true); + }); +}); + +test('refuses to remove hooks owned by another Rstack project', () => { + withRepository((cwd) => { + const frontend = path.join(cwd, 'frontend'); + const docs = path.join(cwd, 'docs'); + mkdirSync(frontend); + mkdirSync(docs); + + expect(installHooks({ cwd: frontend }).status).toBe('installed'); + expect(uninstallHooks({ cwd: docs })).toMatchObject({ + status: 'failed', + reason: 'owned-by-another-project', + }); + expect(existsSync(path.join(cwd, hooksPath, 'runner'))).toBe(true); + }); +}); + +test('refuses to remove an unmanaged hooks directory', () => { + withRepository((cwd) => { + const unmanagedDirectory = path.join(cwd, '.husky', '_'); + const unmanagedHook = path.join(unmanagedDirectory, 'pre-commit'); + mkdirSync(unmanagedDirectory, { recursive: true }); + writeFileSync(unmanagedHook, '#!/usr/bin/env sh\n'); + runGit(cwd, ['config', '--local', 'core.hooksPath', '.husky/_']); + + expect(uninstallHooks({ cwd })).toMatchObject({ + status: 'failed', + reason: 'hooks-directory-conflict', + }); + expect(readFileSync(unmanagedHook, 'utf8')).toBe('#!/usr/bin/env sh\n'); + }); +}); + +test('is unchanged outside a Git repository', () => { + withDirectory((cwd) => { + expect(uninstallHooks({ cwd })).toEqual({ + status: 'unchanged', + reason: 'not-git-repository', + }); + expect(existsSync(path.join(cwd, '.rstack'))).toBe(false); + }); +}); + +test.each([ + [ + 'unsetting fails', + (cwd: string) => writeFileSync(path.join(cwd, '.git', 'config.lock'), ''), + ], + [ + 'the path remains active', + (cwd: string) => + runGit(cwd, ['config', '--global', 'core.hooksPath', hooksPath]), + ], +] as const)('preserves generated hooks when %s', (_, arrangeFailure) => { + withRepository((cwd) => { + expect(installHooks({ cwd }).status).toBe('installed'); + arrangeFailure(cwd); + + expect(uninstallHooks({ cwd })).toMatchObject({ + status: 'failed', + reason: 'git-config-failed', + }); + expect(existsSync(path.join(cwd, hooksPath, 'runner'))).toBe(true); + }); +});