From ce5599329654cf1a340fe0c7534b8d2036f0db39 Mon Sep 17 00:00:00 2001 From: neverland Date: Mon, 10 Aug 2026 13:11:22 +0800 Subject: [PATCH 1/5] fix(setup): improve monorepo hook handling --- packages/rstack/src/setup/hooks.ts | 17 +- packages/rstack/src/setup/index.ts | 7 +- packages/rstack/src/setup/install.ts | 256 +++++++++++++++--- packages/rstack/tests/cli/setup/index.test.ts | 29 +- .../rstack/tests/setup/directories.test.ts | 92 +++++-- packages/rstack/tests/setup/hooks.test.ts | 25 +- packages/rstack/tests/setup/install.test.ts | 65 +++++ packages/rstack/tests/setup/runtime.test.ts | 22 ++ scripts/dictionary.txt | 2 + website/docs/en/guide/cli/setup.mdx | 42 +-- website/docs/en/guide/quick-start.mdx | 2 +- website/docs/zh/guide/cli/setup.mdx | 42 +-- website/docs/zh/guide/quick-start.mdx | 2 +- 13 files changed, 488 insertions(+), 115 deletions(-) diff --git a/packages/rstack/src/setup/hooks.ts b/packages/rstack/src/setup/hooks.ts index 518d4789..bfaeec0e 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,14 +30,13 @@ 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" - +name=\${0##*/} +root=$PWD +generated_dir=$(CDPATH= cd "$(dirname "$0")" && pwd) || exit 1 +hook="\${generated_dir%/*}/$name" [ -f "$hook" ] || exit 0 init="\${XDG_CONFIG_HOME:-$HOME/.config}/rstack/hooks-init.sh" @@ -46,6 +45,9 @@ init="\${XDG_CONFIG_HOME:-$HOME/.config}/rstack/hooks-init.sh" [ "\${RSTACK_HOOKS-}" = "0" ] && exit 0 [ "\${RSTACK_HOOKS-}" = "2" ] && set -x +IFS= read -r project_path < "$generated_dir/.owner" || exit 1 +[ -n "$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)} @@ -53,6 +55,7 @@ if ! command -v node >/dev/null 2>&1 && [ -x "$node_fallback" ]; then PATH="\${PATH:+$PATH:}\${node_fallback%/*}" fi +cd "$root/$project_path" || exit 1 export PATH="node_modules/.bin\${PATH:+:$PATH}" code=0 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..6dacbe0f 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,40 @@ 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; +}; + +type GeneratedDirectoryState = + { kind: 'empty' } | { kind: 'foreign' } | { kind: 'owned'; project: 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 +71,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 +86,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 +97,64 @@ 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 (repository.status !== 0) { + if (insideWorkTree.trim() === 'true') { + return fail( + 'git-command-failed', + `Failed to resolve the Git repository paths: ${repository.stderr.trim()}`, + ); + } + return skip('not-git-repository'); + } + + if (insideWorkTree.trim() !== 'true') { + return skip('not-git-repository'); + } + + if ( + gitRoot === undefined || + repositoryPrefix === undefined || + gitCommonDirectory === undefined || + effectiveHooksDirectory === undefined + ) { + return fail('git-command-failed', 'Failed to resolve the Git repository paths.'); + } + + const normalizedPrefix = repositoryPrefix.replaceAll('\\', '/').replace(/\/$/u, ''); + + return { + defaultHooksDirectory: path.join(gitCommonDirectory, 'hooks'), + effectiveHooksDirectory, + gitRoot, + projectPath: normalizedPrefix || '.', + }; +}; + 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 +167,99 @@ const isCurrentFile = (filePath: string, content: string, executable = false): b } }; +const isSamePath = (first: string, second: string): boolean => + path.resolve(first) === path.resolve(second); + +const ownerContent = (project: string): string => `${project}\n`; + +const readGeneratedDirectoryState = (directory: string): GeneratedDirectoryState => { + let entries: string[]; + try { + entries = readdirSync(directory); + } catch { + return { kind: 'empty' }; + } + + if (entries.includes(ownerFileName)) { + try { + const project = removeLineEnding(readFileSync(path.join(directory, ownerFileName), 'utf8')); + return project.length > 0 && !project.includes('\n') && !project.includes('\r') + ? { kind: 'owned', project } + : { kind: 'foreign' }; + } catch { + return { kind: 'foreign' }; + } + } + + return entries.every((entry) => entry === '.gitignore') ? { kind: 'empty' } : { kind: 'foreign' }; +}; + +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 content = ownerContent(project); + const state = readGeneratedDirectoryState(directory); + + if (state.kind === 'owned' && state.project !== project) { + return ownerConflict(state.project); + } + if (state.kind === 'foreign') { + return directoryConflict(gitRoot, directory); + } + + if (state.kind === 'owned') { + writeFileSync(ownerPath, content); + return undefined; + } + + try { + // Exclusive creation makes concurrent prepare scripts agree on one owner. + writeFileSync(ownerPath, content, { flag: 'wx' }); + } catch (error) { + const code = error instanceof Error && 'code' in error ? error.code : undefined; + if (code !== 'EEXIST') { + throw error; + } + + const concurrentState = readGeneratedDirectoryState(directory); + if (concurrentState.kind === 'owned' && concurrentState.project === project) { + return undefined; + } + if (concurrentState.kind === 'owned') { + return ownerConflict(concurrentState.project); + } + return directoryConflict(gitRoot, directory); + } + + 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,48 +268,47 @@ 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 activeState = readGeneratedDirectoryState(effectiveHooksDirectory); + if (activeState.kind === 'owned') { + if (activeState.project !== projectPath) { + return ownerConflict(activeState.project); + } + } else { + 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 (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 expectedOwner = ownerContent(projectPath); + // Skip all writes only when the config, owner, generated content, and executable modes match. const unchanged = hooksPathMatches && + isCurrentFile(path.join(directory, ownerFileName), expectedOwner) && isCurrentFile(path.join(directory, '.gitignore'), gitignore) && files.every(([name, content]) => isCurrentFile(path.join(directory, name), content, true)); @@ -139,6 +318,11 @@ export const installHooks = ({ try { mkdirSync(directory, { recursive: true }); + const ownerResult = claimOwner(directory, gitRoot, projectPath); + if (ownerResult) { + return ownerResult; + } + 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..b3cb1901 100644 --- a/packages/rstack/tests/cli/setup/index.test.ts +++ b/packages/rstack/tests/cli/setup/index.test.ts @@ -51,11 +51,13 @@ test('displays setup help', ({ execCli, expect }) => { expect(execCli('setup -h', { cwd })).toBe(output); expect(output).toContain('Usage:\n $ rs setup [options]'); expect(output).toContain('--hooks-dir '); + expect(output).not.toContain('--force'); expect(output).toContain('-h, --help'); }); test('rejects unknown setup options', ({ execCli, expect }) => { expect(() => execCli('setup --unknown', { cwd })).toThrow(); + expect(() => execCli('setup --force', { cwd })).toThrow(); }); test('reports missing and repeated hooks directory options', ({ expect }) => { @@ -76,7 +78,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 +98,33 @@ 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 a root-relative custom hooks directory from a nested project', ({ + 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); + expect(git(['config', '--local', '--get', 'core.hooksPath'])).toBe('custom hooks/_'); + expect(existsSync(path.join(cwd, 'custom hooks', '_', 'runner'))).toBe(true); + expect(existsSync(path.join(projectDirectory, 'custom hooks'))).toBe(false); +}); + +test('reports a different project owner without replacing it', ({ execCli, expect }) => { + initRepository(); + const frontend = path.join(cwd, 'frontend'); + const docs = path.join(cwd, 'docs'); + mkdirSync(frontend); + mkdirSync(docs); + + expect(execCli('setup', { cwd: frontend, env })).toBe(''); + const conflict = runSetup([], 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..154662cc 100644 --- a/packages/rstack/tests/setup/directories.test.ts +++ b/packages/rstack/tests/setup/directories.test.ts @@ -22,52 +22,100 @@ 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(existsSync(path.join(projectDirectory, '.rstack'))).toBe(false); + 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); + writeHook(cwd, "printf 'ran\\n' > custom-hook-ran\n", 'config/hooks'); 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); + expect(existsSync(path.join(projectDirectory, 'config'))).toBe(false); + expect(runHook(cwd).status).toBe(0); + expect(readFileSync(path.join(projectDirectory, 'custom-hook-ran'), 'utf8')).toBe('ran\n'); + }); +}); + +test('does not replace another Rstack project owner', () => { + withRepository((cwd) => { + const frontend = path.join(cwd, 'frontend'); + const docs = path.join(cwd, 'docs'); + mkdirSync(frontend); + mkdirSync(docs); + writeHook(cwd, 'printf \'%s\\n\' "$PWD" > hook-cwd\n'); + + expect(installHooks({ cwd: frontend }).status).toBe('installed'); + expect(installHooks({ cwd: docs })).toEqual({ + status: 'skipped', + reason: 'owned-by-another-project', + message: 'Git hooks are already managed by Rstack project "frontend"', + }); + expect(readFileSync(path.join(cwd, hooksPath, '.owner'), 'utf8')).toBe('frontend\n'); + + expect(runHook(cwd).status).toBe(0); + expect(readFileSync(path.join(frontend, 'hook-cwd'), 'utf8')).toBe(`${frontend}\n`); + expect(existsSync(path.join(docs, 'hook-cwd'))).toBe(false); + }); +}); + +test('installs generated hooks relative to the current worktree', () => { + withRepository((cwd) => { + runGit(cwd, [ + '-c', + 'user.name=Rstack', + '-c', + 'user.email=rstack@example.com', + 'commit', + '--allow-empty', + '--quiet', + '-m', + 'Initial commit', + ]); + const worktree = path.join(cwd, 'linked', 'secondary'); + mkdirSync(path.dirname(worktree), { recursive: true }); + runGit(cwd, ['worktree', 'add', '--quiet', '-b', 'secondary', worktree]); + + const projectDirectory = path.join(worktree, 'frontend'); + mkdirSync(projectDirectory); + writeHook(worktree, "printf 'ran\\n' > worktree-hook-ran\n"); + + expect(installHooks({ cwd: projectDirectory }).status).toBe('installed'); + expect(existsSync(path.join(worktree, hooksPath, 'runner'))).toBe(true); + expect(existsSync(path.join(cwd, hooksPath, 'runner'))).toBe(false); + + expect(runHook(worktree).status).toBe(0); + expect(readFileSync(path.join(projectDirectory, 'worktree-hook-ran'), 'utf8')).toBe('ran\n'); }); }); diff --git a/packages/rstack/tests/setup/hooks.test.ts b/packages/rstack/tests/setup/hooks.test.ts index 882e1b60..e4ff6292 100644 --- a/packages/rstack/tests/setup/hooks.test.ts +++ b/packages/rstack/tests/setup/hooks.test.ts @@ -43,7 +43,8 @@ test.runIf(process.platform !== 'win32')('preserves backslashes in POSIX Node pa test.runIf(process.platform !== 'win32')('runs generated hooks', () => { withDirectory((directory) => { - const hooksDirectory = path.join(directory, "hooks with ' quotes"); + const hooksDir = "hooks with ' quotes"; + const hooksDirectory = path.join(directory, hooksDir); const generatedDirectory = path.join(hooksDirectory, '_'); const generatedHook = path.join(generatedDirectory, 'pre-commit'); const userHook = path.join(hooksDirectory, 'pre-commit'); @@ -58,10 +59,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 +72,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 +87,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 +102,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..f1d953e3 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,44 @@ 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 override a global Git hooks path', () => { + withRepository((cwd) => { + runGit(cwd, ['config', '--global', 'core.hooksPath', 'global-hooks']); + + expect(installHooks({ cwd })).toMatchObject({ + status: 'skipped', + reason: 'hooks-path-conflict', + }); + expect(git(cwd, ['config', '--local', '--get', 'core.hooksPath']).status).toBe(1); + expect(runGit(cwd, ['config', '--global', '--get', 'core.hooksPath'])).toBe('global-hooks'); + }); +}); + +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..ae48b8ea 100644 --- a/packages/rstack/tests/setup/runtime.test.ts +++ b/packages/rstack/tests/setup/runtime.test.ts @@ -34,6 +34,28 @@ rstack-hook-command }); }); +test('loads binaries from a nested project while running the root hook', () => { + withRepository((cwd) => { + const projectDirectory = path.join(cwd, 'frontend'); + const binDirectory = path.join(projectDirectory, 'node_modules', '.bin'); + mkdirSync(binDirectory, { recursive: true }); + + const command = path.join(binDirectory, 'rstack-hook-command'); + writeFileSync( + command, + `#!/usr/bin/env sh +printf 'ran\n' > project-bin-ran +`, + ); + chmodSync(command, 0o755); + writeHook(cwd, 'rstack-hook-command\n'); + + expect(installHooks({ cwd: projectDirectory }).status).toBe('installed'); + expect(runHook(cwd).status).toBe(0); + expect(readFileSync(path.join(projectDirectory, 'project-bin-ran'), 'utf8')).toBe('ran\n'); + }); +}); + test('skips user hooks when disabled by the environment or init', () => { withRepository((cwd) => { writeHook(cwd, 'echo ran >> hook-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..aab474d6 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. Hook scripts next to `_` are preserved. ## 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..87f5ee88 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`。与 `_` 同级的 hook 脚本会被保留。 ## 移除 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} From 43d6be6f84a99521ddb45b60830d6a638abc7a53 Mon Sep 17 00:00:00 2001 From: neverland Date: Mon, 10 Aug 2026 14:23:23 +0800 Subject: [PATCH 2/5] refactor(setup): refine generated hook internals --- packages/rstack/src/setup/hooks.ts | 38 ++++++++++----------- packages/rstack/src/setup/install.ts | 6 ++-- packages/rstack/tests/setup/hooks.test.ts | 4 +-- packages/rstack/tests/setup/install.test.ts | 31 +++++++++++++++++ 4 files changed, 55 insertions(+), 24 deletions(-) diff --git a/packages/rstack/src/setup/hooks.ts b/packages/rstack/src/setup/hooks.ts index bfaeec0e..ccc22534 100644 --- a/packages/rstack/src/setup/hooks.ts +++ b/packages/rstack/src/setup/hooks.ts @@ -33,43 +33,43 @@ const quoteShellPath = (value: string): string => { const createDispatcher = (nodeExecutable: string): string => `#!/usr/bin/env sh # Generated by Rstack. Do not edit. -name=\${0##*/} -root=$PWD -generated_dir=$(CDPATH= cd "$(dirname "$0")" && pwd) || exit 1 -hook="\${generated_dir%/*}/$name" -[ -f "$hook" ] || exit 0 +rs_name=\${0##*/} +rs_root=$PWD +rs_hook="\${rs_dir%/*}/$rs_name" +[ -f "$rs_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 project_path < "$generated_dir/.owner" || exit 1 -[ -n "$project_path" ] || exit 1 +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 "$root/$project_path" || exit 1 +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/install.ts b/packages/rstack/src/setup/install.ts index 6dacbe0f..bb6699ee 100644 --- a/packages/rstack/src/setup/install.ts +++ b/packages/rstack/src/setup/install.ts @@ -182,8 +182,9 @@ const readGeneratedDirectoryState = (directory: string): GeneratedDirectoryState if (entries.includes(ownerFileName)) { try { - const project = removeLineEnding(readFileSync(path.join(directory, ownerFileName), 'utf8')); - return project.length > 0 && !project.includes('\n') && !project.includes('\r') + const content = readFileSync(path.join(directory, ownerFileName), 'utf8'); + const project = removeLineEnding(content); + return content === ownerContent(project) && project.length > 0 && !/[\r\n]/u.test(project) ? { kind: 'owned', project } : { kind: 'foreign' }; } catch { @@ -225,7 +226,6 @@ const claimOwner = ( } if (state.kind === 'owned') { - writeFileSync(ownerPath, content); return undefined; } diff --git a/packages/rstack/tests/setup/hooks.test.ts b/packages/rstack/tests/setup/hooks.test.ts index e4ff6292..4e495bf4 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', () => { diff --git a/packages/rstack/tests/setup/install.test.ts b/packages/rstack/tests/setup/install.test.ts index f1d953e3..65fd9d29 100644 --- a/packages/rstack/tests/setup/install.test.ts +++ b/packages/rstack/tests/setup/install.test.ts @@ -63,6 +63,37 @@ test('repairs generated files without rewriting an unchanged hooksPath', () => { }); }); +test.runIf(process.platform !== 'win32')('repairs files without rewriting the owner', () => { + withRepository((cwd) => { + expect(installHooks({ cwd }).status).toBe('installed'); + const directory = path.join(cwd, hooksPath); + const owner = path.join(directory, '.owner'); + writeFileSync(path.join(directory, 'runner'), 'stale\n'); + chmodSync(owner, 0o444); + + try { + expect(installHooks({ cwd })).toEqual({ status: 'installed', hooksPath }); + } finally { + chmodSync(owner, 0o644); + } + }); +}); + +test('does not claim an owner file without a trailing newline', () => { + withRepository((cwd) => { + const directory = path.join(cwd, hooksPath); + const owner = path.join(directory, '.owner'); + mkdirSync(directory, { recursive: true }); + writeFileSync(owner, '.'); + + expect(installHooks({ cwd })).toMatchObject({ + status: 'skipped', + reason: 'hooks-directory-conflict', + }); + expect(readFileSync(owner, 'utf8')).toBe('.'); + }); +}); + test('resolves repository context with a single Git process when unchanged', () => { withRepository((cwd) => { expect(installHooks({ cwd }).status).toBe('installed'); From c66ec4bb47de4987b340dab1922af9beae82970c Mon Sep 17 00:00:00 2001 From: neverland Date: Mon, 10 Aug 2026 14:46:22 +0800 Subject: [PATCH 3/5] test(setup): simplify hook coverage --- packages/rstack/tests/cli/setup/index.test.ts | 25 ++------ .../rstack/tests/setup/directories.test.ts | 57 ------------------- packages/rstack/tests/setup/hooks.test.ts | 3 +- packages/rstack/tests/setup/install.test.ts | 44 -------------- packages/rstack/tests/setup/runtime.test.ts | 29 ++-------- 5 files changed, 11 insertions(+), 147 deletions(-) diff --git a/packages/rstack/tests/cli/setup/index.test.ts b/packages/rstack/tests/cli/setup/index.test.ts index b3cb1901..077f032b 100644 --- a/packages/rstack/tests/cli/setup/index.test.ts +++ b/packages/rstack/tests/cli/setup/index.test.ts @@ -51,13 +51,11 @@ test('displays setup help', ({ execCli, expect }) => { expect(execCli('setup -h', { cwd })).toBe(output); expect(output).toContain('Usage:\n $ rs setup [options]'); expect(output).toContain('--hooks-dir '); - expect(output).not.toContain('--force'); expect(output).toContain('-h, --help'); }); test('rejects unknown setup options', ({ execCli, expect }) => { expect(() => execCli('setup --unknown', { cwd })).toThrow(); - expect(() => execCli('setup --force', { cwd })).toThrow(); }); test('reports missing and repeated hooks directory options', ({ expect }) => { @@ -98,29 +96,18 @@ test('installs hooks silently without loading Rstack config', ({ execCli, expect expect(execCli('setup', { cwd, env })).toBe(''); }); -test('installs a root-relative custom hooks directory from a nested project', ({ - 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('custom hooks/_'); - expect(existsSync(path.join(cwd, 'custom hooks', '_', 'runner'))).toBe(true); - expect(existsSync(path.join(projectDirectory, 'custom hooks'))).toBe(false); -}); - -test('reports a different project owner without replacing it', ({ execCli, expect }) => { +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('setup', { cwd: frontend, env })).toBe(''); - const conflict = runSetup([], 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"', diff --git a/packages/rstack/tests/setup/directories.test.ts b/packages/rstack/tests/setup/directories.test.ts index 154662cc..dfaf62da 100644 --- a/packages/rstack/tests/setup/directories.test.ts +++ b/packages/rstack/tests/setup/directories.test.ts @@ -38,7 +38,6 @@ test('installs repository-level hooks from a nested project', () => { }); expect(runGit(cwd, ['config', '--local', '--get', 'core.hooksPath'])).toBe(hooksPath); expect(existsSync(path.join(cwd, hooksPath, 'runner'))).toBe(true); - expect(existsSync(path.join(projectDirectory, '.rstack'))).toBe(false); expect(readFileSync(path.join(cwd, hooksPath, '.owner'), 'utf8')).toBe('frontend\n'); expect(runHook(cwd).status).toBe(0); @@ -50,7 +49,6 @@ test('installs a root-relative custom hooks directory from a nested project', () withRepository((cwd) => { const projectDirectory = path.join(cwd, 'frontend app'); mkdirSync(projectDirectory); - writeHook(cwd, "printf 'ran\\n' > custom-hook-ran\n", 'config/hooks'); expect(installHooks({ cwd: projectDirectory, hooksDir: 'config\\hooks' })).toEqual({ status: 'installed', @@ -62,60 +60,5 @@ test('installs a root-relative custom hooks directory from a nested project', () }); expect(runGit(cwd, ['config', '--local', '--get', 'core.hooksPath'])).toBe('config/hooks/_'); expect(existsSync(path.join(cwd, 'config', 'hooks', '_', 'runner'))).toBe(true); - expect(existsSync(path.join(projectDirectory, 'config'))).toBe(false); - expect(runHook(cwd).status).toBe(0); - expect(readFileSync(path.join(projectDirectory, 'custom-hook-ran'), 'utf8')).toBe('ran\n'); - }); -}); - -test('does not replace another Rstack project owner', () => { - withRepository((cwd) => { - const frontend = path.join(cwd, 'frontend'); - const docs = path.join(cwd, 'docs'); - mkdirSync(frontend); - mkdirSync(docs); - writeHook(cwd, 'printf \'%s\\n\' "$PWD" > hook-cwd\n'); - - expect(installHooks({ cwd: frontend }).status).toBe('installed'); - expect(installHooks({ cwd: docs })).toEqual({ - status: 'skipped', - reason: 'owned-by-another-project', - message: 'Git hooks are already managed by Rstack project "frontend"', - }); - expect(readFileSync(path.join(cwd, hooksPath, '.owner'), 'utf8')).toBe('frontend\n'); - - expect(runHook(cwd).status).toBe(0); - expect(readFileSync(path.join(frontend, 'hook-cwd'), 'utf8')).toBe(`${frontend}\n`); - expect(existsSync(path.join(docs, 'hook-cwd'))).toBe(false); - }); -}); - -test('installs generated hooks relative to the current worktree', () => { - withRepository((cwd) => { - runGit(cwd, [ - '-c', - 'user.name=Rstack', - '-c', - 'user.email=rstack@example.com', - 'commit', - '--allow-empty', - '--quiet', - '-m', - 'Initial commit', - ]); - const worktree = path.join(cwd, 'linked', 'secondary'); - mkdirSync(path.dirname(worktree), { recursive: true }); - runGit(cwd, ['worktree', 'add', '--quiet', '-b', 'secondary', worktree]); - - const projectDirectory = path.join(worktree, 'frontend'); - mkdirSync(projectDirectory); - writeHook(worktree, "printf 'ran\\n' > worktree-hook-ran\n"); - - expect(installHooks({ cwd: projectDirectory }).status).toBe('installed'); - expect(existsSync(path.join(worktree, hooksPath, 'runner'))).toBe(true); - expect(existsSync(path.join(cwd, hooksPath, 'runner'))).toBe(false); - - expect(runHook(worktree).status).toBe(0); - expect(readFileSync(path.join(projectDirectory, 'worktree-hook-ran'), 'utf8')).toBe('ran\n'); }); }); diff --git a/packages/rstack/tests/setup/hooks.test.ts b/packages/rstack/tests/setup/hooks.test.ts index 4e495bf4..14cb8eb2 100644 --- a/packages/rstack/tests/setup/hooks.test.ts +++ b/packages/rstack/tests/setup/hooks.test.ts @@ -43,8 +43,7 @@ test.runIf(process.platform !== 'win32')('preserves backslashes in POSIX Node pa test.runIf(process.platform !== 'win32')('runs generated hooks', () => { withDirectory((directory) => { - const hooksDir = "hooks with ' quotes"; - const hooksDirectory = path.join(directory, hooksDir); + const hooksDirectory = path.join(directory, "hooks with ' quotes"); const generatedDirectory = path.join(hooksDirectory, '_'); const generatedHook = path.join(generatedDirectory, 'pre-commit'); const userHook = path.join(hooksDirectory, 'pre-commit'); diff --git a/packages/rstack/tests/setup/install.test.ts b/packages/rstack/tests/setup/install.test.ts index 65fd9d29..c1ca1cd5 100644 --- a/packages/rstack/tests/setup/install.test.ts +++ b/packages/rstack/tests/setup/install.test.ts @@ -63,37 +63,6 @@ test('repairs generated files without rewriting an unchanged hooksPath', () => { }); }); -test.runIf(process.platform !== 'win32')('repairs files without rewriting the owner', () => { - withRepository((cwd) => { - expect(installHooks({ cwd }).status).toBe('installed'); - const directory = path.join(cwd, hooksPath); - const owner = path.join(directory, '.owner'); - writeFileSync(path.join(directory, 'runner'), 'stale\n'); - chmodSync(owner, 0o444); - - try { - expect(installHooks({ cwd })).toEqual({ status: 'installed', hooksPath }); - } finally { - chmodSync(owner, 0o644); - } - }); -}); - -test('does not claim an owner file without a trailing newline', () => { - withRepository((cwd) => { - const directory = path.join(cwd, hooksPath); - const owner = path.join(directory, '.owner'); - mkdirSync(directory, { recursive: true }); - writeFileSync(owner, '.'); - - expect(installHooks({ cwd })).toMatchObject({ - status: 'skipped', - reason: 'hooks-directory-conflict', - }); - expect(readFileSync(owner, 'utf8')).toBe('.'); - }); -}); - test('resolves repository context with a single Git process when unchanged', () => { withRepository((cwd) => { expect(installHooks({ cwd }).status).toBe('installed'); @@ -181,19 +150,6 @@ test('does not replace another Git hooks path', () => { }); }); -test('does not override a global Git hooks path', () => { - withRepository((cwd) => { - runGit(cwd, ['config', '--global', 'core.hooksPath', 'global-hooks']); - - expect(installHooks({ cwd })).toMatchObject({ - status: 'skipped', - reason: 'hooks-path-conflict', - }); - expect(git(cwd, ['config', '--local', '--get', 'core.hooksPath']).status).toBe(1); - expect(runGit(cwd, ['config', '--global', '--get', 'core.hooksPath'])).toBe('global-hooks'); - }); -}); - test('does not bypass existing Git hooks', () => { withRepository((cwd) => { const existingHook = path.join(cwd, '.git', 'hooks', 'pre-commit'); diff --git a/packages/rstack/tests/setup/runtime.test.ts b/packages/rstack/tests/setup/runtime.test.ts index ae48b8ea..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,32 +27,10 @@ rstack-hook-command `, ); - expect(installHooks({ cwd }).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'); - }); -}); - -test('loads binaries from a nested project while running the root hook', () => { - withRepository((cwd) => { - const projectDirectory = path.join(cwd, 'frontend'); - const binDirectory = path.join(projectDirectory, 'node_modules', '.bin'); - mkdirSync(binDirectory, { recursive: true }); - - const command = path.join(binDirectory, 'rstack-hook-command'); - writeFileSync( - command, - `#!/usr/bin/env sh -printf 'ran\n' > project-bin-ran -`, - ); - chmodSync(command, 0o755); - writeHook(cwd, 'rstack-hook-command\n'); - expect(installHooks({ cwd: projectDirectory }).status).toBe('installed'); + expect(runHook(cwd).status).toBe(0); + expect(readFileSync(path.join(projectDirectory, 'init-ran'), 'utf8')).toBe('loaded\n'); expect(readFileSync(path.join(projectDirectory, 'project-bin-ran'), 'utf8')).toBe('ran\n'); }); }); From 2a5eae3a0c0b480c9a55e96013f2d8fd7b5915e7 Mon Sep 17 00:00:00 2001 From: neverland Date: Mon, 10 Aug 2026 14:50:47 +0800 Subject: [PATCH 4/5] docs(setup): remove hook preservation note --- website/docs/en/guide/cli/setup.mdx | 2 +- website/docs/zh/guide/cli/setup.mdx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/website/docs/en/guide/cli/setup.mdx b/website/docs/en/guide/cli/setup.mdx index aab474d6..e61403d4 100644 --- a/website/docs/en/guide/cli/setup.mdx +++ b/website/docs/en/guide/cli/setup.mdx @@ -164,7 +164,7 @@ rs staged 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. Hook scripts next to `_` are preserved. +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 diff --git a/website/docs/zh/guide/cli/setup.mdx b/website/docs/zh/guide/cli/setup.mdx index 87f5ee88..4144e9b6 100644 --- a/website/docs/zh/guide/cli/setup.mdx +++ b/website/docs/zh/guide/cli/setup.mdx @@ -164,7 +164,7 @@ rs staged 一个 Git 仓库只能有一个 hooks owner。只有负责管理 hooks 的项目应在 `prepare` 脚本中调用 `rs setup`。其他项目调用时会收到警告并跳过。 -如需更换 owner,请先从原项目的 `prepare` 脚本中移除 `rs setup`,删除生成的 `_` 目录,再从新项目运行 `rs setup`。与 `_` 同级的 hook 脚本会被保留。 +如需更换 owner,请先从原项目的 `prepare` 脚本中移除 `rs setup`,删除生成的 `_` 目录,再从新项目运行 `rs setup`。 ## 移除 hooks \{#remove-hooks} From 3e64a503243a835b09f4e7d3e08b53939ac5dbd4 Mon Sep 17 00:00:00 2001 From: neverland Date: Mon, 10 Aug 2026 15:36:58 +0800 Subject: [PATCH 5/5] refactor(setup): simplify hook installation --- packages/rstack/src/setup/install.ts | 123 ++++++++++----------------- 1 file changed, 43 insertions(+), 80 deletions(-) diff --git a/packages/rstack/src/setup/install.ts b/packages/rstack/src/setup/install.ts index bb6699ee..4084d270 100644 --- a/packages/rstack/src/setup/install.ts +++ b/packages/rstack/src/setup/install.ts @@ -46,9 +46,6 @@ type GitContext = { projectPath: string; }; -type GeneratedDirectoryState = - { kind: 'empty' } | { kind: 'foreign' } | { kind: 'owned'; project: string }; - const fail = (reason: string, message: string): FailedInstallResult => ({ status: 'failed', reason, @@ -116,42 +113,32 @@ const resolveGitContext = (cwd: string): GitContext | InstallResult => { const [ insideWorkTree = '', - gitRoot, - repositoryPrefix, - gitCommonDirectory, - effectiveHooksDirectory, + gitRoot = '', + repositoryPrefix = '', + gitCommonDirectory = '', + effectiveHooksDirectory = '', ] = removeLineEnding(repository.stdout).split(/\r?\n/u); - if (repository.status !== 0) { - if (insideWorkTree.trim() === 'true') { - return fail( - 'git-command-failed', - `Failed to resolve the Git repository paths: ${repository.stderr.trim()}`, - ); - } + if (insideWorkTree !== 'true') { return skip('not-git-repository'); } - if (insideWorkTree.trim() !== '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 === undefined || - repositoryPrefix === undefined || - gitCommonDirectory === undefined || - effectiveHooksDirectory === undefined - ) { + if (!gitRoot || !gitCommonDirectory || !effectiveHooksDirectory) { return fail('git-command-failed', 'Failed to resolve the Git repository paths.'); } - const normalizedPrefix = repositoryPrefix.replaceAll('\\', '/').replace(/\/$/u, ''); - return { defaultHooksDirectory: path.join(gitCommonDirectory, 'hooks'), effectiveHooksDirectory, gitRoot, - projectPath: normalizedPrefix || '.', + projectPath: repositoryPrefix.replaceAll('\\', '/').replace(/\/$/u, '') || '.', }; }; @@ -170,29 +157,16 @@ const isCurrentFile = (filePath: string, content: string, executable = false): b const isSamePath = (first: string, second: string): boolean => path.resolve(first) === path.resolve(second); -const ownerContent = (project: string): string => `${project}\n`; - -const readGeneratedDirectoryState = (directory: string): GeneratedDirectoryState => { - let entries: string[]; +const readOwner = (directory: string): string | undefined => { try { - entries = readdirSync(directory); + 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 { kind: 'empty' }; - } - - if (entries.includes(ownerFileName)) { - try { - const content = readFileSync(path.join(directory, ownerFileName), 'utf8'); - const project = removeLineEnding(content); - return content === ownerContent(project) && project.length > 0 && !/[\r\n]/u.test(project) - ? { kind: 'owned', project } - : { kind: 'foreign' }; - } catch { - return { kind: 'foreign' }; - } + return undefined; } - - return entries.every((entry) => entry === '.gitignore') ? { kind: 'empty' } : { kind: 'foreign' }; }; const displayPath = (gitRoot: string, filePath: string): string => { @@ -215,37 +189,30 @@ const claimOwner = ( project: string, ): SkippedInstallResult | undefined => { const ownerPath = path.join(directory, ownerFileName); - const content = ownerContent(project); - const state = readGeneratedDirectoryState(directory); + const owner = readOwner(directory); - if (state.kind === 'owned' && state.project !== project) { - return ownerConflict(state.project); - } - if (state.kind === 'foreign') { - return directoryConflict(gitRoot, directory); + if (owner) { + return owner === project ? undefined : ownerConflict(owner); } - if (state.kind === 'owned') { - return undefined; + if (readdirSync(directory).some((entry) => entry !== '.gitignore')) { + return directoryConflict(gitRoot, directory); } try { // Exclusive creation makes concurrent prepare scripts agree on one owner. - writeFileSync(ownerPath, content, { flag: 'wx' }); + 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 concurrentState = readGeneratedDirectoryState(directory); - if (concurrentState.kind === 'owned' && concurrentState.project === project) { - return undefined; + const concurrentOwner = readOwner(directory); + if (!concurrentOwner) { + return directoryConflict(gitRoot, directory); } - if (concurrentState.kind === 'owned') { - return ownerConflict(concurrentState.project); - } - return directoryConflict(gitRoot, directory); + return concurrentOwner === project ? undefined : ownerConflict(concurrentOwner); } return undefined; @@ -280,17 +247,16 @@ export const installHooks = ({ const usesDefaultHooks = isSamePath(effectiveHooksDirectory, defaultHooksDirectory); if (!hooksPathMatches && !usesDefaultHooks) { - const activeState = readGeneratedDirectoryState(effectiveHooksDirectory); - if (activeState.kind === 'owned') { - if (activeState.project !== projectPath) { - return ownerConflict(activeState.project); - } - } else { + const activeOwner = readOwner(effectiveHooksDirectory); + if (!activeOwner) { return skip( 'hooks-path-conflict', `Git hooks are already configured at "${displayPath(gitRoot, effectiveHooksDirectory)}"`, ); } + if (activeOwner !== projectPath) { + return ownerConflict(activeOwner); + } } if (usesDefaultHooks) { @@ -304,18 +270,6 @@ export const installHooks = ({ } const files = Object.entries(createHookFiles()); - const expectedOwner = ownerContent(projectPath); - // Skip all writes only when the config, owner, generated content, and executable modes match. - const unchanged = - hooksPathMatches && - isCurrentFile(path.join(directory, ownerFileName), expectedOwner) && - 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); @@ -323,6 +277,15 @@ export const installHooks = ({ 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) {