diff --git a/docs/guide/commit-hooks.md b/docs/guide/commit-hooks.md index 6de5dcbae8..922a86e9db 100644 --- a/docs/guide/commit-hooks.md +++ b/docs/guide/commit-hooks.md @@ -76,6 +76,8 @@ Use `--no-hooks` when you want `vp config` to leave the Git hook dispatcher unch If `core.hooksPath` points to another hook directory, `vp config` skips dispatcher installation to preserve the existing setup. Run `vp hooks status` to inspect the current configuration. +After migrating from Husky, existing clones can retain a local `core.hooksPath` of `.husky` or `.husky/_`. `vp config` and `vp hooks enable` automatically replace this stale setting when the Vite+ hooks directory contains a hook script, only generated dispatcher files remain under `.husky/`, and the repository and ancestor package manifests no longer declare Husky dependencies, configuration, or scripts. The old Husky files are left unchanged. Global and worktree-scoped hook settings are preserved. Automatic replacement is skipped when multiple worktrees are registered, because local Git configuration is shared and another checkout may still use Husky. + You can also set `VP_GIT_HOOKS=0` to disable hook installation from lifecycle scripts such as `prepare` or `postinstall`. Project-owned hook scripts such as `.vite-hooks/pre-commit` should be committed to the repository. The generated dispatcher and shims under `.vite-hooks/_` are ignored and recreated by `vp config` or `vp hooks enable`. Neither command creates or modifies project hook scripts or staged-file configuration. diff --git a/packages/cli/src/config/__tests__/fixtures/husky-9.1.7/LICENSE b/packages/cli/src/config/__tests__/fixtures/husky-9.1.7/LICENSE new file mode 100644 index 0000000000..049c9dc4e8 --- /dev/null +++ b/packages/cli/src/config/__tests__/fixtures/husky-9.1.7/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 typicode + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/cli/src/config/__tests__/fixtures/husky-9.1.7/h b/packages/cli/src/config/__tests__/fixtures/husky-9.1.7/h new file mode 100644 index 0000000000..bf7c896408 --- /dev/null +++ b/packages/cli/src/config/__tests__/fixtures/husky-9.1.7/h @@ -0,0 +1,22 @@ +#!/usr/bin/env sh +[ "$HUSKY" = "2" ] && set -x +n=$(basename "$0") +s=$(dirname "$(dirname "$0")")/$n + +[ ! -f "$s" ] && exit 0 + +if [ -f "$HOME/.huskyrc" ]; then + echo "husky - '~/.huskyrc' is DEPRECATED, please move your code to ~/.config/husky/init.sh" +fi +i="${XDG_CONFIG_HOME:-$HOME/.config}/husky/init.sh" +[ -f "$i" ] && . "$i" + +[ "${HUSKY-}" = "0" ] && exit 0 + +export PATH="node_modules/.bin:$PATH" +sh -e "$s" "$@" +c=$? + +[ $c != 0 ] && echo "husky - $n script failed (code $c)" +[ $c = 127 ] && echo "husky - command not found in PATH=$PATH" +exit $c diff --git a/packages/cli/src/config/__tests__/fixtures/husky-9.1.7/husky.sh b/packages/cli/src/config/__tests__/fixtures/husky-9.1.7/husky.sh new file mode 100644 index 0000000000..f9d0637909 --- /dev/null +++ b/packages/cli/src/config/__tests__/fixtures/husky-9.1.7/husky.sh @@ -0,0 +1,9 @@ +echo "husky - DEPRECATED + +Please remove the following two lines from $0: + +#!/usr/bin/env sh +. \"\$(dirname -- \"\$0\")/_/husky.sh\" + +They WILL FAIL in v10.0.0 +" \ No newline at end of file diff --git a/packages/cli/src/config/__tests__/hooks.spec.ts b/packages/cli/src/config/__tests__/hooks.spec.ts index e9230de2f8..aac97cf9ff 100644 --- a/packages/cli/src/config/__tests__/hooks.spec.ts +++ b/packages/cli/src/config/__tests__/hooks.spec.ts @@ -1,4 +1,4 @@ -import { execSync, spawn } from 'node:child_process'; +import { execFileSync, execSync, spawn, spawnSync } from 'node:child_process'; import { once } from 'node:events'; import { chmodSync, @@ -18,6 +18,7 @@ import { join, resolve } from 'node:path'; import { describe, expect, it } from 'vitest'; import { + HOOK_STUB_SCRIPT, disable, enable, hookScript, @@ -27,6 +28,9 @@ import { status, } from '../hooks.js'; +// Unmodified upstream dispatcher; retained to verify safe migration against real Husky output. +const HUSKY_DISPATCHER = readFileSync(new URL('./fixtures/husky-9.1.7/h', import.meta.url), 'utf8'); + function countDirnameCalls(script: string): number { // Count nested dirname calls in the `d=...` line const match = script.match(/^d=(.+)$/m); @@ -114,6 +118,9 @@ describe('install', () => { execSync('git config core.hooksPath .husky/_', { cwd: tmp }); mkdirSync(join(tmp, '.husky')); writeFileSync(join(tmp, '.husky', 'pre-commit'), 'npm test\n'); + writeFileSync(join(tmp, 'package.json'), '{}'); + mkdirSync(join(tmp, '.vite-hooks')); + writeFileSync(join(tmp, '.vite-hooks', 'pre-commit'), 'npm test\n'); process.chdir(tmp); expect(install()).toEqual({ @@ -124,8 +131,393 @@ describe('install', () => { '.husky/_', ); expect(readFileSync(join(tmp, '.husky', 'pre-commit'), 'utf8')).toBe('npm test\n'); - expect(existsSync(join(tmp, '.vite-hooks'))).toBe(false); + expect(existsSync(join(tmp, '.vite-hooks', '_'))).toBe(false); + } finally { + process.chdir(originalCwd); + rmSync(tmp, { recursive: true, force: true }); + } + }); + + it.each([ + ['.husky', '.vite-hooks'], + ['.husky/_', '.vite-hooks'], + ['./.husky/_/', '.vite-hooks'], + ['.husky/_', '.vite-hooks', '\r\n'], + ['.husky/_', '.custom-hooks'], + ['.husky/_', 'packages/app/.vite-hooks'], + ])( + 'takes over a stale local Husky path %s after migration to %s', + (hooksPath, hooksDir, newline = '\n') => { + const tmp = mkdtempSync(join(tmpdir(), 'hooks-husky-migration-')); + const originalCwd = process.cwd(); + try { + execFileSync('git', ['init'], { cwd: tmp, stdio: 'ignore' }); + execFileSync('git', ['config', 'core.hooksPath', hooksPath], { cwd: tmp }); + writeFileSync( + join(tmp, 'package.json'), + JSON.stringify({ scripts: { prepare: 'vp config' } }), + ); + mkdirSync(join(tmp, '.husky', '_'), { recursive: true }); + const oldDispatcher = join(tmp, '.husky', '_', 'h'); + const oldContents = HUSKY_DISPATCHER.replaceAll('\n', newline); + writeFileSync(oldDispatcher, oldContents); + writeFileSync( + join(tmp, '.husky', '_', 'pre-commit'), + HOOK_STUB_SCRIPT.replaceAll('\n', newline), + ); + writeFileSync(join(tmp, '.husky', '_', '.gitignore'), '*'); + writeFileSync( + join(tmp, '.husky', '_', 'husky.sh'), + readFileSync( + new URL('./fixtures/husky-9.1.7/husky.sh', import.meta.url), + 'utf8', + ).replaceAll('\n', newline), + ); + mkdirSync(join(tmp, hooksDir), { recursive: true }); + writeFileSync(join(tmp, hooksDir, 'commit-msg'), 'exit 1\n'); + process.chdir(tmp); + + expect(install(hooksDir)).toEqual({ message: '', isError: false }); + expect( + execFileSync('git', ['config', '--get', 'core.hooksPath'], { encoding: 'utf8' }).trim(), + ).toBe(`${hooksDir}/_`); + expect(readFileSync(oldDispatcher, 'utf8')).toBe(oldContents); + // A real commit must invoke the migrated hook, even in the old clone. + const commit = spawnSync( + 'git', + [ + '-c', + 'user.name=Test', + '-c', + 'user.email=test@example.invalid', + 'commit', + '--allow-empty', + '-m', + 'bad message', + ], + { encoding: 'utf8' }, + ); + expect(commit.status).toBe(1); + expect(commit.stdout + commit.stderr).toContain('commit-msg script failed'); + expect(enable().isError).toBe(false); + expect(install()).toEqual({ message: '', isError: false }); + } finally { + process.chdir(originalCwd); + rmSync(tmp, { recursive: true, force: true }); + } + }, + ); + + it + .skipIf(process.platform === 'win32') + .each(['custom supported hook', 'symlinked hook', 'custom dispatcher'])( + 'preserves a rejecting %s during install and enable', + (kind) => { + const tmp = mkdtempSync(join(tmpdir(), 'hooks-custom-husky-')); + const originalCwd = process.cwd(); + try { + execFileSync('git', ['init'], { cwd: tmp, stdio: 'ignore' }); + execFileSync('git', ['config', 'core.hooksPath', '.husky/_'], { cwd: tmp }); + writeFileSync( + join(tmp, 'package.json'), + JSON.stringify({ scripts: { prepare: 'vp config' } }), + ); + const internal = join(tmp, '.husky', '_'); + mkdirSync(internal, { recursive: true }); + const rejectingScript = '#!/bin/sh\necho "original hook rejected" >&2\nexit 1\n'; + writeFileSync(join(internal, 'h'), HUSKY_DISPATCHER); + const hook = join(internal, 'pre-commit'); + if (kind === 'symlinked hook') { + const external = join(tmp, 'reject-commit'); + writeFileSync(external, rejectingScript, { mode: 0o755 }); + symlinkSync(external, hook); + } else if (kind === 'custom dispatcher') { + writeFileSync(hook, HOOK_STUB_SCRIPT, { mode: 0o755 }); + writeFileSync(join(internal, 'h'), rejectingScript); + } else { + writeFileSync(hook, rejectingScript, { mode: 0o755 }); + } + mkdirSync(join(tmp, '.vite-hooks')); + writeFileSync(join(tmp, '.vite-hooks', 'commit-msg'), 'exit 0\n'); + process.chdir(tmp); + const assertCommitRejected = () => { + const result = spawnSync( + 'git', + [ + '-c', + 'user.name=Test', + '-c', + 'user.email=test@example.invalid', + '-c', + 'commit.gpgsign=false', + 'commit', + '--allow-empty', + '-m', + 'test', + ], + { cwd: tmp, encoding: 'utf8' }, + ); + expect(result.status).toBe(1); + expect(result.stderr).toContain('original hook rejected'); + }; + assertCommitRejected(); + for (const setup of [install, enable]) { + expect(setup()).toEqual({ + message: 'core.hooksPath is already set to ".husky/_", skipping', + isError: false, + }); + expect( + execFileSync('git', ['config', '--get', 'core.hooksPath'], { + encoding: 'utf8', + }).trim(), + ).toBe('.husky/_'); + expect(existsSync(join(tmp, '.vite-hooks', '_'))).toBe(false); + assertCommitRejected(); + } + } finally { + process.chdir(originalCwd); + rmSync(tmp, { recursive: true, force: true }); + } + }, + ); + + it.each([ + ['production Husky dependency', { dependencies: { husky: '^9.0.0' } }], + ['Husky dependency', { devDependencies: { husky: '^9.0.0' } }], + ['optional Husky dependency', { optionalDependencies: { husky: '^9.0.0' } }], + ['peer Husky dependency', { peerDependencies: { husky: '^9.0.0' } }], + ['Husky prepare script', { scripts: { prepare: 'husky' } }], + ['legacy Husky config', { husky: { hooks: { 'pre-commit': 'npm test' } } }], + ])('preserves a Husky path with %s', (_name, pkg) => { + const tmp = mkdtempSync(join(tmpdir(), 'hooks-active-husky-')); + const originalCwd = process.cwd(); + try { + execFileSync('git', ['init'], { cwd: tmp, stdio: 'ignore' }); + execFileSync('git', ['config', 'core.hooksPath', '.husky/_'], { cwd: tmp }); + writeFileSync(join(tmp, 'package.json'), JSON.stringify(pkg)); + mkdirSync(join(tmp, '.vite-hooks')); + writeFileSync(join(tmp, '.vite-hooks', 'pre-commit'), 'exit 1\n'); + process.chdir(tmp); + + expect(install().message).toBe('core.hooksPath is already set to ".husky/_", skipping'); + expect(existsSync(join(tmp, '.vite-hooks', '_'))).toBe(false); + } finally { + process.chdir(originalCwd); + rmSync(tmp, { recursive: true, force: true }); + } + }); + + it.each([ + ['.husky', 'reference-transaction'], + ['.husky', 'post-index-change'], + ['.husky', 'pre-receive'], + ['.husky/_', 'reference-transaction'], + ['.husky/_', 'post-index-change'], + ['.husky/_', 'pre-receive'], + ])('preserves an existing %s/%s hook outside the supported-name list', (hooksPath, hook) => { + const tmp = mkdtempSync(join(tmpdir(), 'hooks-other-husky-hook-')); + const originalCwd = process.cwd(); + try { + execFileSync('git', ['init'], { cwd: tmp, stdio: 'ignore' }); + execFileSync('git', ['config', 'core.hooksPath', hooksPath], { cwd: tmp }); + writeFileSync(join(tmp, 'package.json'), '{}'); + mkdirSync(join(tmp, '.vite-hooks')); + writeFileSync(join(tmp, '.vite-hooks', 'pre-commit'), 'exit 0\n'); + mkdirSync(join(tmp, hooksPath), { recursive: true }); + const oldHook = join(tmp, hooksPath, hook); + writeFileSync(oldHook, '#!/bin/sh\necho "original hook rejected" >&2\nexit 1\n'); + chmodSync(oldHook, 0o755); + process.chdir(tmp); + + const runHook = () => spawnSync('git', ['hook', 'run', hook], { encoding: 'utf8' }); + const before = runHook(); + expect(before.status).toBe(1); + expect(before.stderr).toContain('original hook rejected'); + expect(install()).toEqual({ + message: `core.hooksPath is already set to "${hooksPath}", skipping`, + isError: false, + }); + const after = runHook(); + expect(after.status).toBe(1); + expect(after.stderr).toContain('original hook rejected'); + expect(existsSync(join(tmp, '.vite-hooks', '_'))).toBe(false); + } finally { + process.chdir(originalCwd); + rmSync(tmp, { recursive: true, force: true }); + } + }); + + it.each(['original', 'linked'])( + 'does not replace shared Husky config from a migrated %s worktree', + (migratedWorktree) => { + const tmp = mkdtempSync(join(tmpdir(), 'hooks-husky-shared-config-')); + const original = join(tmp, 'original'); + const linked = join(tmp, 'linked'); + const originalCwd = process.cwd(); + try { + mkdirSync(original); + execFileSync('git', ['init'], { cwd: original, stdio: 'ignore' }); + execFileSync( + 'git', + [ + '-c', + 'user.name=Test', + '-c', + 'user.email=test@example.invalid', + '-c', + 'commit.gpgsign=false', + 'commit', + '--allow-empty', + '-m', + 'initial', + ], + { cwd: original, stdio: 'ignore' }, + ); + execFileSync('git', ['worktree', 'add', '-b', 'linked', linked], { + cwd: original, + stdio: 'ignore', + }); + const migrated = migratedWorktree === 'original' ? original : linked; + const unmigrated = migratedWorktree === 'original' ? linked : original; + execFileSync('git', ['config', '--local', 'core.hooksPath', '.husky'], { cwd: original }); + writeFileSync(join(migrated, 'package.json'), '{}'); + mkdirSync(join(migrated, '.vite-hooks')); + writeFileSync(join(migrated, '.vite-hooks', 'pre-commit'), 'exit 0\n'); + writeFileSync( + join(unmigrated, 'package.json'), + JSON.stringify({ devDependencies: { husky: '*' } }), + ); + mkdirSync(join(unmigrated, '.husky')); + const oldHook = join(unmigrated, '.husky', 'pre-commit'); + writeFileSync(oldHook, '#!/bin/sh\necho "unmigrated hook rejected" >&2\nexit 1\n'); + chmodSync(oldHook, 0o755); + const commit = () => + spawnSync( + 'git', + [ + '-c', + 'user.name=Test', + '-c', + 'user.email=test@example.invalid', + '-c', + 'commit.gpgsign=false', + 'commit', + '--allow-empty', + '-m', + 'rejected', + ], + { cwd: unmigrated, encoding: 'utf8' }, + ); + expect(commit().status).toBe(1); + process.chdir(migrated); + + expect(install()).toEqual({ + message: 'core.hooksPath is already set to ".husky", skipping', + isError: false, + }); + expect( + execFileSync('git', ['config', '--local', 'core.hooksPath'], { encoding: 'utf8' }).trim(), + ).toBe('.husky'); + const after = commit(); + expect(after.status).toBe(1); + expect(after.stderr).toContain('unmigrated hook rejected'); + expect(existsSync(join(migrated, '.vite-hooks', '_'))).toBe(false); + } finally { + process.chdir(originalCwd); + rmSync(tmp, { recursive: true, force: true }); + } + }, + ); + + it.skipIf(process.platform === 'win32').each(['.husky', '.husky/_'])( + 'preserves a symlinked Husky directory at %s', + (linkedDir) => { + const tmp = mkdtempSync(join(tmpdir(), 'hooks-husky-symlink-')); + const originalCwd = process.cwd(); + try { + execFileSync('git', ['init'], { cwd: tmp, stdio: 'ignore' }); + execFileSync('git', ['config', 'core.hooksPath', '.husky/_'], { cwd: tmp }); + writeFileSync(join(tmp, 'package.json'), '{}'); + mkdirSync(join(tmp, '.vite-hooks')); + writeFileSync(join(tmp, '.vite-hooks', 'pre-commit'), 'exit 1\n'); + mkdirSync(join(tmp, 'external')); + if (linkedDir === '.husky/_') { + mkdirSync(join(tmp, '.husky')); + } + symlinkSync(join(tmp, 'external'), join(tmp, linkedDir), 'dir'); + process.chdir(tmp); + + expect(install().message).toBe('core.hooksPath is already set to ".husky/_", skipping'); + expect(existsSync(join(tmp, '.vite-hooks', '_'))).toBe(false); + } finally { + process.chdir(originalCwd); + rmSync(tmp, { recursive: true, force: true }); + } + }, + ); + + it.each([ + 'custom hooks path', + 'worktree-scoped Husky path', + 'global Husky path', + 'missing manifest', + 'invalid manifest', + 'no migrated hook', + 'disabled hooks', + 'Husky in an ancestor manifest', + ])('does not reclaim a Husky setup with %s', (scenario) => { + const tmp = mkdtempSync(join(tmpdir(), 'hooks-husky-preserve-')); + const originalCwd = process.cwd(); + const oldGlobal = process.env.GIT_CONFIG_GLOBAL; + try { + execFileSync('git', ['init'], { cwd: tmp, stdio: 'ignore' }); + let hooksPath = '.husky/_'; + if (scenario === 'custom hooks path') { + hooksPath = '.husky/custom'; + } + execFileSync('git', ['config', '--local', 'core.hooksPath', hooksPath], { cwd: tmp }); + writeFileSync(join(tmp, 'package.json'), '{}'); + mkdirSync(join(tmp, '.vite-hooks')); + writeFileSync(join(tmp, '.vite-hooks', 'pre-commit'), 'exit 1\n'); + process.chdir(tmp); + if (scenario === 'worktree-scoped Husky path') { + execFileSync('git', ['config', 'extensions.worktreeConfig', 'true']); + execFileSync('git', ['config', '--worktree', 'core.hooksPath', hooksPath]); + } else if (scenario === 'global Husky path') { + process.env.GIT_CONFIG_GLOBAL = join(tmp, 'global-config'); + execFileSync('git', ['config', '--global', 'core.hooksPath', hooksPath]); + execFileSync('git', ['config', '--local', '--unset', 'core.hooksPath']); + } else if (scenario === 'missing manifest') { + rmSync(join(tmp, 'package.json')); + } else if (scenario === 'invalid manifest') { + writeFileSync(join(tmp, 'package.json'), '{'); + } else if (scenario === 'no migrated hook') { + rmSync(join(tmp, '.vite-hooks', 'pre-commit')); + } else if (scenario === 'disabled hooks') { + execFileSync('git', ['config', 'vp.hooks.disabled', 'true']); + } else if (scenario === 'Husky in an ancestor manifest') { + writeFileSync( + join(tmp, 'package.json'), + JSON.stringify({ devDependencies: { husky: '*' } }), + ); + const sub = join(tmp, 'packages', 'app'); + mkdirSync(join(sub, '.vite-hooks'), { recursive: true }); + writeFileSync(join(sub, 'package.json'), '{}'); + writeFileSync(join(sub, '.vite-hooks', 'pre-commit'), 'exit 1\n'); + process.chdir(sub); + } + + expect(install().isError).toBe(false); + expect( + execFileSync('git', ['config', '--get', 'core.hooksPath'], { encoding: 'utf8' }).trim(), + ).toBe(hooksPath); + expect(existsSync(join(process.cwd(), '.vite-hooks', '_'))).toBe(false); } finally { + if (oldGlobal === undefined) { + delete process.env.GIT_CONFIG_GLOBAL; + } else { + process.env.GIT_CONFIG_GLOBAL = oldGlobal; + } process.chdir(originalCwd); rmSync(tmp, { recursive: true, force: true }); } diff --git a/packages/cli/src/config/hooks.ts b/packages/cli/src/config/hooks.ts index a1232af7e0..11a7264933 100644 --- a/packages/cli/src/config/hooks.ts +++ b/packages/cli/src/config/hooks.ts @@ -1,10 +1,12 @@ import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; import { chmodSync, existsSync, lstatSync, mkdirSync, readdirSync, + readFileSync, realpathSync, rmSync, writeFileSync, @@ -507,6 +509,131 @@ function getScopedHooksPath(scope: 'local' | 'worktree'): string { return result.status === 0 ? result.stdout?.toString().trim() : ''; } +// Exact Husky 9.1.7 generated contents, with CRLF and a final newline normalized. +// https://github.com/typicode/husky/blob/v9.1.7/husky +// https://github.com/typicode/husky/blob/v9.1.7/index.js +// Unknown versions are deliberately preserved rather than guessing that their +// dispatcher is safe to abandon. +const HUSKY_GENERATED_SCRIPT_HASHES: Readonly> = { + h: 'd80994934f440d579303d6590fa1ce7bc0d31c650179d2089dea5191d1ab10d1', + 'husky.sh': '21122903fca7209a13c991e5be68780636e28f1b8f0ae7ea07ed0065dfe37268', +}; + +function isUnmodifiedHuskyFile(directory: string, name: string): boolean { + const path = join(directory, name); + const stats = lstatSync(path); + if (!stats.isFile() || stats.isSymbolicLink() || stats.nlink !== 1) { + return false; + } + const contents = readFileSync(path, 'utf8').replace(/\r\n/g, '\n').replace(/\n$/, ''); + if (name === '.gitignore') { + return contents === '*'; + } + if (SUPPORTED_GIT_HOOK_NAMES.includes(name)) { + return contents === HOOK_STUB_SCRIPT; + } + const expectedHash = HUSKY_GENERATED_SCRIPT_HASHES[name]; + return ( + expectedHash !== undefined && + createHash('sha256').update(contents).digest('hex') === expectedHash + ); +} + +/** Only reclaim an abandoned, repository-local Husky setup after hooks were moved. */ +function canReplaceStaleHuskyPath(hooksPath: string, location: HooksLocation): boolean { + const normalized = normalizeHooksPath(hooksPath); + if (normalized !== '.husky' && normalized !== join('.husky', '_')) { + return false; + } + // Never override a global, worktree, or command-scoped hook policy. + const scoped = spawnSync('git', ['config', '--show-scope', '--get', 'core.hooksPath']); + if (scoped.status !== 0 || !scoped.stdout.toString().startsWith('local\t')) { + return false; + } + // Local config is shared by linked worktrees. Inspecting only this checkout + // cannot establish whether the others have also migrated away from Husky. + const worktrees = spawnSync('git', ['worktree', 'list', '--porcelain', '-z']); + if ( + worktrees.status !== 0 || + worktrees.stdout + .toString() + .split('\0') + .filter((field) => field.startsWith('worktree ')).length !== 1 + ) { + return false; + } + try { + const huskyDir = join(location.toplevel, '.husky'); + const huskyStats = lstatSync(huskyDir, { throwIfNoEntry: false }); + const dispatcherDir = join(huskyDir, '_'); + const dispatcherStats = lstatSync(dispatcherDir, { throwIfNoEntry: false }); + if ( + (huskyStats && (!huskyStats.isDirectory() || huskyStats.isSymbolicLink())) || + (dispatcherStats && (!dispatcherStats.isDirectory() || dispatcherStats.isSymbolicLink())) + ) { + return false; + } + // Git can execute hook names outside Vite+'s supported set (including + // arbitrary names through `git hook run`). Only ignore generated files. + if ( + (huskyStats && readdirSync(huskyDir).some((name) => name !== '_')) || + (dispatcherStats && + readdirSync(dispatcherDir).some((name) => !isUnmodifiedHuskyFile(dispatcherDir, name))) + ) { + return false; + } + // A dispatcher alone is not evidence of a completed migration. + if ( + !SUPPORTED_GIT_HOOK_NAMES.some((hook) => + lstatSync(join(location.baseDir, location.dir, hook), { throwIfNoEntry: false })?.isFile(), + ) || + !existsSync(join(location.toplevel, 'package.json')) + ) { + return false; + } + // Include ancestor manifests when hooks are configured from a workspace package. + const relativeBase = relative(location.toplevel, location.baseDir); + const directories = [location.toplevel]; + for (const segment of relativeBase.split(sep).filter(Boolean)) { + directories.push(join(directories.at(-1)!, segment)); + } + for (const directory of directories) { + const manifest = join(directory, 'package.json'); + if (!existsSync(manifest)) { + continue; + } + const pkg = JSON.parse(readFileSync(manifest, 'utf8')) as Record; + if (!pkg || typeof pkg !== 'object' || Array.isArray(pkg) || 'husky' in pkg) { + return false; + } + for (const key of [ + 'dependencies', + 'devDependencies', + 'optionalDependencies', + 'peerDependencies', + ]) { + const dependencies = pkg[key]; + if (dependencies && typeof dependencies === 'object' && 'husky' in dependencies) { + return false; + } + } + if ( + pkg.scripts && + typeof pkg.scripts === 'object' && + Object.values(pkg.scripts).some( + (script) => typeof script === 'string' && /\bhusky\b/.test(script), + ) + ) { + return false; + } + } + } catch { + // Unreadable or invalid manifests are not evidence that Husky was removed. + return false; + } + return true; +} + function unsetScopedHooksPath(scope: 'local' | 'worktree'): InstallResult | null { const result = spawnSync('git', ['config', `--${scope}`, '--unset-all', 'core.hooksPath']); if (result.status == null) { @@ -598,7 +725,8 @@ export function install(dir?: string, options: InstallOptions = {}): InstallResu const existingHooksPath = getEffectiveHooksPath(); if ( existingHooksPath && - !hooksPathsEqual(existingHooksPath, location.target, location.toplevel) + !hooksPathsEqual(existingHooksPath, location.target, location.toplevel) && + !canReplaceStaleHuskyPath(existingHooksPath, location) ) { return { message: `core.hooksPath is already set to "${existingHooksPath}", skipping`,