diff --git a/packages/rstack/src/setup/hooks.ts b/packages/rstack/src/setup/hooks.ts index 518d4789..ccc22534 100644 --- a/packages/rstack/src/setup/hooks.ts +++ b/packages/rstack/src/setup/hooks.ts @@ -1,6 +1,6 @@ // Keep this list aligned with the client-side hooks generated by Husky v9. // `pre-auto-gc` is included even though it is not listed in Husky's documentation. -const hookNames = [ +export const hookNames: string[] = [ 'pre-commit', 'pre-merge-commit', 'prepare-commit-msg', @@ -30,43 +30,46 @@ const quoteShellPath = (value: string): string => { return `'${shellPath.replaceAll("'", `'"'"'`)}'`; }; -// Generated shims live in `/_`. When a shim sources this -// dispatcher, `$0` still points to the shim, so the user hook is one level up. const createDispatcher = (nodeExecutable: string): string => `#!/usr/bin/env sh +# Generated by Rstack. Do not edit. -name=$(basename "$0") -dir=$(dirname "$(dirname "$0")") -hook="$dir/$name" +rs_name=\${0##*/} +rs_root=$PWD +rs_hook="\${rs_dir%/*}/$rs_name" +[ -f "$rs_hook" ] || exit 0 -[ -f "$hook" ] || exit 0 - -init="\${XDG_CONFIG_HOME:-$HOME/.config}/rstack/hooks-init.sh" -[ -f "$init" ] && . "$init" +rs_init="\${XDG_CONFIG_HOME:-$HOME/.config}/rstack/hooks-init.sh" +[ -f "$rs_init" ] && . "$rs_init" [ "\${RSTACK_HOOKS-}" = "0" ] && exit 0 [ "\${RSTACK_HOOKS-}" = "2" ] && set -x +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 # it from PATH. Keep an existing Node.js environment ahead of this fallback. -node_fallback=${quoteShellPath(nodeExecutable)} -if ! command -v node >/dev/null 2>&1 && [ -x "$node_fallback" ]; then - PATH="\${PATH:+$PATH:}\${node_fallback%/*}" +rs_node_fallback=${quoteShellPath(nodeExecutable)} +if ! command -v node >/dev/null 2>&1 && [ -x "$rs_node_fallback" ]; then + PATH="\${PATH:+$PATH:}\${rs_node_fallback%/*}" fi +cd "$rs_root/$rs_project_path" || exit 1 export PATH="node_modules/.bin\${PATH:+:$PATH}" -code=0 -sh -e "$hook" "$@" || code=$? +rs_code=0 +sh -e "$rs_hook" "$@" || rs_code=$? -[ "$code" = "0" ] || echo "Rstack - $name hook failed (code $code)" -[ "$code" = "127" ] && echo "Rstack - command not found in PATH=$PATH" -exit "$code" +[ "$rs_code" = "0" ] || echo "Rstack - $rs_name hook failed (code $rs_code)" +[ "$rs_code" = "127" ] && echo "Rstack - command not found in PATH=$PATH" +exit "$rs_code" `; // Every generated Git hook sources the same dispatcher to keep runtime behavior // consistent and make future initialization changes local to one file. const shim = `#!/usr/bin/env sh -. "$(dirname "$0")/runner" +rs_dir=$(CDPATH= cd "$(dirname "$0")" && pwd) || exit 1 +. "$rs_dir/runner" `; export const createHookFiles = ( diff --git a/packages/rstack/src/setup/index.ts b/packages/rstack/src/setup/index.ts index a8a039ac..114dfcf4 100644 --- a/packages/rstack/src/setup/index.ts +++ b/packages/rstack/src/setup/index.ts @@ -10,7 +10,7 @@ ${color.yellow(' $ rs setup [options]')} Install Git hooks in the current repository. ${color.cyan('Options')}: - --hooks-dir Specify hooks directory relative to the current directory + --hooks-dir Specify hooks directory relative to the Git repository root -h, --help Display this help message`; export const runSetupCLI = (args: string[]): void => { @@ -43,6 +43,11 @@ export const runSetupCLI = (args: string[]): void => { } if (result.status === 'skipped') { + if (result.message) { + logger.warn(`Git hooks setup skipped: ${color.yellow(result.message)}.`); + return; + } + const reason = result.reason === 'disabled' ? 'disabled by RSTACK_HOOKS' : 'not a Git repository'; logger.info(`Git hooks setup skipped: ${color.yellow(reason)}.`); diff --git a/packages/rstack/src/setup/install.ts b/packages/rstack/src/setup/install.ts index e73fdef9..4084d270 100644 --- a/packages/rstack/src/setup/install.ts +++ b/packages/rstack/src/setup/install.ts @@ -1,9 +1,19 @@ import { spawnSync } from 'node:child_process'; -import { chmodSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'; +import { + chmodSync, + existsSync, + mkdirSync, + readFileSync, + readdirSync, + statSync, + writeFileSync, +} from 'node:fs'; import path from 'node:path'; -import { createHookFiles } from './hooks.ts'; +import { createHookFiles, hookNames } from './hooks.ts'; const defaultHooksDir = '.rstack/hooks'; +const generatedDirectoryName = '_'; +const ownerFileName = '.owner'; const gitignore = '*\n'; type InstallHooksOptions = { @@ -17,18 +27,37 @@ type FailedInstallResult = { message: string; }; +type SkippedInstallResult = { + status: 'skipped'; + reason: string; + message?: string; +}; + type InstallResult = | { status: 'installed'; hooksPath: string } | { status: 'unchanged'; hooksPath: string } - | { status: 'skipped'; reason: string } + | SkippedInstallResult | FailedInstallResult; +type GitContext = { + defaultHooksDirectory: string; + effectiveHooksDirectory: string; + gitRoot: string; + projectPath: string; +}; + const fail = (reason: string, message: string): FailedInstallResult => ({ status: 'failed', reason, message, }); +const skip = (reason: string, message?: string): SkippedInstallResult => ({ + status: 'skipped', + reason, + ...(message ? { message } : {}), +}); + const resolveHooksDir = (hooksDir: string): string | FailedInstallResult => { const resolvedDir = hooksDir.replaceAll('\\', '/'); @@ -39,7 +68,7 @@ const resolveHooksDir = (hooksDir: string): string | FailedInstallResult => { if (path.isAbsolute(resolvedDir)) { return fail( 'invalid-hooks-directory', - 'Git hooks directory must be relative to the current directory.', + 'Git hooks directory must be relative to the Git repository root.', ); } @@ -54,7 +83,10 @@ const runGit = (cwd: string, args: string[]) => spawnSync('git', args, { cwd, en const removeLineEnding = (value: string): string => value.replace(/\r?\n$/u, ''); -const gitFailure = (error: NodeJS.ErrnoException | undefined, stderr: string): InstallResult => { +const gitFailure = ( + error: NodeJS.ErrnoException | undefined, + stderr: string, +): FailedInstallResult => { if (error?.code === 'ENOENT') { return fail('git-not-found', 'Git command not found.'); } @@ -62,6 +94,54 @@ const gitFailure = (error: NodeJS.ErrnoException | undefined, stderr: string): I return fail('git-command-failed', `Failed to run Git: ${error?.message || stderr.trim()}`); }; +const resolveGitContext = (cwd: string): GitContext | InstallResult => { + // Resolve every repository path in one Git process. `--git-path hooks` + // accounts for an existing local or global core.hooksPath configuration. + 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, '') || '.', + }; +}; + const isCurrentFile = (filePath: string, content: string, executable = false): boolean => { try { // Windows does not expose POSIX executable bits, but Git for Windows still runs hook shims. @@ -74,12 +154,79 @@ const isCurrentFile = (filePath: string, content: string, executable = false): b } }; +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', `Git hooks are already managed by Rstack project "${project}"`); + +const directoryConflict = (gitRoot: string, directory: string): SkippedInstallResult => + skip( + 'hooks-directory-conflict', + `the hooks directory "${displayPath(gitRoot, directory)}" is not managed by Rstack`, + ); + +const claimOwner = ( + directory: string, + gitRoot: string, + project: string, +): SkippedInstallResult | undefined => { + const ownerPath = path.join(directory, ownerFileName); + const owner = readOwner(directory); + + if (owner) { + return owner === project ? undefined : ownerConflict(owner); + } + + if (readdirSync(directory).some((entry) => entry !== '.gitignore')) { + return directoryConflict(gitRoot, directory); + } + + try { + // Exclusive creation makes concurrent prepare scripts agree on one owner. + writeFileSync(ownerPath, `${project}\n`, { flag: 'wx' }); + } catch (error) { + const code = error instanceof Error && 'code' in error ? error.code : undefined; + if (code !== 'EEXIST') { + throw error; + } + + const concurrentOwner = readOwner(directory); + if (!concurrentOwner) { + return directoryConflict(gitRoot, directory); + } + return concurrentOwner === project ? undefined : ownerConflict(concurrentOwner); + } + + return undefined; +}; + +const findExistingHooks = (directory: string): string[] => + hookNames.filter((name) => existsSync(path.join(directory, name))); + export const installHooks = ({ cwd = process.cwd(), hooksDir = defaultHooksDir, }: InstallHooksOptions = {}): InstallResult => { if (process.env.RSTACK_HOOKS === '0') { - return { status: 'skipped', reason: 'disabled' }; + return skip('disabled'); } const resolvedDir = resolveHooksDir(hooksDir); @@ -88,57 +235,57 @@ export const installHooks = ({ } // Check Git before touching the filesystem so non-repositories have no side effects. - const repository = runGit(cwd, [ - 'rev-parse', - '--is-inside-work-tree', - '--show-prefix', - '--git-path', - 'hooks', - ]); - if (repository.error || repository.status === null) { - return gitFailure(repository.error, repository.stderr); + const context = resolveGitContext(cwd); + if ('status' in context) { + return context; } - const [insideWorkTree = '', repositoryPrefix, configuredHooksPath] = removeLineEnding( - repository.stdout, - ).split(/\r?\n/u); + const { defaultHooksDirectory, effectiveHooksDirectory, gitRoot, projectPath } = context; + const hooksPath = `${resolvedDir}/${generatedDirectoryName}`; + const directory = path.join(gitRoot, resolvedDir, generatedDirectoryName); + const hooksPathMatches = isSamePath(effectiveHooksDirectory, directory); + const usesDefaultHooks = isSamePath(effectiveHooksDirectory, defaultHooksDirectory); - if (repository.status !== 0) { - if (insideWorkTree.trim() === 'true') { - return fail( - 'git-command-failed', - `Failed to resolve the Git repository paths: ${repository.stderr.trim()}`, + if (!hooksPathMatches && !usesDefaultHooks) { + const activeOwner = readOwner(effectiveHooksDirectory); + if (!activeOwner) { + return skip( + 'hooks-path-conflict', + `Git hooks are already configured at "${displayPath(gitRoot, effectiveHooksDirectory)}"`, ); } - return { status: 'skipped', reason: 'not-git-repository' }; - } - - if (insideWorkTree.trim() !== 'true') { - return { status: 'skipped', reason: 'not-git-repository' }; + if (activeOwner !== projectPath) { + return ownerConflict(activeOwner); + } } - if (repositoryPrefix === undefined || configuredHooksPath === undefined) { - return fail('git-command-failed', 'Failed to resolve the Git repository paths.'); + if (usesDefaultHooks) { + const existingHooks = findExistingHooks(defaultHooksDirectory); + if (existingHooks.length > 0) { + return skip( + 'existing-git-hooks', + `existing Git hooks were found: ${existingHooks.join(', ')}`, + ); + } } - const prefix = repositoryPrefix.replaceAll('\\', '/'); - const hooksPath = `${prefix}${resolvedDir}/_`; - - const directory = path.join(cwd, resolvedDir, '_'); const files = Object.entries(createHookFiles()); - const hooksPathMatches = path.resolve(cwd, configuredHooksPath) === directory; - // Skip all writes only when the config, generated content, and executable modes match. - const unchanged = - hooksPathMatches && - isCurrentFile(path.join(directory, '.gitignore'), gitignore) && - files.every(([name, content]) => isCurrentFile(path.join(directory, name), content, true)); - - if (unchanged) { - return { status: 'unchanged', hooksPath }; - } - try { mkdirSync(directory, { recursive: true }); + const ownerResult = claimOwner(directory, gitRoot, projectPath); + if (ownerResult) { + return ownerResult; + } + + // Skip generated file writes when their content and executable modes match. + const unchanged = + hooksPathMatches && + isCurrentFile(path.join(directory, '.gitignore'), gitignore) && + files.every(([name, content]) => isCurrentFile(path.join(directory, name), content, true)); + if (unchanged) { + return { status: 'unchanged', hooksPath }; + } + writeFileSync(path.join(directory, '.gitignore'), gitignore); for (const [name, content] of files) { diff --git a/packages/rstack/tests/cli/setup/index.test.ts b/packages/rstack/tests/cli/setup/index.test.ts index 8a3c3e89..077f032b 100644 --- a/packages/rstack/tests/cli/setup/index.test.ts +++ b/packages/rstack/tests/cli/setup/index.test.ts @@ -76,7 +76,7 @@ test('rejects invalid hooks directory options', ({ expect }) => { 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 current directory.', + 'Git hooks directory must be relative to the Git repository root.', ); const parent = runSetup(['--hooks-dir', '../hooks']); @@ -96,14 +96,22 @@ test('installs hooks silently without loading Rstack config', ({ execCli, expect expect(execCli('setup', { cwd, env })).toBe(''); }); -test('installs a custom hooks directory from a nested project', ({ execCli, expect }) => { +test('installs root-relative hooks and reports owner conflicts', ({ execCli, expect }) => { initRepository(); - const projectDirectory = path.join(cwd, 'frontend'); - mkdirSync(projectDirectory); - - expect(execCli('setup --hooks-dir "custom hooks"', { cwd: projectDirectory, env })).toBe(''); - expect(git(['config', '--local', '--get', 'core.hooksPath'])).toBe('frontend/custom hooks/_'); - expect(existsSync(path.join(projectDirectory, 'custom hooks', '_', 'runner'))).toBe(true); + const frontend = path.join(cwd, 'frontend'); + const docs = path.join(cwd, 'docs'); + mkdirSync(frontend); + mkdirSync(docs); + + expect(execCli('setup --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); + + const conflict = runSetup(['--hooks-dir', 'custom hooks'], docs); + expect(conflict.status).toBe(0); + expect(`${conflict.stdout}${conflict.stderr}`).toContain( + 'Git hooks are already managed by Rstack project "frontend"', + ); }); test('skips non-Git directories without creating files', ({ execCli, expect }) => { diff --git a/packages/rstack/tests/setup/directories.test.ts b/packages/rstack/tests/setup/directories.test.ts index 59ce17f6..dfaf62da 100644 --- a/packages/rstack/tests/setup/directories.test.ts +++ b/packages/rstack/tests/setup/directories.test.ts @@ -22,52 +22,43 @@ test('installs a custom hooks directory from the Git root and runs its hook', () }); }); -test('installs the default hooks directory from a nested project', () => { +test('installs repository-level hooks from a nested project', () => { withRepository((cwd) => { const projectDirectory = path.join(cwd, 'frontend'); - const nestedHooksPath = `frontend/${hooksPath}`; mkdirSync(projectDirectory); - writeHook( - projectDirectory, - `printf 'root\\n' > nested-hook-cwd -cd frontend -printf 'nested\\n' > nested-hook-ran -`, - ); + writeHook(cwd, "printf 'ran\\n' > nested-hook-ran\n"); expect(installHooks({ cwd: projectDirectory })).toEqual({ status: 'installed', - hooksPath: nestedHooksPath, + hooksPath, }); expect(installHooks({ cwd: projectDirectory })).toEqual({ status: 'unchanged', - hooksPath: nestedHooksPath, + hooksPath, }); - expect(runGit(cwd, ['config', '--local', '--get', 'core.hooksPath'])).toBe(nestedHooksPath); - expect(existsSync(path.join(projectDirectory, hooksPath, 'runner'))).toBe(true); + expect(runGit(cwd, ['config', '--local', '--get', 'core.hooksPath'])).toBe(hooksPath); + expect(existsSync(path.join(cwd, hooksPath, 'runner'))).toBe(true); + expect(readFileSync(path.join(cwd, hooksPath, '.owner'), 'utf8')).toBe('frontend\n'); expect(runHook(cwd).status).toBe(0); - expect(readFileSync(path.join(cwd, 'nested-hook-cwd'), 'utf8')).toBe('root\n'); - expect(readFileSync(path.join(projectDirectory, 'nested-hook-ran'), 'utf8')).toBe('nested\n'); + expect(readFileSync(path.join(projectDirectory, 'nested-hook-ran'), 'utf8')).toBe('ran\n'); }); }); -test('installs a custom hooks directory from a nested project', () => { +test('installs a root-relative custom hooks directory from a nested project', () => { withRepository((cwd) => { const projectDirectory = path.join(cwd, 'frontend app'); mkdirSync(projectDirectory); expect(installHooks({ cwd: projectDirectory, hooksDir: 'config\\hooks' })).toEqual({ status: 'installed', - hooksPath: 'frontend app/config/hooks/_', + hooksPath: 'config/hooks/_', }); expect(installHooks({ cwd: projectDirectory, hooksDir: 'config\\hooks' })).toEqual({ status: 'unchanged', - hooksPath: 'frontend app/config/hooks/_', + hooksPath: 'config/hooks/_', }); - expect(runGit(cwd, ['config', '--local', '--get', 'core.hooksPath'])).toBe( - 'frontend app/config/hooks/_', - ); - expect(existsSync(path.join(projectDirectory, 'config', 'hooks', '_', 'runner'))).toBe(true); + expect(runGit(cwd, ['config', '--local', '--get', 'core.hooksPath'])).toBe('config/hooks/_'); + expect(existsSync(path.join(cwd, 'config', 'hooks', '_', 'runner'))).toBe(true); }); }); diff --git a/packages/rstack/tests/setup/hooks.test.ts b/packages/rstack/tests/setup/hooks.test.ts index 882e1b60..14cb8eb2 100644 --- a/packages/rstack/tests/setup/hooks.test.ts +++ b/packages/rstack/tests/setup/hooks.test.ts @@ -31,14 +31,14 @@ test('generates the dispatcher and all client-side Git hook shims', () => { test.runIf(process.platform === 'win32')('converts Windows Node paths', () => { const { runner } = createHookFiles(String.raw`C:\Program Files\nodejs\node.exe`); - expect(runner).toContain("node_fallback='/c/Program Files/nodejs/node.exe'"); + expect(runner).toContain("rs_node_fallback='/c/Program Files/nodejs/node.exe'"); }); test.runIf(process.platform !== 'win32')('preserves backslashes in POSIX Node paths', () => { const nodeExecutable = String.raw`/opt/node\24/bin/node`; const { runner } = createHookFiles(nodeExecutable); - expect(runner).toContain(`node_fallback='${nodeExecutable}'`); + expect(runner).toContain(`rs_node_fallback='${nodeExecutable}'`); }); test.runIf(process.platform !== 'win32')('runs generated hooks', () => { @@ -58,10 +58,11 @@ test.runIf(process.platform !== 'win32')('runs generated hooks', () => { }; mkdirSync(generatedDirectory, { recursive: true }); + writeFileSync(path.join(generatedDirectory, '.owner'), '.\n'); writeFileSync(path.join(generatedDirectory, 'runner'), files.runner); writeFileSync(generatedHook, files['pre-commit']); - expect(spawnSync('sh', [generatedHook], { env }).status).toBe(0); + expect(spawnSync('sh', [generatedHook], { cwd: directory, env }).status).toBe(0); writeFileSync( userHook, @@ -70,6 +71,7 @@ printf '%s\\n' "$1|$input" `, ); const result = spawnSync('sh', [generatedHook, 'argument with spaces'], { + cwd: directory, encoding: 'utf8', env, input: 'standard input\n', @@ -84,7 +86,11 @@ printf '%s\\n' "$1|$input" printf 'unreachable\\n' `, ); - const errexitResult = spawnSync('sh', [generatedHook], { encoding: 'utf8', env }); + const errexitResult = spawnSync('sh', [generatedHook], { + cwd: directory, + encoding: 'utf8', + env, + }); expect(errexitResult.status).toBe(1); expect(errexitResult.stdout).toBe('Rstack - pre-commit hook failed (code 1)\n'); @@ -95,13 +101,21 @@ printf 'unreachable\\n' symlinkSync('/bin/sh', path.join(runtimeDirectory, 'sh')); symlinkSync('/bin/sh', fallbackNode); - const fallbackResult = spawnSync('sh', [generatedHook], { encoding: 'utf8', env }); + const fallbackResult = spawnSync('sh', [generatedHook], { + cwd: directory, + encoding: 'utf8', + env, + }); expect(fallbackResult.stdout).toBe(`${fallbackNode}\n`); const activeNode = path.join(runtimeDirectory, 'node'); symlinkSync('/bin/sh', activeNode); - const activeResult = spawnSync('sh', [generatedHook], { encoding: 'utf8', env }); + const activeResult = spawnSync('sh', [generatedHook], { + cwd: directory, + encoding: 'utf8', + env, + }); expect(activeResult.stdout).toBe(`${activeNode}\n`); }); }); diff --git a/packages/rstack/tests/setup/install.test.ts b/packages/rstack/tests/setup/install.test.ts index 44aef2a6..c1ca1cd5 100644 --- a/packages/rstack/tests/setup/install.test.ts +++ b/packages/rstack/tests/setup/install.test.ts @@ -12,6 +12,7 @@ test('installs generated hooks and configures the repository', () => { const directory = path.join(cwd, hooksPath); expect(readFileSync(path.join(directory, '.gitignore'), 'utf8')).toBe('*\n'); + expect(readFileSync(path.join(directory, '.owner'), 'utf8')).toBe('.\n'); expect(runGit(cwd, ['status', '--short', '--untracked-files=all'])).toBe(''); for (const [name, content] of Object.entries(createHookFiles())) { @@ -62,6 +63,29 @@ test('repairs generated files without rewriting an unchanged hooksPath', () => { }); }); +test('resolves repository context with a single Git process when unchanged', () => { + withRepository((cwd) => { + expect(installHooks({ cwd }).status).toBe('installed'); + const tracePath = path.join(cwd, 'git-trace.json'); + const originalTrace = process.env.GIT_TRACE2_EVENT; + process.env.GIT_TRACE2_EVENT = tracePath; + + try { + expect(installHooks({ cwd })).toEqual({ status: 'unchanged', hooksPath }); + } finally { + restoreEnv('GIT_TRACE2_EVENT', originalTrace); + } + + const starts = readFileSync(tracePath, 'utf8') + .trim() + .split('\n') + .map((line) => JSON.parse(line)) + .filter((event) => event.event === 'start'); + expect(starts).toHaveLength(1); + expect(starts[0].argv).toContain('rev-parse'); + }); +}); + test('skips non-Git directories without creating files', () => { withDirectory((cwd) => { expect(installHooks({ cwd })).toEqual({ @@ -112,3 +136,31 @@ test('reports Git configuration failures without changing hooksPath', () => { expect(existsSync(path.join(cwd, hooksPath, 'runner'))).toBe(true); }); }); + +test('does not replace another Git hooks path', () => { + withRepository((cwd) => { + runGit(cwd, ['config', '--local', 'core.hooksPath', '.husky/_']); + + expect(installHooks({ cwd })).toMatchObject({ + status: 'skipped', + reason: 'hooks-path-conflict', + }); + expect(runGit(cwd, ['config', '--local', '--get', 'core.hooksPath'])).toBe('.husky/_'); + expect(existsSync(path.join(cwd, hooksPath))).toBe(false); + }); +}); + +test('does not bypass existing Git hooks', () => { + withRepository((cwd) => { + const existingHook = path.join(cwd, '.git', 'hooks', 'pre-commit'); + writeFileSync(existingHook, '#!/usr/bin/env sh\n'); + + expect(installHooks({ cwd })).toEqual({ + status: 'skipped', + reason: 'existing-git-hooks', + message: 'existing Git hooks were found: pre-commit', + }); + expect(git(cwd, ['config', '--local', '--get', 'core.hooksPath']).status).toBe(1); + expect(readFileSync(existingHook, 'utf8')).toBe('#!/usr/bin/env sh\n'); + }); +}); diff --git a/packages/rstack/tests/setup/runtime.test.ts b/packages/rstack/tests/setup/runtime.test.ts index 4efb84e9..dcbac5b7 100644 --- a/packages/rstack/tests/setup/runtime.test.ts +++ b/packages/rstack/tests/setup/runtime.test.ts @@ -6,7 +6,8 @@ import { runHook, withRepository, writeHook, writeInit } from './helpers.ts'; test('loads user init and project binaries', () => { withRepository((cwd) => { - const binDirectory = path.join(cwd, 'node_modules', '.bin'); + const projectDirectory = path.join(cwd, 'frontend'); + const binDirectory = path.join(projectDirectory, 'node_modules', '.bin'); mkdirSync(binDirectory, { recursive: true }); writeInit(cwd, 'set -u\nexport RSTACK_INIT=loaded\n'); @@ -26,11 +27,11 @@ rstack-hook-command `, ); - expect(installHooks({ cwd }).status).toBe('installed'); + expect(installHooks({ cwd: projectDirectory }).status).toBe('installed'); expect(runHook(cwd).status).toBe(0); - expect(readFileSync(path.join(cwd, 'init-ran'), 'utf8')).toBe('loaded\n'); - expect(readFileSync(path.join(cwd, 'project-bin-ran'), 'utf8')).toBe('ran\n'); + expect(readFileSync(path.join(projectDirectory, 'init-ran'), 'utf8')).toBe('loaded\n'); + expect(readFileSync(path.join(projectDirectory, 'project-bin-ran'), 'utf8')).toBe('ran\n'); }); }); diff --git a/scripts/dictionary.txt b/scripts/dictionary.txt index 74ed3eac..61e10e63 100644 --- a/scripts/dictionary.txt +++ b/scripts/dictionary.txt @@ -1,5 +1,6 @@ # Custom Dictionary Words applypatch +cdpath clippy dirents errexit @@ -29,4 +30,5 @@ solidjs turborepo typicode worktank +worktree yuku diff --git a/website/docs/en/guide/cli/setup.mdx b/website/docs/en/guide/cli/setup.mdx index 186a46dd..e61403d4 100644 --- a/website/docs/en/guide/cli/setup.mdx +++ b/website/docs/en/guide/cli/setup.mdx @@ -2,7 +2,7 @@ import { PackageManagerTabs } from '@rspress/core/theme'; -The `rs setup` command installs project-local [Git hooks](https://git-scm.com/docs/githooks) in the current repository. +The `rs setup` command installs repository-level [Git hooks](https://git-scm.com/docs/githooks) and runs them in the project that invokes the command. ## Usage @@ -10,9 +10,9 @@ The `rs setup` command installs project-local [Git hooks](https://git-scm.com/do rs setup [options] ``` -By default, project hook scripts are stored in `.rstack/hooks`. If the current directory is not inside a Git repository, the command skips installation. +By default, hook scripts are stored in `.rstack/hooks`, relative to the Git repository root. If the current directory is not inside a Git repository, the command skips installation. -Add `rs setup` to the `prepare` script in the root `package.json` to automatically generate hook files when dependencies are installed: +Add `rs setup` to the `prepare` script of the project that should manage the repository hooks: ```json title="package.json" { @@ -41,7 +41,7 @@ rs staged :::warning Existing Git hook managers -`rs setup` updates the repository's [`core.hooksPath`](https://git-scm.com/docs/git-config#Documentation/git-config.txt-corehooksPath). If the repository already uses Husky or another Git hook manager, move the required hooks before running the command. +`rs setup` updates the repository's [`core.hooksPath`](https://git-scm.com/docs/git-config#Documentation/git-config.txt-corehooksPath). It skips installation when another hooks path or existing Git hook is detected. Migrate the required hooks and remove the existing hooks configuration before running the command. ::: @@ -49,7 +49,7 @@ rs staged ### `--hooks-dir` -Sets the directory for project hook scripts, relative to the current directory. +Sets the directory for hook scripts, relative to the Git repository root. ```bash rs setup --hooks-dir config/git-hooks @@ -58,7 +58,7 @@ rs setup --hooks-dir config/git-hooks rs setup --hooks-dir "config/git hooks" ``` -When using a custom directory, add the full command to the `prepare` script in the root `package.json`: +When using a custom directory, add the full command to the `prepare` script of the project that manages hooks: ```json title="package.json" { @@ -68,7 +68,7 @@ When using a custom directory, add the full command to the `prepare` script in t } ``` -> To prevent Git hook files from being created or overwritten outside the current project through parent directory paths, the path must not contain `..`. +> To prevent Git hook files from being created or overwritten outside the repository through parent directory paths, the path must not contain `..`. ### `--help` @@ -85,16 +85,17 @@ The default directory structure is: ```text .rstack/ └── hooks/ - ├── pre-commit # Project hook script: edit and commit + ├── pre-commit # Repository hook script: edit and commit └── _/ # Generated by rs setup; ignored by Git ├── .gitignore + ├── .owner ├── runner ├── pre-commit ├── commit-msg └── ... ``` -Files next to `_` are project hook scripts. The `_` directory contains generated files and is ignored by Git. `rs setup` points `core.hooksPath` to `.rstack/hooks/_`; rerun it after cloning the repository or when generated files are missing. +Files next to `_` are repository hook scripts. The `_` directory contains generated files and is ignored by Git. `rs setup` points `core.hooksPath` to `.rstack/hooks/_`; rerun it after cloning the repository or when generated files are missing. ## Supported hooks @@ -119,7 +120,7 @@ Create a file with the matching name next to the `_` directory. ## Hook runtime -Rstack runs hook scripts with POSIX `sh -e`, forwards Git's arguments and standard input, and returns the hook's exit code. It also prepends `node_modules/.bin` to `PATH`. +Rstack runs hook scripts with POSIX `sh -e`, forwards Git's arguments and standard input, and returns the hook's exit code. Before running a hook, it changes to the project that installed the hooks and prepends that project's `node_modules/.bin` to `PATH`. ### Disable and debug @@ -147,22 +148,23 @@ Use it to initialize a Node.js version manager, update `PATH`, or set `RSTACK_HO ## Monorepo -In a monorepo, a project may be located in a Git repository subdirectory, such as `frontend/`. When run from that directory, `rs setup` creates the hooks directory relative to the project and includes the project path in `core.hooksPath`: +In a monorepo, the project that provides Rstack may be located in a subdirectory such as `frontend/`. Running `rs setup` from that directory still installs hooks at the Git repository root: ```text -frontend/.rstack/hooks/ -frontend/.rstack/hooks/_/ -core.hooksPath=frontend/.rstack/hooks/_ +repo/.rstack/hooks/ +repo/.rstack/hooks/_/ +core.hooksPath=.rstack/hooks/_ ``` -Git runs hooks from the repository root. If the project is in a subdirectory, change to that directory in the hook script before running project commands: +Rstack records `frontend` as the project that owns the hooks. Hook scripts remain at the repository root, but run from `frontend`, so they can use its configuration and dependencies without an explicit `cd`: -```sh title="frontend/.rstack/hooks/pre-commit" -cd frontend -pnpm test +```sh title=".rstack/hooks/pre-commit" +rs staged ``` -A Git repository has one `core.hooksPath`, so choose either the repository root or one subproject to manage hooks. +A Git repository has one hooks owner. Only that project should include `rs setup` in its `prepare` script. Calls from another project are skipped with a warning. + +To change the owner, remove `rs setup` from the previous project's `prepare` script, delete the generated `_` directory, and then run `rs setup` from the new project. ## Remove hooks @@ -185,6 +187,8 @@ To remove Rstack-managed hooks: - Run `git config --local --get core.hooksPath` and verify the configured path. - Rerun `rs setup` to restore generated files and executable permissions. - Check that `RSTACK_HOOKS` is not set to `0` in the environment or initialization file. +- If another hooks setup is reported, migrate or remove the conflicting setup before rerunning the command. +- If another Rstack owner is reported, follow the ownership transfer steps in [Monorepo](#monorepo). Hook scripts do not need to be executable because Rstack runs them with `sh`. diff --git a/website/docs/en/guide/quick-start.mdx b/website/docs/en/guide/quick-start.mdx index bee941fa..d3a5d38b 100644 --- a/website/docs/en/guide/quick-start.mdx +++ b/website/docs/en/guide/quick-start.mdx @@ -62,7 +62,7 @@ The following commands are available: - [`rs test`](./cli/test): Run tests with Rstest. - [`rs lint`](./cli/lint): Lint source code with Rslint. - [`rs fmt`](./cli/fmt): Format code. -- [`rs setup`](./cli/setup): Install project-local Git hooks. +- [`rs setup`](./cli/setup): Install repository-level Git hooks. - [`rs staged`](./cli/staged): Run tasks against files staged in Git with lint-staged. ## Configure Rstack diff --git a/website/docs/zh/guide/cli/setup.mdx b/website/docs/zh/guide/cli/setup.mdx index ab78d95f..4144e9b6 100644 --- a/website/docs/zh/guide/cli/setup.mdx +++ b/website/docs/zh/guide/cli/setup.mdx @@ -2,7 +2,7 @@ import { PackageManagerTabs } from '@rspress/core/theme'; -`rs setup` 命令用于在当前 Git 仓库中安装项目级 [Git hooks](https://git-scm.com/docs/githooks)。 +`rs setup` 命令用于安装仓库级 [Git hooks](https://git-scm.com/docs/githooks),并在调用该命令的项目中运行 hooks。 ## 用法 \{#usage} @@ -10,9 +10,9 @@ import { PackageManagerTabs } from '@rspress/core/theme'; rs setup [options] ``` -项目 hook 脚本默认存放在 `.rstack/hooks`。如果当前目录不属于 Git 仓库,命令会跳过安装。 +hook 脚本默认存放在 Git 仓库根目录下的 `.rstack/hooks`。如果当前目录不属于 Git 仓库,命令会跳过安装。 -在根目录 `package.json` 的 `prepare` 脚本中添加 `rs setup`,即可在安装依赖时自动生成 hook 文件: +在负责管理仓库 hooks 的项目 `package.json` 中添加 `prepare` 脚本: ```json title="package.json" { @@ -41,7 +41,7 @@ rs staged :::warning 已有 Git hook 管理工具 -`rs setup` 会更新仓库的 [`core.hooksPath`](https://git-scm.com/docs/git-config#Documentation/git-config.txt-corehooksPath)。如果仓库已经使用 Husky 或其他 Git hook 管理工具,请先迁移所需的 hooks,再运行该命令。 +`rs setup` 会更新仓库的 [`core.hooksPath`](https://git-scm.com/docs/git-config#Documentation/git-config.txt-corehooksPath)。检测到其他 hooks 路径或已有 Git hook 时,命令会跳过安装。请先迁移所需的 hooks 并移除已有 hooks 配置,再运行该命令。 ::: @@ -49,7 +49,7 @@ rs staged ### `--hooks-dir` -设置项目 hook 脚本的存放目录,路径相对于命令的当前目录。 +设置 hook 脚本的存放目录,路径相对于 Git 仓库根目录。 ```bash rs setup --hooks-dir config/git-hooks @@ -58,7 +58,7 @@ rs setup --hooks-dir config/git-hooks rs setup --hooks-dir "config/git hooks" ``` -使用自定义目录时,请将完整命令写入根目录 `package.json` 的 `prepare` 脚本: +使用自定义目录时,请将完整命令写入负责管理 hooks 的项目 `package.json`: ```json title="package.json" { @@ -68,7 +68,7 @@ rs setup --hooks-dir "config/git hooks" } ``` -> 为避免通过父目录路径在当前项目之外创建或覆盖 Git hook 文件,路径中不能包含 `..`。 +> 为避免通过父目录路径在仓库之外创建或覆盖 Git hook 文件,路径中不能包含 `..`。 ### `--help` @@ -85,16 +85,17 @@ rs setup --help ```text .rstack/ └── hooks/ - ├── pre-commit # 项目 hook 脚本:编辑并提交 + ├── pre-commit # 仓库 hook 脚本:编辑并提交 └── _/ # 由 rs setup 生成;默认被 Git 忽略 ├── .gitignore + ├── .owner ├── runner ├── pre-commit ├── commit-msg └── ... ``` -与 `_` 同级的文件是项目 hook 脚本。`_` 目录包含生成文件,并由 Git 忽略。`rs setup` 会将 `core.hooksPath` 指向 `.rstack/hooks/_`;克隆仓库后或生成文件缺失时,请重新运行该命令。 +与 `_` 同级的文件是仓库 hook 脚本。`_` 目录包含生成文件,并由 Git 忽略。`rs setup` 会将 `core.hooksPath` 指向 `.rstack/hooks/_`;克隆仓库后或生成文件缺失时,请重新运行该命令。 ## 支持的 hooks \{#supported-hooks} @@ -119,7 +120,7 @@ Rstack 支持以下客户端 Git hooks: ## Hook 运行时 \{#hook-runtime} -Rstack 使用 POSIX `sh -e` 运行 hook 脚本,并转发 Git 提供的参数和标准输入,同时返回 hook 的退出码。运行时还会将 `node_modules/.bin` 添加到 `PATH` 开头。 +Rstack 使用 POSIX `sh -e` 运行 hook 脚本,并转发 Git 提供的参数和标准输入,同时返回 hook 的退出码。运行 hook 前,Rstack 会切换到安装 hooks 的项目,并将该项目的 `node_modules/.bin` 添加到 `PATH` 开头。 ### 禁用与调试 \{#disable-and-debug} @@ -147,22 +148,23 @@ ${XDG_CONFIG_HOME:-$HOME/.config}/rstack/hooks-init.sh ## Monorepo \{#monorepo} -在 monorepo 中,项目可能位于 Git 仓库的子目录,例如 `frontend/`。从该目录运行 `rs setup` 时,hooks 目录会相对于项目创建,`core.hooksPath` 也会包含项目路径: +在 monorepo 中,提供 Rstack 的项目可能位于 `frontend/` 等子目录。从该目录运行 `rs setup` 时,hooks 仍会安装到 Git 仓库根目录: ```text -frontend/.rstack/hooks/ -frontend/.rstack/hooks/_/ -core.hooksPath=frontend/.rstack/hooks/_ +repo/.rstack/hooks/ +repo/.rstack/hooks/_/ +core.hooksPath=.rstack/hooks/_ ``` -Git 会从仓库根目录运行 hook。如果项目位于子目录,请在 hook 脚本中先切换到该目录,再执行项目命令: +Rstack 会将 `frontend` 记录为负责管理 hooks 的项目。hook 脚本仍位于仓库根目录,但会从 `frontend` 目录运行,因此可以直接使用其中的配置和依赖,无需显式执行 `cd`: -```sh title="frontend/.rstack/hooks/pre-commit" -cd frontend -pnpm test +```sh title=".rstack/hooks/pre-commit" +rs staged ``` -一个 Git 仓库只有一个 `core.hooksPath`,因此应选择仓库根目录或其中一个子项目统一管理 hooks。 +一个 Git 仓库只能有一个 hooks owner。只有负责管理 hooks 的项目应在 `prepare` 脚本中调用 `rs setup`。其他项目调用时会收到警告并跳过。 + +如需更换 owner,请先从原项目的 `prepare` 脚本中移除 `rs setup`,删除生成的 `_` 目录,再从新项目运行 `rs setup`。 ## 移除 hooks \{#remove-hooks} @@ -185,6 +187,8 @@ pnpm test - 运行 `git config --local --get core.hooksPath`,检查配置的路径。 - 重新运行 `rs setup`,恢复生成文件及其可执行权限。 - 检查环境变量或初始化文件中是否设置了 `RSTACK_HOOKS=0`。 +- 如果命令提示存在其他 hooks 配置,请先迁移或移除冲突配置,再重新运行该命令。 +- 如果命令提示存在其他 Rstack owner,请按照 [Monorepo](#monorepo) 中的步骤转移 owner。 hook 脚本不需要可执行权限,因为 Rstack 会使用 `sh` 运行它。 diff --git a/website/docs/zh/guide/quick-start.mdx b/website/docs/zh/guide/quick-start.mdx index 6927d123..00fbe0f6 100644 --- a/website/docs/zh/guide/quick-start.mdx +++ b/website/docs/zh/guide/quick-start.mdx @@ -62,7 +62,7 @@ Rstack 提供以下命令: - [`rs test`](./cli/test):使用 Rstest 运行测试。 - [`rs lint`](./cli/lint):使用 Rslint 检查源代码。 - [`rs fmt`](./cli/fmt):格式化代码。 -- [`rs setup`](./cli/setup):安装项目本地 Git hooks。 +- [`rs setup`](./cli/setup):安装仓库级 Git hooks。 - [`rs staged`](./cli/staged):使用 lint-staged 对 Git 暂存区中的文件运行任务。 ## 配置 Rstack \{#configure-rstack}