From 16f8bd14e501d4e9b081f25ad9071ab4e0e63ce2 Mon Sep 17 00:00:00 2001 From: neverland Date: Tue, 25 Aug 2026 12:41:58 +0800 Subject: [PATCH] feat(rstack): add hooks command --- packages/rstack/src/cli/commandHelp.ts | 20 +- packages/rstack/src/cli/commands.ts | 8 +- packages/rstack/src/setup/hooks.ts | 2 +- packages/rstack/src/setup/index.ts | 9 +- packages/rstack/src/setup/install.ts | 2 +- .../tests/cli/__snapshots__/help.test.ts.snap | 2 +- .../__snapshots__/index.test.ts.snap | 4 +- packages/rstack/tests/cli/hooks/index.test.ts | 245 ++++++++++++++++++ packages/rstack/tests/cli/setup/index.test.ts | 213 +-------------- 9 files changed, 290 insertions(+), 215 deletions(-) rename packages/rstack/tests/cli/{setup => hooks}/__snapshots__/index.test.ts.snap (78%) create mode 100644 packages/rstack/tests/cli/hooks/index.test.ts diff --git a/packages/rstack/src/cli/commandHelp.ts b/packages/rstack/src/cli/commandHelp.ts index a844a1b4..b11daabe 100644 --- a/packages/rstack/src/cli/commandHelp.ts +++ b/packages/rstack/src/cli/commandHelp.ts @@ -45,6 +45,7 @@ export type HelpTopic = | 'lint' | 'fmt' | 'staged' + | 'hooks' | 'setup'; const CONFIG_OPTION: HelpItem = [ @@ -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'], ], }, { @@ -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 ', + 'Specify hooks directory relative to the Git repository root', + ], + HELP_OPTION, + ], + }, + ], + }, setup: { usage: 'rs setup [options]', description: 'Install Git hooks in the current repository', diff --git a/packages/rstack/src/cli/commands.ts b/packages/rstack/src/cli/commands.ts index 891f67d5..624788ce 100644 --- a/packages/rstack/src/cli/commands.ts +++ b/packages/rstack/src/cli/commands.ts @@ -252,12 +252,12 @@ export async function setupCommands(): Promise { 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; } diff --git a/packages/rstack/src/setup/hooks.ts b/packages/rstack/src/setup/hooks.ts index 7a6e523c..2b068fcc 100644 --- a/packages/rstack/src/setup/hooks.ts +++ b/packages/rstack/src/setup/hooks.ts @@ -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 diff --git a/packages/rstack/src/setup/index.ts b/packages/rstack/src/setup/index.ts index b2538185..bfdf077d 100644 --- a/packages/rstack/src/setup/index.ts +++ b/packages/rstack/src/setup/index.ts @@ -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 => { +export const runHooksCLI = async ( + args: string[], + command: 'hooks' | 'setup' = 'hooks', +): Promise => { const { values } = parseArgs({ args, options: { @@ -25,7 +28,7 @@ export const runSetupCLI = async (args: string[]): Promise => { const hooksDir = hooksDirs?.[0]; if (values.help) { - await printCommandHelp('setup'); + await printCommandHelp(command); return; } @@ -68,7 +71,7 @@ export const runSetupCLI = async (args: string[]): Promise => { 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; diff --git a/packages/rstack/src/setup/install.ts b/packages/rstack/src/setup/install.ts index a0dabd99..e0d4409b 100644 --- a/packages/rstack/src/setup/install.ts +++ b/packages/rstack/src/setup/install.ts @@ -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') { diff --git a/packages/rstack/tests/cli/__snapshots__/help.test.ts.snap b/packages/rstack/tests/cli/__snapshots__/help.test.ts.snap index 6198d4dd..3bb29f53 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 - setup Install Git hooks + hooks Install Git hooks For command-specific options, run: $ rs -h diff --git a/packages/rstack/tests/cli/setup/__snapshots__/index.test.ts.snap b/packages/rstack/tests/cli/hooks/__snapshots__/index.test.ts.snap similarity index 78% rename from packages/rstack/tests/cli/setup/__snapshots__/index.test.ts.snap rename to packages/rstack/tests/cli/hooks/__snapshots__/index.test.ts.snap index 71351935..2945e481 100644 --- a/packages/rstack/tests/cli/setup/__snapshots__/index.test.ts.snap +++ b/packages/rstack/tests/cli/hooks/__snapshots__/index.test.ts.snap @@ -1,10 +1,10 @@ // Rstest Snapshot v1 -exports[`displays setup help 1`] = ` +exports[`displays hooks help without installing hooks 1`] = ` "Rstack v Usage: - $ rs setup [options] + $ rs hooks [options] Install Git hooks in the current repository diff --git a/packages/rstack/tests/cli/hooks/index.test.ts b/packages/rstack/tests/cli/hooks/index.test.ts new file mode 100644 index 00000000..7d627fd8 --- /dev/null +++ b/packages/rstack/tests/cli/hooks/index.test.ts @@ -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.'); +}); diff --git a/packages/rstack/tests/cli/setup/index.test.ts b/packages/rstack/tests/cli/setup/index.test.ts index c7749128..6fdc1501 100644 --- a/packages/rstack/tests/cli/setup/index.test.ts +++ b/packages/rstack/tests/cli/setup/index.test.ts @@ -1,17 +1,8 @@ import { spawnSync } from 'node:child_process'; -import { - chmodSync, - existsSync, - mkdirSync, - mkdtempSync, - rmSync, - writeFileSync, -} from 'node:fs'; +import { existsSync, mkdtempSync, rmSync } 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/_'; +import { test } from '#test-helpers'; let cwd: string; let env: NodeJS.ProcessEnv; @@ -24,32 +15,10 @@ const git = (args: string[]): string => { return result.stdout.trim(); }; -const initRepository = (): void => { - git(['init', '--quiet']); -}; - -const runSetup = (args: string[], runCwd: string = cwd) => - spawnSync(process.execPath, [RSTACK_BIN_PATH, 'setup', ...args], { - cwd: runCwd, - encoding: 'utf8', - env, - }); - -const runSetupSuccessfully = (args: string[], runCwd: string = cwd): string => { - const result = runSetup(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 setup ')); 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', @@ -60,177 +29,17 @@ afterEach(() => { rmSync(cwd, { force: true, recursive: true }); }); -test('displays setup help', ({ execCli, expect }) => { - const output = execCli('setup --help', { cwd }); - - expect(execCli('setup -h', { cwd })).toBe(output); - expect(normalizeHelpOutput(output)).toMatchSnapshot(); -}); - -test('rejects unknown setup options', ({ execCli, expect }) => { - expect(() => execCli('setup --unknown', { cwd })).toThrow(); -}); - -test('reports missing and repeated hooks directory options', ({ expect }) => { - const missing = runSetup(['--hooks-dir']); - expect(missing.status).toBe(1); - expect(missing.stderr).toContain('--hooks-dir'); - - const repeated = runSetup(['--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 = runSetup(['--hooks-dir', '']); - expect(empty.status).toBe(1); - expect(empty.stderr).toContain('Git hooks directory must not be empty.'); - - const absolute = runSetup(['--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 = runSetup(['--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('setup', { 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('setup', { cwd, env })).toBe(''); -}); - -test('guides and forces setup 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 = runSetupSuccessfully([]); - expect(skippedOutput).toContain( - 'Git hooks setup skipped: existing Git hooks were found: pre-commit.', - ); - expect(skippedOutput).toContain( - 'To continue, run rs setup --force. Existing hook files will be preserved but become inactive.', - ); - - const forcedOutput = runSetupSuccessfully(['--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(runSetupSuccessfully(['-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 = runSetupSuccessfully(['--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('keeps setup as a compatibility alias', ({ execCli, expect }) => { + git(['init', '--quiet']); -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); + const help = execCli('setup --help', { cwd, env }); + expect(execCli('setup -h', { cwd, env })).toBe(help); + expect(help).toContain('$ rs setup [options]'); - expect( - execCli('setup --hooks-dir "custom hooks"', { cwd: frontend, env }), - ).toBe(''); + expect(execCli('setup --hooks-dir "compat hooks"', { cwd, env })).toBe(''); expect(git(['config', '--local', '--get', 'core.hooksPath'])).toBe( - 'custom hooks/_', - ); - expect(existsSync(path.join(cwd, 'custom hooks', '_', 'runner'))).toBe(true); - - expect(runSetupSuccessfully(['--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('setup', { cwd, env })).toContain( - 'info Git hooks setup skipped: not a Git repository.', + 'compat hooks/_', ); - expect(existsSync(path.join(cwd, '.rstack'))).toBe(false); -}); - -test('skips setup when hooks are disabled', ({ execCli, expect }) => { - const output = execCli('setup', { 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, 'setup'], { - cwd, - encoding: 'utf8', - env: { ...env, PATH: '', Path: '' }, - }); - - expect(result.status).toBe(1); - expect(result.stderr).toContain('Git command not found.'); + expect(existsSync(path.join(cwd, 'compat hooks', '_', 'runner'))).toBe(true); + expect(execCli('hooks --hooks-dir "compat hooks"', { cwd, env })).toBe(''); });