From 841a6b356e6a3ec6687babcfd5c42e72439ffc79 Mon Sep 17 00:00:00 2001 From: Denny Biasiolli Date: Wed, 5 Aug 2026 12:52:29 +0200 Subject: [PATCH 01/10] feat(cli): add hooks setup/disable/enable/status core API Implement pure hook lifecycle helpers on top of install(): user preference persistence (local git config), safe per-scope core.hooksPath unset, and status reporting. Teach install() and vp config to honor a disable preference and resolve the last-used hooks directory. Cover lifecycle, foreign path, worktree, stored custom dir, and unsafe-tree cases in unit tests; update the config help snapshot for the remembered hooks-dir default. --- .../snapshots/command_config_help.md | 6 +- .../cli/src/config/__tests__/hooks.spec.ts | 248 +++++++++- packages/cli/src/config/bin.ts | 16 +- packages/cli/src/config/hooks.ts | 434 +++++++++++++++++- 4 files changed, 674 insertions(+), 30 deletions(-) diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_config_help/snapshots/command_config_help.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_config_help/snapshots/command_config_help.md index 8c282e4c14..4c755dc08c 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_config_help/snapshots/command_config_help.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_config_help/snapshots/command_config_help.md @@ -10,7 +10,7 @@ Usage: vp config [OPTIONS] Configure Vite+ for the current project (hook dispatcher + agent integration). Options: - --hooks-dir Custom hooks directory (default: .vite-hooks) + --hooks-dir Custom hooks directory (default: .vite-hooks, or last used in this clone) --no-hooks Skip hook dispatcher installation --no-agent Skip updating coding agent instructions -h, --help Show this help message @@ -31,7 +31,7 @@ Usage: vp config [OPTIONS] Configure Vite+ for the current project (hook dispatcher + agent integration). Options: - --hooks-dir Custom hooks directory (default: .vite-hooks) + --hooks-dir Custom hooks directory (default: .vite-hooks, or last used in this clone) --no-hooks Skip hook dispatcher installation --no-agent Skip updating coding agent instructions -h, --help Show this help message @@ -52,7 +52,7 @@ Usage: vp config [OPTIONS] Configure Vite+ for the current project (hook dispatcher + agent integration). Options: - --hooks-dir Custom hooks directory (default: .vite-hooks) + --hooks-dir Custom hooks directory (default: .vite-hooks, or last used in this clone) --no-hooks Skip hook dispatcher installation --no-agent Skip updating coding agent instructions -h, --help Show this help message diff --git a/packages/cli/src/config/__tests__/hooks.spec.ts b/packages/cli/src/config/__tests__/hooks.spec.ts index d556ba6d7c..cc99b8587a 100644 --- a/packages/cli/src/config/__tests__/hooks.spec.ts +++ b/packages/cli/src/config/__tests__/hooks.spec.ts @@ -16,7 +16,16 @@ import { join, resolve } from 'node:path'; import { describe, expect, it } from 'vitest'; -import { hookScript, install } from '../hooks.js'; +import { + disable, + enable, + hookScript, + install, + isHooksUserDisabled, + resolveHooksDir, + setup, + status, +} from '../hooks.js'; function countDirnameCalls(script: string): number { // Count nested dirname calls in the `d=...` line @@ -86,14 +95,14 @@ describe('install', () => { it('rejects an absolute hooks directory', () => { expect(install(resolve(tmpdir(), 'external-hooks'))).toEqual({ message: 'absolute hooks directory not allowed', - isError: false, + isError: true, }); }); it.each(['', '.', './'])('rejects the project root as hooks directory: %j', (hooksDir) => { expect(install(hooksDir)).toEqual({ message: 'hooks directory must be a project subdirectory', - isError: false, + isError: true, }); }); @@ -239,6 +248,239 @@ describe('install', () => { }); }); +describe('setup / disable / enable / status', () => { + it.skipIf(process.platform === 'win32')( + 'setup installs dispatcher; disable tears down and persists preference; enable restores', + () => { + const tmp = mkdtempSync(join(tmpdir(), 'hooks-lifecycle-')); + const originalCwd = process.cwd(); + try { + execSync('git init', { cwd: tmp, stdio: 'ignore' }); + process.chdir(tmp); + + const hooksDir = '.vite-hooks'; + mkdirSync(hooksDir, { recursive: true }); + writeFileSync(join(hooksDir, 'pre-commit'), 'vp staged\n'); + + expect(setup(hooksDir).isError).toBe(false); + expect(existsSync(join(tmp, hooksDir, '_', 'pre-commit'))).toBe(true); + expect(execSync('git config --get core.hooksPath', { cwd: tmp }).toString().trim()).toBe( + '.vite-hooks/_', + ); + expect(isHooksUserDisabled()).toBe(false); + + const disabled = disable(hooksDir); + expect(disabled.isError).toBe(false); + expect(disabled.message).toContain('Git hooks disabled'); + expect(existsSync(join(tmp, hooksDir, '_'))).toBe(false); + expect(existsSync(join(tmp, hooksDir, 'pre-commit'))).toBe(true); + expect(() => execSync('git config --get core.hooksPath', { cwd: tmp })).toThrow(); + expect(isHooksUserDisabled()).toBe(true); + + // install (as vp config would) respects the preference + expect(install(hooksDir)).toEqual({ + message: 'skip install (hooks disabled; run `vp hooks enable` to re-enable)', + isError: false, + }); + expect(existsSync(join(tmp, hooksDir, '_'))).toBe(false); + + expect(enable(hooksDir).isError).toBe(false); + expect(existsSync(join(tmp, hooksDir, '_', 'pre-commit'))).toBe(true); + expect(existsSync(join(tmp, hooksDir, 'pre-commit'))).toBe(true); + expect(execSync('git config --get core.hooksPath', { cwd: tmp }).toString().trim()).toBe( + '.vite-hooks/_', + ); + expect(isHooksUserDisabled()).toBe(false); + } finally { + process.chdir(originalCwd); + rmSync(tmp, { recursive: true, force: true }); + } + }, + ); + + it.skipIf(process.platform === 'win32')( + 'disable leaves a foreign core.hooksPath alone but still records preference', + () => { + const tmp = mkdtempSync(join(tmpdir(), 'hooks-foreign-disable-')); + const originalCwd = process.cwd(); + try { + execSync('git init', { cwd: tmp, stdio: 'ignore' }); + execSync('git config core.hooksPath .husky/_', { cwd: tmp }); + mkdirSync(join(tmp, '.husky', '_'), { recursive: true }); + writeFileSync(join(tmp, '.husky', 'pre-commit'), 'npm test\n'); + mkdirSync(join(tmp, '.vite-hooks', '_'), { recursive: true }); + writeFileSync(join(tmp, '.vite-hooks', '_', 'h'), 'stale\n'); + process.chdir(tmp); + + const result = disable(); + expect(result.isError).toBe(false); + expect(result.message).toContain('left unchanged'); + expect(execSync('git config --get core.hooksPath', { cwd: tmp }).toString().trim()).toBe( + '.husky/_', + ); + expect(existsSync(join(tmp, '.vite-hooks', '_'))).toBe(false); + expect(existsSync(join(tmp, '.husky', 'pre-commit'))).toBe(true); + expect(isHooksUserDisabled()).toBe(true); + } finally { + process.chdir(originalCwd); + rmSync(tmp, { recursive: true, force: true }); + } + }, + ); + + it.skipIf(process.platform === 'win32')('status reports preference and dispatcher state', () => { + const tmp = mkdtempSync(join(tmpdir(), 'hooks-status-')); + const originalCwd = process.cwd(); + try { + execSync('git init', { cwd: tmp, stdio: 'ignore' }); + process.chdir(tmp); + + mkdirSync('.vite-hooks', { recursive: true }); + writeFileSync(join(tmp, '.vite-hooks', 'pre-commit'), 'vp staged\n'); + + const unset = status(); + expect(unset.isError).toBe(false); + expect(unset.message).toContain('Preference: not set'); + + expect(setup().isError).toBe(false); + const active = status(); + expect(active.isError).toBe(false); + expect(active.status?.userDisabled).toBe(false); + expect(active.status?.dispatcherInstalled).toBe(true); + expect(active.status?.ownsHooksPath).toBe(true); + expect(active.status?.projectHooks).toEqual(['pre-commit']); + expect(active.message).toContain('Preference: enabled'); + + expect(disable().isError).toBe(false); + const inactive = status(); + expect(inactive.status?.userDisabled).toBe(true); + expect(inactive.status?.dispatcherInstalled).toBe(false); + expect(inactive.message).toContain('Preference: disabled (local)'); + } finally { + process.chdir(originalCwd); + rmSync(tmp, { recursive: true, force: true }); + } + }); + + it.skipIf(process.platform === 'win32')( + 'remembers custom hooks dir across disable/enable/status without an explicit dir', + () => { + const tmp = mkdtempSync(join(tmpdir(), 'hooks-stored-dir-')); + const originalCwd = process.cwd(); + try { + execSync('git init', { cwd: tmp, stdio: 'ignore' }); + process.chdir(tmp); + + const customDir = '.custom-hooks'; + mkdirSync(customDir, { recursive: true }); + writeFileSync(join(tmp, customDir, 'pre-commit'), 'vp staged\n'); + + expect(setup(customDir).isError).toBe(false); + expect(resolveHooksDir()).toBe(customDir); + expect(existsSync(join(tmp, customDir, '_', 'pre-commit'))).toBe(true); + expect(execSync('git config --get core.hooksPath', { cwd: tmp }).toString().trim()).toBe( + `${customDir}/_`, + ); + + // Callers that only have resolveHooksDir() (CLI without --hooks-dir) must hit the custom tree. + const disabled = disable(resolveHooksDir()); + expect(disabled.isError).toBe(false); + expect(existsSync(join(tmp, customDir, '_'))).toBe(false); + expect(existsSync(join(tmp, customDir, 'pre-commit'))).toBe(true); + expect(existsSync(join(tmp, '.vite-hooks'))).toBe(false); + expect(isHooksUserDisabled()).toBe(true); + + const inactive = status(); + expect(inactive.status?.hooksDir).toBe(customDir); + expect(inactive.message).toContain('Preference: disabled (local)'); + expect(inactive.message).toContain(`Hooks dir: ${customDir}`); + + expect(enable(resolveHooksDir()).isError).toBe(false); + expect(existsSync(join(tmp, customDir, '_', 'pre-commit'))).toBe(true); + expect(execSync('git config --get core.hooksPath', { cwd: tmp }).toString().trim()).toBe( + `${customDir}/_`, + ); + expect(isHooksUserDisabled()).toBe(false); + } finally { + process.chdir(originalCwd); + rmSync(tmp, { recursive: true, force: true }); + } + }, + ); + + it('rejects an absolute hooks directory for disable', () => { + expect(disable(resolve(tmpdir(), 'external-hooks'))).toEqual({ + message: 'absolute hooks directory not allowed', + isError: true, + }); + }); + + it.skipIf(process.platform === 'win32')( + 'disable unsets only the worktree Vite+ path and leaves a foreign local path', + () => { + const tmp = mkdtempSync(join(tmpdir(), 'hooks-worktree-disable-')); + const originalCwd = process.cwd(); + try { + execSync('git init', { cwd: tmp, stdio: 'ignore' }); + execSync('git config extensions.worktreeConfig true', { cwd: tmp }); + execSync('git config --local core.hooksPath .husky/_', { cwd: tmp }); + execSync('git config --worktree core.hooksPath .vite-hooks/_', { cwd: tmp }); + mkdirSync(join(tmp, '.vite-hooks', '_'), { recursive: true }); + writeFileSync(join(tmp, '.vite-hooks', '_', 'h'), 'stale\n'); + process.chdir(tmp); + + const result = disable(); + expect(result.isError).toBe(false); + expect(isHooksUserDisabled()).toBe(true); + expect(existsSync(join(tmp, '.vite-hooks', '_'))).toBe(false); + // Local foreign value must remain; effective path should fall back to it. + expect( + execSync('git config --local --get core.hooksPath', { cwd: tmp }).toString().trim(), + ).toBe('.husky/_'); + expect(() => + execSync('git config --worktree --get core.hooksPath', { cwd: tmp }), + ).toThrow(); + expect(execSync('git config --get core.hooksPath', { cwd: tmp }).toString().trim()).toBe( + '.husky/_', + ); + } finally { + process.chdir(originalCwd); + rmSync(tmp, { recursive: true, force: true }); + } + }, + ); + + it.skipIf(process.platform === 'win32')( + 'disable refuses an unsafe dispatcher tree before mutating hooksPath or preference', + () => { + const tmp = mkdtempSync(join(tmpdir(), 'hooks-unsafe-disable-')); + const originalCwd = process.cwd(); + try { + execSync('git init', { cwd: tmp, stdio: 'ignore' }); + const externalFile = join(tmp, 'external-hook-runner'); + mkdirSync(join(tmp, '.vite-hooks', '_'), { recursive: true }); + writeFileSync(externalFile, 'keep me\n'); + symlinkSync(externalFile, join(tmp, '.vite-hooks', '_', 'h')); + execSync('git config core.hooksPath .vite-hooks/_', { cwd: tmp }); + process.chdir(tmp); + + expect(disable()).toEqual({ + message: 'symbolic hook path ".vite-hooks/_/h" not allowed', + isError: false, + }); + expect(isHooksUserDisabled()).toBe(false); + expect(execSync('git config --get core.hooksPath', { cwd: tmp }).toString().trim()).toBe( + '.vite-hooks/_', + ); + expect(existsSync(join(tmp, '.vite-hooks', '_', 'h'))).toBe(true); + } finally { + process.chdir(originalCwd); + rmSync(tmp, { recursive: true, force: true }); + } + }, + ); +}); + describe('hookScript env gates', () => { it('honors VP_GIT_HOOKS and keeps VITE_GIT_HOOKS as a deprecated alias', () => { const script = hookScript('.vite-hooks'); diff --git a/packages/cli/src/config/bin.ts b/packages/cli/src/config/bin.ts index 04136aabb3..0b3b986025 100644 --- a/packages/cli/src/config/bin.ts +++ b/packages/cli/src/config/bin.ts @@ -7,7 +7,7 @@ import { updateExistingAgentInstructions } from '../utils/agent.ts'; import { renderCliDoc } from '../utils/help.ts'; import { defaultInteractive, promptGitHooks } from '../utils/prompts.ts'; import { log, printHeader } from '../utils/terminal.ts'; -import { install } from './hooks.ts'; +import { install, isHooksUserDisabled, resolveHooksDir } from './hooks.ts'; async function main() { const args = mri(process.argv.slice(3), { @@ -27,7 +27,8 @@ async function main() { rows: [ { label: '--hooks-dir ', - description: 'Custom hooks directory (default: .vite-hooks)', + description: + 'Custom hooks directory (default: .vite-hooks, or last used in this clone)', }, { label: '--no-hooks', description: 'Skip hook dispatcher installation' }, { label: '--no-agent', description: 'Skip updating coding agent instructions' }, @@ -54,11 +55,16 @@ async function main() { const root = process.cwd(); // --- Step 1: Hooks setup --- - const hooksDir = dir ?? '.vite-hooks'; + // Prefer CLI flag, then last-used dir from local git config, then default. + const hooksDir = resolveHooksDir(dir); const isFirstHooksRun = !existsSync(join(root, hooksDir, '_', 'pre-commit')); let shouldSetupHooks = !skipHooks; - if (shouldSetupHooks && interactive && isFirstHooksRun && !dir && !isLifecycleScript) { + if (shouldSetupHooks && isHooksUserDisabled()) { + // Honor `vp hooks disable` without re-prompting (option A). + log('skip install (hooks disabled; run `vp hooks enable` to re-enable)'); + shouldSetupHooks = false; + } else if (shouldSetupHooks && interactive && isFirstHooksRun && !dir && !isLifecycleScript) { // Explicit directories and lifecycle scripts already opt in. shouldSetupHooks = await promptGitHooks({ interactive, @@ -67,7 +73,7 @@ async function main() { } if (shouldSetupHooks) { - const { message, isError } = install(dir); + const { message, isError } = install(hooksDir); if (message) { log(message); if (isError) { diff --git a/packages/cli/src/config/hooks.ts b/packages/cli/src/config/hooks.ts index 4dd43e59d7..029ae29de9 100644 --- a/packages/cli/src/config/hooks.ts +++ b/packages/cli/src/config/hooks.ts @@ -1,5 +1,13 @@ import { spawnSync } from 'node:child_process'; -import { chmodSync, lstatSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { + chmodSync, + existsSync, + lstatSync, + mkdirSync, + readdirSync, + rmSync, + writeFileSync, +} from 'node:fs'; import { isAbsolute, join, normalize, relative, resolve, sep } from 'node:path'; export const SUPPORTED_GIT_HOOK_NAMES = [ @@ -19,6 +27,13 @@ export const SUPPORTED_GIT_HOOK_NAMES = [ 'pre-auto-gc', ]; +export const DEFAULT_HOOKS_DIR = '.vite-hooks'; + +/** Local git config: user chose `vp hooks disable` (survives prepare / vp config). */ +const PREFERENCE_DISABLED_KEY = 'vp.hooks.disabled'; +/** Local git config: last hooks directory used by setup/enable. */ +const PREFERENCE_DIR_KEY = 'vp.hooks.dir'; + // Build nested dirname expression: depth 3 → dirname "$(dirname "$(dirname "$0"))" function nestedDirname(depth: number): string { let expr = '"$0"'; @@ -83,6 +98,16 @@ export interface UnsafeHookInstallPath { relativePath: string; } +export interface HooksStatus { + hooksDir: string; + userDisabled: boolean; + hooksPath: string | null; + ownsHooksPath: boolean; + dispatcherInstalled: boolean; + projectHooks: string[]; + lines: string[]; +} + export function normalizeHooksPath(hooksPath: string): string { let normalized = normalize(hooksPath); while (normalized.endsWith(sep)) { @@ -143,7 +168,172 @@ function describeUnsafeHookInstallPath(unsafePath: UnsafeHookInstallPath): strin return `hook path "${unsafePath.relativePath}" is not a file`; } -export function install(dir = '.vite-hooks'): InstallResult { +function gitConfigGet(key: string, options?: { local?: boolean; bool?: boolean }): string | null { + const args = ['config']; + if (options?.local) { + args.push('--local'); + } + if (options?.bool) { + args.push('--bool'); + } + args.push('--get', key); + const result = spawnSync('git', args); + if (result.status !== 0) { + return null; + } + return result.stdout?.toString().trim() || null; +} + +function gitConfigSet(key: string, value: string): { ok: boolean; error?: string } { + const result = spawnSync('git', ['config', '--local', key, value]); + if (result.status == null) { + return { ok: false, error: 'git command not found' }; + } + if (result.status !== 0) { + return { + ok: false, + error: result.stderr?.toString().trim() || `failed to set ${key}`, + }; + } + return { ok: true }; +} + +function gitConfigUnset(key: string): { ok: boolean; error?: string } { + const result = spawnSync('git', ['config', '--local', '--unset', key]); + if (result.status == null) { + return { ok: false, error: 'git command not found' }; + } + // status 5 = key not found + if (result.status !== 0 && result.status !== 5) { + return { + ok: false, + error: result.stderr?.toString().trim() || `failed to unset ${key}`, + }; + } + return { ok: true }; +} + +/** Whether the user ran `vp hooks disable` in this repo (local git config). */ +export function isHooksUserDisabled(): boolean { + return gitConfigGet(PREFERENCE_DISABLED_KEY, { local: true, bool: true }) === 'true'; +} + +export function setHooksUserDisabled(disabled: boolean): { ok: boolean; error?: string } { + if (disabled) { + return gitConfigSet(PREFERENCE_DISABLED_KEY, 'true'); + } + return gitConfigUnset(PREFERENCE_DISABLED_KEY); +} + +export function getStoredHooksDir(): string | null { + return gitConfigGet(PREFERENCE_DIR_KEY, { local: true }); +} + +export function setStoredHooksDir(dir: string): { ok: boolean; error?: string } { + return gitConfigSet(PREFERENCE_DIR_KEY, dir); +} + +/** + * Resolve the hooks directory: CLI flag > stored preference > default. + */ +export function resolveHooksDir(dir?: string): string { + if (dir) { + return dir; + } + return getStoredHooksDir() ?? DEFAULT_HOOKS_DIR; +} + +function validateHooksDir(dir: string): InstallResult | null { + if (dir.includes('..')) { + return { message: '.. not allowed', isError: true }; + } + if (isAbsolute(dir)) { + return { message: 'absolute hooks directory not allowed', isError: true }; + } + if (relative(process.cwd(), resolve(process.cwd(), dir)) === '') { + return { message: 'hooks directory must be a project subdirectory', isError: true }; + } + return null; +} + +function computeTarget(dir: string): { target: string } | InstallResult { + const prefixResult = spawnSync('git', ['rev-parse', '--show-prefix']); + if (prefixResult.status == null) { + return { message: 'git command not found', isError: true }; + } + if (prefixResult.status !== 0) { + return { message: ".git can't be found", isError: false }; + } + const rel = prefixResult.stdout.toString().trim().replace(/\/$/, ''); + const target = rel ? `${rel}/${dir}/_` : `${dir}/_`; + return { target }; +} + +function getEffectiveHooksPath(): string { + const checkResult = spawnSync('git', ['config', '--get', 'core.hooksPath']); + return checkResult.status === 0 ? checkResult.stdout?.toString().trim() : ''; +} + +function getScopedHooksPath(scope: 'local' | 'worktree'): string { + const result = spawnSync('git', ['config', `--${scope}`, '--get', 'core.hooksPath']); + return result.status === 0 ? result.stdout?.toString().trim() : ''; +} + +function unsetScopedHooksPath(scope: 'local' | 'worktree'): InstallResult | null { + const result = spawnSync('git', ['config', `--${scope}`, '--unset', 'core.hooksPath']); + if (result.status == null) { + return { message: 'git command not found', isError: true }; + } + // status 5 = key not found at that scope + if (result.status !== 0 && result.status !== 5) { + return { + message: result.stderr?.toString().trim() || `failed to unset ${scope} core.hooksPath`, + isError: true, + }; + } + return null; +} + +/** + * Unset core.hooksPath only at scopes that actually point at our dispatcher. + * + * Must not touch a foreign value at another scope (e.g. local still `.husky/_` + * while worktree holds the Vite+ target). + */ +function unsetOwnedHooksPath(target: string): InstallResult | null { + const normalizedTarget = normalizeHooksPath(target); + + for (const scope of ['local', 'worktree'] as const) { + const scopedPath = getScopedHooksPath(scope); + if (!scopedPath || normalizeHooksPath(scopedPath) !== normalizedTarget) { + continue; + } + const unsetError = unsetScopedHooksPath(scope); + if (unsetError) { + return unsetError; + } + } + + const finalPath = getEffectiveHooksPath(); + if (finalPath && normalizeHooksPath(finalPath) === normalizedTarget) { + return { + message: `could not unset core.hooksPath (still "${finalPath}"); remove it with git config --unset core.hooksPath`, + isError: true, + }; + } + + return null; +} + +export interface InstallOptions { + /** + * When true, ignore a user-disabled preference (used by `vp hooks setup` / `enable`). + * Still honors `VP_GIT_HOOKS=0` / `HUSKY=0`. + */ + ignoreUserPreference?: boolean; +} + +export function install(dir = DEFAULT_HOOKS_DIR, options: InstallOptions = {}): InstallResult { // VP_GIT_HOOKS is the canonical name; VITE_GIT_HOOKS is kept for backwards compatibility. if ( process.env.HUSKY === '0' || @@ -152,14 +342,15 @@ export function install(dir = '.vite-hooks'): InstallResult { ) { return { message: 'skip install (git hooks disabled)', isError: false }; } - if (dir.includes('..')) { - return { message: '.. not allowed', isError: false }; - } - if (isAbsolute(dir)) { - return { message: 'absolute hooks directory not allowed', isError: false }; + if (!options.ignoreUserPreference && isHooksUserDisabled()) { + return { + message: 'skip install (hooks disabled; run `vp hooks enable` to re-enable)', + isError: false, + }; } - if (relative(process.cwd(), resolve(process.cwd(), dir)) === '') { - return { message: 'hooks directory must be a project subdirectory', isError: false }; + const dirError = validateHooksDir(dir); + if (dirError) { + return dirError; } const unsafeInstallPath = findUnsafeHookInstallPath(process.cwd(), dir); if (unsafeInstallPath) { @@ -168,21 +359,16 @@ export function install(dir = '.vite-hooks'): InstallResult { // Use --show-prefix to get the relative path from git root to cwd. // This avoids Windows path normalization issues (MSYS paths, 8.3 short names) // that make path.relative() unreliable across git and Node.js representations. - const prefixResult = spawnSync('git', ['rev-parse', '--show-prefix']); - if (prefixResult.status == null) { - return { message: 'git command not found', isError: true }; - } - if (prefixResult.status !== 0) { - return { message: ".git can't be found", isError: false }; + const targetResult = computeTarget(dir); + if ('message' in targetResult) { + return targetResult; } + const { target } = targetResult; const internal = (x = '') => join(dir, '_', x); - const rel = prefixResult.stdout.toString().trim().replace(/\/$/, ''); - const target = rel ? `${rel}/${dir}/_` : `${dir}/_`; // Read the effective value so a worktree-scoped setting cannot silently // override the local value we are about to write. - const checkResult = spawnSync('git', ['config', '--get', 'core.hooksPath']); - const existingHooksPath = checkResult.status === 0 ? checkResult.stdout?.toString().trim() : ''; + const existingHooksPath = getEffectiveHooksPath(); if (existingHooksPath && normalizeHooksPath(existingHooksPath) !== normalizeHooksPath(target)) { return { message: `core.hooksPath is already set to "${existingHooksPath}", skipping`, @@ -206,5 +392,215 @@ export function install(dir = '.vite-hooks'): InstallResult { if (status) { return { message: '' + stderr, isError: true }; } + + // Persist enabled state + directory for later enable/disable/status. + const clearDisabled = setHooksUserDisabled(false); + if (!clearDisabled.ok) { + return { + message: clearDisabled.error || 'failed to clear hooks disabled preference', + isError: true, + }; + } + const storeDir = setStoredHooksDir(dir); + if (!storeDir.ok) { + return { message: storeDir.error || 'failed to store hooks directory', isError: true }; + } + return { message: '', isError: false }; } + +/** + * Install (or refresh) the Vite+ hook dispatcher and mark hooks as enabled. + * Clears a previous `vp hooks disable` preference. + */ +export function setup(dir = DEFAULT_HOOKS_DIR): InstallResult { + const result = install(dir, { ignoreUserPreference: true }); + if (result.isError) { + return result; + } + if (result.message) { + // Non-error skip messages (env disabled, foreign hooksPath, unsafe path, etc.) + return result; + } + return { + message: `Git hook dispatcher installed at ${dir}/_`, + isError: false, + }; +} + +/** + * Re-enable hooks after `disable` (same as setup). + */ +export function enable(dir = DEFAULT_HOOKS_DIR): InstallResult { + return setup(dir); +} + +/** + * Disable Vite+ hooks in this repo and tear down the dispatcher. + * + * - Persists the decision in local git config so `vp config` / prepare do not reinstall + * - Unsets `core.hooksPath` only when it points at this project's dispatcher + * - Removes the generated `/_` directory + * - Leaves project-owned hooks, staged config, and package.json scripts alone + */ +export function disable(dir = DEFAULT_HOOKS_DIR): InstallResult { + const dirError = validateHooksDir(dir); + if (dirError) { + return dirError; + } + + const targetResult = computeTarget(dir); + if ('message' in targetResult) { + return targetResult; + } + const { target } = targetResult; + const internalDir = join(dir, '_'); + const hasInternalDir = existsSync(internalDir); + + // Refuse unsafe trees before any git config mutation so we never leave a + // partial teardown (hooksPath cleared but `_/` still present). + if (hasInternalDir) { + const unsafeInstallPath = findUnsafeHookInstallPath(process.cwd(), dir); + if (unsafeInstallPath) { + return { + message: describeUnsafeHookInstallPath(unsafeInstallPath), + isError: false, + }; + } + } + + const existingHooksPath = getEffectiveHooksPath(); + const ownsHooksPath = + !!existingHooksPath && normalizeHooksPath(existingHooksPath) === normalizeHooksPath(target); + const foreignHooksPath = + !!existingHooksPath && normalizeHooksPath(existingHooksPath) !== normalizeHooksPath(target); + + const actions: string[] = []; + const notes: string[] = []; + + // Persist preference *before* teardown so a later unset/rm failure still + // blocks prepare / `vp config` from reinstalling the dispatcher. + const pref = setHooksUserDisabled(true); + if (!pref.ok) { + return { message: pref.error || 'failed to persist hooks disabled preference', isError: true }; + } + const storeDir = setStoredHooksDir(dir); + if (!storeDir.ok) { + return { message: storeDir.error || 'failed to store hooks directory', isError: true }; + } + actions.push('recorded disable preference (local git config)'); + + if (ownsHooksPath) { + const unsetError = unsetOwnedHooksPath(target); + if (unsetError) { + return { + message: `${unsetError.message}; disable preference was recorded (local git config). Run \`vp hooks enable\` to clear it, or \`git config --local --unset vp.hooks.disabled\``, + isError: true, + }; + } + actions.push(`unset core.hooksPath (was "${existingHooksPath}")`); + } else if (foreignHooksPath) { + notes.push( + `core.hooksPath is set to "${existingHooksPath}" (not Vite+ dispatcher "${target}"), left unchanged`, + ); + } + + if (hasInternalDir) { + rmSync(internalDir, { recursive: true, force: true }); + actions.push(`removed ${internalDir}`); + } + + const summary = `Git hooks disabled: ${actions.join('; ')}. Project-owned hooks under ${dir}/ and staged config were left unchanged. Run \`vp hooks enable\` to re-enable.`; + if (notes.length > 0) { + return { message: `${summary} ${notes.join('; ')}.`, isError: false }; + } + return { message: summary, isError: false }; +} + +/** + * Report whether Vite+ hooks are set up, disabled by preference, and active. + */ +export function status(dir?: string): InstallResult & { status?: HooksStatus } { + const hooksDir = resolveHooksDir(dir); + if (dir) { + const dirError = validateHooksDir(dir); + if (dirError) { + return dirError; + } + } else { + const dirError = validateHooksDir(hooksDir); + if (dirError) { + return dirError; + } + } + + const prefixResult = spawnSync('git', ['rev-parse', '--show-prefix']); + if (prefixResult.status == null) { + return { message: 'git command not found', isError: true }; + } + if (prefixResult.status !== 0) { + return { message: ".git can't be found", isError: false }; + } + + const rel = prefixResult.stdout.toString().trim().replace(/\/$/, ''); + const target = rel ? `${rel}/${hooksDir}/_` : `${hooksDir}/_`; + const existingHooksPath = getEffectiveHooksPath(); + const userDisabled = isHooksUserDisabled(); + const dispatcherInstalled = existsSync(join(hooksDir, '_', 'h')); + const ownsHooksPath = + !!existingHooksPath && normalizeHooksPath(existingHooksPath) === normalizeHooksPath(target); + + let projectHooks: string[] = []; + if (existsSync(hooksDir)) { + try { + projectHooks = readdirSync(hooksDir, { withFileTypes: true }) + .filter((entry) => entry.isFile() && SUPPORTED_GIT_HOOK_NAMES.includes(entry.name)) + .map((entry) => entry.name) + .toSorted(); + } catch { + projectHooks = []; + } + } + + // Preference is the stored user decision, not runtime activity. + // - disabled (local): user ran `vp hooks disable` + // - enabled: setup/enable ran (or hooks are currently owned/installed) + // - not set: no disable preference and no evidence of a prior setup + const preferenceLabel = userDisabled + ? 'disabled (local)' + : getStoredHooksDir() || dispatcherInstalled || ownsHooksPath + ? 'enabled' + : 'not set'; + const hooksPathLabel = existingHooksPath || '(unset)'; + const ownership = !existingHooksPath + ? '' + : ownsHooksPath + ? ' (Vite+ dispatcher)' + : ' (not Vite+ dispatcher)'; + const dispatcherLabel = dispatcherInstalled ? 'installed' : 'missing'; + const projectHooksLabel = projectHooks.length > 0 ? projectHooks.join(', ') : '(none)'; + + const lines = [ + `Preference: ${preferenceLabel}`, + `Hooks dir: ${hooksDir}`, + `core.hooksPath: ${hooksPathLabel}${ownership}`, + `Dispatcher: ${dispatcherLabel} (${hooksDir}/_)`, + `Project hooks: ${projectHooksLabel}`, + ]; + + const hooksStatus: HooksStatus = { + hooksDir, + userDisabled, + hooksPath: existingHooksPath || null, + ownsHooksPath, + dispatcherInstalled, + projectHooks, + lines, + }; + + return { + message: lines.join('\n'), + isError: false, + status: hooksStatus, + }; +} From 7b808057b3986c5cb782e1e2c20f7c53b1cf583c Mon Sep 17 00:00:00 2001 From: Denny Biasiolli Date: Wed, 5 Aug 2026 12:52:29 +0200 Subject: [PATCH 02/10] feat(cli): wire vp hooks command in the local CLI Add the hooks bin entry and register it from bin.ts so local vp can run setup/disable/enable/status. Bundle the entry with tsdown, document it in the CLI package build notes, and add a PTY lifecycle fixture for setup/status/disable/enable plus prepare-style config skip after disable. --- .../.vite-hooks/pre-commit | 1 + .../command_hooks_lifecycle/package.json | 3 + .../command_hooks_lifecycle/snapshots.toml | 20 +++ .../snapshots/command_hooks_lifecycle.md | 124 ++++++++++++++++ .../command_hooks_lifecycle/vite.config.ts | 3 + packages/cli/BUNDLING.md | 3 +- packages/cli/src/bin.ts | 4 +- packages/cli/src/hooks/bin.ts | 140 ++++++++++++++++++ packages/cli/tsdown.config.ts | 1 + 9 files changed, 297 insertions(+), 2 deletions(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_hooks_lifecycle/.vite-hooks/pre-commit create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_hooks_lifecycle/package.json create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_hooks_lifecycle/snapshots.toml create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_hooks_lifecycle/snapshots/command_hooks_lifecycle.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_hooks_lifecycle/vite.config.ts create mode 100644 packages/cli/src/hooks/bin.ts diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_hooks_lifecycle/.vite-hooks/pre-commit b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_hooks_lifecycle/.vite-hooks/pre-commit new file mode 100644 index 0000000000..85fb65b4fc --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_hooks_lifecycle/.vite-hooks/pre-commit @@ -0,0 +1 @@ +vp staged diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_hooks_lifecycle/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_hooks_lifecycle/package.json new file mode 100644 index 0000000000..7f574f4feb --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_hooks_lifecycle/package.json @@ -0,0 +1,3 @@ +{ + "name": "command-hooks-lifecycle" +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_hooks_lifecycle/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_hooks_lifecycle/snapshots.toml new file mode 100644 index 0000000000..6a2dec4674 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_hooks_lifecycle/snapshots.toml @@ -0,0 +1,20 @@ +[[case]] +name = "command_hooks_lifecycle" +vp = "local" +skip-platforms = ["windows"] +steps = [ + { argv = ["git", "init"], snapshot = false, continue-on-failure = true }, + { argv = ["vp", "hooks", "status"], comment = "preference not set before setup", continue-on-failure = true }, + { argv = ["vp", "hooks", "setup"], comment = "install dispatcher", continue-on-failure = true }, + { argv = ["vp", "hooks", "status"], comment = "preference enabled after setup", continue-on-failure = true }, + { argv = ["git", "config", "--local", "core.hooksPath"], comment = "should be .vite-hooks/_", continue-on-failure = true }, + { argv = ["vp", "hooks", "disable"], comment = "tear down and persist preference", continue-on-failure = true }, + { argv = ["vp", "hooks", "status"], comment = "preference disabled (local)", continue-on-failure = true }, + { argv = ["vpt", "stat-file", ".vite-hooks/_/pre-commit", "--assert", "missing"], comment = "dispatcher removed", continue-on-failure = true }, + { argv = ["vpt", "print-file", ".vite-hooks/pre-commit"], comment = "project-owned hook left unchanged", continue-on-failure = true }, + { argv = ["vp", "config", "--no-agent"], comment = "prepare-like config should skip reinstall", envs = [["npm_lifecycle_event", "prepare"]], continue-on-failure = true }, + { argv = ["vpt", "stat-file", ".vite-hooks/_/pre-commit", "--assert", "missing"], comment = "still missing after vp config", continue-on-failure = true }, + { argv = ["vp", "hooks", "enable"], comment = "re-enable after disable", continue-on-failure = true }, + { argv = ["vp", "hooks", "status"], comment = "preference enabled again", continue-on-failure = true }, + { argv = ["git", "config", "--local", "core.hooksPath"], comment = "dispatcher restored", continue-on-failure = true }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_hooks_lifecycle/snapshots/command_hooks_lifecycle.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_hooks_lifecycle/snapshots/command_hooks_lifecycle.md new file mode 100644 index 0000000000..c05fe6af80 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_hooks_lifecycle/snapshots/command_hooks_lifecycle.md @@ -0,0 +1,124 @@ +# command_hooks_lifecycle + +## `git init` + + +## `vp hooks status` + +preference not set before setup + +``` +Preference: not set +Hooks dir: .vite-hooks +core.hooksPath: (unset) +Dispatcher: missing (.vite-hooks/_) +Project hooks: pre-commit +``` + +## `vp hooks setup` + +install dispatcher + +``` +Git hook dispatcher installed at .vite-hooks/_ +``` + +## `vp hooks status` + +preference enabled after setup + +``` +Preference: enabled +Hooks dir: .vite-hooks +core.hooksPath: .vite-hooks/_ (Vite+ dispatcher) +Dispatcher: installed (.vite-hooks/_) +Project hooks: pre-commit +``` + +## `git config --local core.hooksPath` + +should be .vite-hooks/_ + +``` +.vite-hooks/_ +``` + +## `vp hooks disable` + +tear down and persist preference + +``` +Git hooks disabled: recorded disable preference (local git config); unset core.hooksPath (was ".vite-hooks/_"); removed .vite-hooks/_. Project-owned hooks under .vite-hooks/ and staged config were left unchanged. Run `vp hooks enable` to re-enable. +``` + +## `vp hooks status` + +preference disabled (local) + +``` +Preference: disabled (local) +Hooks dir: .vite-hooks +core.hooksPath: (unset) +Dispatcher: missing (.vite-hooks/_) +Project hooks: pre-commit +``` + +## `vpt stat-file .vite-hooks/_/pre-commit --assert missing` + +dispatcher removed + +``` +.vite-hooks/_/pre-commit: missing +``` + +## `vpt print-file .vite-hooks/pre-commit` + +project-owned hook left unchanged + +``` +vp staged +``` + +## `npm_lifecycle_event=prepare vp config --no-agent` + +prepare-like config should skip reinstall + +``` +skip install (hooks disabled; run `vp hooks enable` to re-enable) +``` + +## `vpt stat-file .vite-hooks/_/pre-commit --assert missing` + +still missing after vp config + +``` +.vite-hooks/_/pre-commit: missing +``` + +## `vp hooks enable` + +re-enable after disable + +``` +Git hook dispatcher installed at .vite-hooks/_ +``` + +## `vp hooks status` + +preference enabled again + +``` +Preference: enabled +Hooks dir: .vite-hooks +core.hooksPath: .vite-hooks/_ (Vite+ dispatcher) +Dispatcher: installed (.vite-hooks/_) +Project hooks: pre-commit +``` + +## `git config --local core.hooksPath` + +dispatcher restored + +``` +.vite-hooks/_ +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_hooks_lifecycle/vite.config.ts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_hooks_lifecycle/vite.config.ts new file mode 100644 index 0000000000..3210accbf9 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_hooks_lifecycle/vite.config.ts @@ -0,0 +1,3 @@ +import { defineConfig } from 'vite-plus'; + +export default defineConfig({}); diff --git a/packages/cli/BUNDLING.md b/packages/cli/BUNDLING.md index a965441d97..1b49eaa8fc 100644 --- a/packages/cli/BUNDLING.md +++ b/packages/cli/BUNDLING.md @@ -22,7 +22,7 @@ Bundles all CLI entry points using tsdown (configured in `tsdown.config.ts`). Th **ESM build** — bundles all entry points to `dist/`: - Public API entries: `bin`, `index`, `define-config`, `fmt`, `lint`, `pack`, `pack-bin` -- Global command entries: `create`, `migrate`, `version`, `config`, `mcp`, `staged` +- Global command entries: `create`, `migrate`, `version`, `config`, `hooks`, `mcp`, `staged` - All third-party dependencies are inlined at build time - Only packages that must be resolved at runtime stay external (NAPI binding, `@voidzero-dev/vite-plus-core`, `vitest`, `oxfmt`, `oxlint`) - Code splitting creates shared chunks for code used by multiple entries @@ -130,6 +130,7 @@ packages/cli/ │ ├── migrate.js # Global command: vp migrate │ ├── version.js # Global command: vp --version │ ├── config/bin.js # Global command: vp config +│ ├── hooks/bin.js # Global command: vp hooks │ ├── mcp.js # Global command: vp mcp │ ├── staged/bin.js # Global command: vp staged │ ├── *-.js # Shared chunks (code splitting) diff --git a/packages/cli/src/bin.ts b/packages/cli/src/bin.ts index 60fc0b7ae8..15cb294d0a 100644 --- a/packages/cli/src/bin.ts +++ b/packages/cli/src/bin.ts @@ -1,7 +1,7 @@ /** * Unified entry point for both the local CLI (via bin/vp) and the global CLI (via Rust vp binary). * - * Global commands (create, migrate, config, staged, --version) are handled by tsdown-bundled modules. + * Global commands (create, migrate, config, hooks, staged, --version) are handled by tsdown-bundled modules. * All other commands are delegated to the Rust core through NAPI bindings, which * uses JavaScript tool resolver functions to locate tool binaries. * @@ -110,6 +110,8 @@ if (maybePrintCommandHelp(args)) { await import('./migration/bin.js'); } else if (command === 'config') { await import('./config/bin.js'); +} else if (command === 'hooks') { + await import('./hooks/bin.js'); } else if (command === '--version' || command === '-V') { await import('./version.js'); } else if (command === 'staged') { diff --git a/packages/cli/src/hooks/bin.ts b/packages/cli/src/hooks/bin.ts new file mode 100644 index 0000000000..fa44bfc0e8 --- /dev/null +++ b/packages/cli/src/hooks/bin.ts @@ -0,0 +1,140 @@ +import mri from 'mri'; + +import { + DEFAULT_HOOKS_DIR, + disable, + enable, + resolveHooksDir, + setup, + status, +} from '../config/hooks.ts'; +import { renderCliDoc } from '../utils/help.ts'; +import { log, printHeader } from '../utils/terminal.ts'; + +const SUBCOMMANDS = ['setup', 'disable', 'enable', 'status'] as const; +type Subcommand = (typeof SUBCOMMANDS)[number]; + +function isSubcommand(value: string | undefined): value is Subcommand { + return !!value && (SUBCOMMANDS as readonly string[]).includes(value); +} + +function printHelp(): void { + const helpMessage = renderCliDoc({ + usage: 'vp hooks [OPTIONS]', + summary: 'Manage the Vite+ Git hook dispatcher for this repository.', + documentationUrl: 'https://viteplus.dev/guide/commit-hooks', + sections: [ + { + title: 'Commands', + rows: [ + { + label: 'setup', + description: 'Install or refresh the hook dispatcher (sets core.hooksPath)', + }, + { + label: 'disable', + description: 'Disable hooks: unset core.hooksPath, remove /_, persist preference', + }, + { + label: 'enable', + description: 'Re-enable hooks after disable (same as setup)', + }, + { + label: 'status', + description: 'Show preference, core.hooksPath, and dispatcher state', + }, + ], + }, + { + title: 'Options', + rows: [ + { + label: '--hooks-dir ', + description: `Custom hooks directory (default: ${DEFAULT_HOOKS_DIR}, or last used)`, + }, + { label: '-h, --help', description: 'Show this help message' }, + ], + }, + { + title: 'Environment', + rows: [ + { + label: 'VP_GIT_HOOKS=0', + description: 'Skip dispatcher install in setup/enable (and skip hooks at commit time)', + }, + ], + }, + { + title: 'Examples', + lines: [ + ' vp hooks setup', + ' vp hooks setup --hooks-dir .custom-hooks', + ' vp hooks disable', + ' vp hooks enable', + ' vp hooks status', + ], + }, + ], + }); + printHeader(); + log(helpMessage); +} + +function applyResult(result: { message: string; isError: boolean }): void { + if (result.message) { + log(result.message); + } + if (result.isError) { + process.exit(1); + } +} + +async function main() { + // argv: [node, bin, hooks, ?, ...flags] + const raw = process.argv.slice(3); + const first = raw[0]; + const wantsHelp = raw.includes('-h') || raw.includes('--help'); + + // `vp hooks` / `vp hooks -h` → top-level help + if (!first || first === '-h' || first === '--help') { + printHelp(); + return; + } + + if (!isSubcommand(first)) { + log(`Unknown hooks command "${first}". Expected one of: ${SUBCOMMANDS.join(', ')}`); + process.exit(1); + } + + const subcommand = first; + const args = mri(raw.slice(1), { + boolean: ['help'], + string: ['hooks-dir'], + alias: { h: 'help' }, + }); + + if (args.help || wantsHelp) { + printHelp(); + return; + } + + const dirFlag = args['hooks-dir'] as string | undefined; + const dir = resolveHooksDir(dirFlag); + + switch (subcommand) { + case 'setup': + applyResult(setup(dir)); + return; + case 'enable': + applyResult(enable(dir)); + return; + case 'disable': + applyResult(disable(dir)); + return; + case 'status': + applyResult(status(dirFlag ? dir : undefined)); + return; + } +} + +void main(); diff --git a/packages/cli/tsdown.config.ts b/packages/cli/tsdown.config.ts index b7f22145ab..94c7d182b2 100644 --- a/packages/cli/tsdown.config.ts +++ b/packages/cli/tsdown.config.ts @@ -60,6 +60,7 @@ export default defineConfig([ 'migration/compat/worker': './src/migration/compat/worker.ts', version: './src/version.ts', 'config/bin': './src/config/bin.ts', + 'hooks/bin': './src/hooks/bin.ts', 'staged/bin': './src/staged/bin.ts', }, outDir: 'dist', From d53a996de1f6bf8a35106266fb0e42a06e1fdfb0 Mon Sep 17 00:00:00 2001 From: Denny Biasiolli Date: Wed, 5 Aug 2026 12:52:29 +0200 Subject: [PATCH 03/10] feat(cli): route vp hooks through the global CLI Delegate hooks from the Rust global CLI to the JS implementation, and list the command in global help, the interactive picker, and the local NAPI help surface. Update top-level help snapshots and add vp hooks --help coverage for local and global flavors. --- .../snapshots/cli_helper_message.md | 1 + .../snapshots/cli_helper_message_local.md | 1 + .../snapshots/command_helper.md | 1 + .../command_hooks_help/snapshots.toml | 8 ++ .../snapshots/command_hooks_help.global.md | 97 +++++++++++++++++++ .../snapshots/command_hooks_help.local.md | 97 +++++++++++++++++++ .../snapshots/command_vp_alias.md | 1 + .../fixtures/vp_help/snapshots/help.global.md | 1 + .../fixtures/vp_help/snapshots/help.local.md | 1 + crates/vp_global_cli/src/cli.rs | 9 ++ crates/vp_global_cli/src/command_picker.rs | 6 ++ crates/vp_global_cli/src/commands/hooks.rs | 16 +++ crates/vp_global_cli/src/commands/mod.rs | 1 + crates/vp_global_cli/src/help.rs | 2 + packages/cli/binding/src/cli/help.rs | 3 +- 15 files changed, 244 insertions(+), 1 deletion(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_hooks_help/snapshots.toml create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_hooks_help/snapshots/command_hooks_help.global.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_hooks_help/snapshots/command_hooks_help.local.md create mode 100644 crates/vp_global_cli/src/commands/hooks.rs diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/cli_helper_message/snapshots/cli_helper_message.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/cli_helper_message/snapshots/cli_helper_message.md index 0e9d8953a2..720b2e8896 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/cli_helper_message/snapshots/cli_helper_message.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/cli_helper_message/snapshots/cli_helper_message.md @@ -13,6 +13,7 @@ Start: create Create a new project from a template migrate Migrate an existing project to Vite+ config Configure hooks and agent integration + hooks Manage the Git hook dispatcher staged Run linters on staged files install, i Install all dependencies, or add packages if package names are provided env Manage Node.js versions diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/cli_helper_message/snapshots/cli_helper_message_local.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/cli_helper_message/snapshots/cli_helper_message_local.md index 074c6346c6..6483032ffc 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/cli_helper_message/snapshots/cli_helper_message_local.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/cli_helper_message/snapshots/cli_helper_message_local.md @@ -24,6 +24,7 @@ Core Commands: preview Preview production build cache Manage the task cache config Configure hooks and agent integration + hooks Manage the Git hook dispatcher staged Run linters on staged files Package Manager Commands: diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_helper/snapshots/command_helper.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_helper/snapshots/command_helper.md index 81cd7514a4..a7e1d4fbf2 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_helper/snapshots/command_helper.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_helper/snapshots/command_helper.md @@ -24,6 +24,7 @@ Core Commands: preview Preview production build cache Manage the task cache config Configure hooks and agent integration + hooks Manage the Git hook dispatcher staged Run linters on staged files Package Manager Commands: diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_hooks_help/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_hooks_help/snapshots.toml new file mode 100644 index 0000000000..8ace63f5ef --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_hooks_help/snapshots.toml @@ -0,0 +1,8 @@ +[[case]] +name = "command_hooks_help" +vp = ["local", "global"] +steps = [ + { argv = ["vp", "hooks", "-h"], continue-on-failure = true }, + { argv = ["vp", "hooks", "--help"], continue-on-failure = true }, + { argv = ["vp", "help", "hooks"], continue-on-failure = true }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_hooks_help/snapshots/command_hooks_help.global.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_hooks_help/snapshots/command_hooks_help.global.md new file mode 100644 index 0000000000..74eee4590d --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_hooks_help/snapshots/command_hooks_help.global.md @@ -0,0 +1,97 @@ +# command_hooks_help + +## `vp hooks -h` + +``` +VITE+ - The Unified Toolchain for the Web + +Usage: vp hooks [OPTIONS] + +Manage the Vite+ Git hook dispatcher for this repository. + +Commands: + setup Install or refresh the hook dispatcher (sets core.hooksPath) + disable Disable hooks: unset core.hooksPath, remove /_, persist preference + enable Re-enable hooks after disable (same as setup) + status Show preference, core.hooksPath, and dispatcher state + +Options: + --hooks-dir Custom hooks directory (default: .vite-hooks, or last used) + -h, --help Show this help message + +Environment: + VP_GIT_HOOKS=0 Skip dispatcher install in setup/enable (and skip hooks at commit time) + +Examples: + vp hooks setup + vp hooks setup --hooks-dir .custom-hooks + vp hooks disable + vp hooks enable + vp hooks status + +Documentation: https://viteplus.dev/guide/commit-hooks +``` + +## `vp hooks --help` + +``` +VITE+ - The Unified Toolchain for the Web + +Usage: vp hooks [OPTIONS] + +Manage the Vite+ Git hook dispatcher for this repository. + +Commands: + setup Install or refresh the hook dispatcher (sets core.hooksPath) + disable Disable hooks: unset core.hooksPath, remove /_, persist preference + enable Re-enable hooks after disable (same as setup) + status Show preference, core.hooksPath, and dispatcher state + +Options: + --hooks-dir Custom hooks directory (default: .vite-hooks, or last used) + -h, --help Show this help message + +Environment: + VP_GIT_HOOKS=0 Skip dispatcher install in setup/enable (and skip hooks at commit time) + +Examples: + vp hooks setup + vp hooks setup --hooks-dir .custom-hooks + vp hooks disable + vp hooks enable + vp hooks status + +Documentation: https://viteplus.dev/guide/commit-hooks +``` + +## `vp help hooks` + +``` +VITE+ - The Unified Toolchain for the Web + +Usage: vp hooks [OPTIONS] + +Manage the Vite+ Git hook dispatcher for this repository. + +Commands: + setup Install or refresh the hook dispatcher (sets core.hooksPath) + disable Disable hooks: unset core.hooksPath, remove /_, persist preference + enable Re-enable hooks after disable (same as setup) + status Show preference, core.hooksPath, and dispatcher state + +Options: + --hooks-dir Custom hooks directory (default: .vite-hooks, or last used) + -h, --help Show this help message + +Environment: + VP_GIT_HOOKS=0 Skip dispatcher install in setup/enable (and skip hooks at commit time) + +Examples: + vp hooks setup + vp hooks setup --hooks-dir .custom-hooks + vp hooks disable + vp hooks enable + vp hooks status + +Documentation: https://viteplus.dev/guide/commit-hooks +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_hooks_help/snapshots/command_hooks_help.local.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_hooks_help/snapshots/command_hooks_help.local.md new file mode 100644 index 0000000000..74eee4590d --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_hooks_help/snapshots/command_hooks_help.local.md @@ -0,0 +1,97 @@ +# command_hooks_help + +## `vp hooks -h` + +``` +VITE+ - The Unified Toolchain for the Web + +Usage: vp hooks [OPTIONS] + +Manage the Vite+ Git hook dispatcher for this repository. + +Commands: + setup Install or refresh the hook dispatcher (sets core.hooksPath) + disable Disable hooks: unset core.hooksPath, remove /_, persist preference + enable Re-enable hooks after disable (same as setup) + status Show preference, core.hooksPath, and dispatcher state + +Options: + --hooks-dir Custom hooks directory (default: .vite-hooks, or last used) + -h, --help Show this help message + +Environment: + VP_GIT_HOOKS=0 Skip dispatcher install in setup/enable (and skip hooks at commit time) + +Examples: + vp hooks setup + vp hooks setup --hooks-dir .custom-hooks + vp hooks disable + vp hooks enable + vp hooks status + +Documentation: https://viteplus.dev/guide/commit-hooks +``` + +## `vp hooks --help` + +``` +VITE+ - The Unified Toolchain for the Web + +Usage: vp hooks [OPTIONS] + +Manage the Vite+ Git hook dispatcher for this repository. + +Commands: + setup Install or refresh the hook dispatcher (sets core.hooksPath) + disable Disable hooks: unset core.hooksPath, remove /_, persist preference + enable Re-enable hooks after disable (same as setup) + status Show preference, core.hooksPath, and dispatcher state + +Options: + --hooks-dir Custom hooks directory (default: .vite-hooks, or last used) + -h, --help Show this help message + +Environment: + VP_GIT_HOOKS=0 Skip dispatcher install in setup/enable (and skip hooks at commit time) + +Examples: + vp hooks setup + vp hooks setup --hooks-dir .custom-hooks + vp hooks disable + vp hooks enable + vp hooks status + +Documentation: https://viteplus.dev/guide/commit-hooks +``` + +## `vp help hooks` + +``` +VITE+ - The Unified Toolchain for the Web + +Usage: vp hooks [OPTIONS] + +Manage the Vite+ Git hook dispatcher for this repository. + +Commands: + setup Install or refresh the hook dispatcher (sets core.hooksPath) + disable Disable hooks: unset core.hooksPath, remove /_, persist preference + enable Re-enable hooks after disable (same as setup) + status Show preference, core.hooksPath, and dispatcher state + +Options: + --hooks-dir Custom hooks directory (default: .vite-hooks, or last used) + -h, --help Show this help message + +Environment: + VP_GIT_HOOKS=0 Skip dispatcher install in setup/enable (and skip hooks at commit time) + +Examples: + vp hooks setup + vp hooks setup --hooks-dir .custom-hooks + vp hooks disable + vp hooks enable + vp hooks status + +Documentation: https://viteplus.dev/guide/commit-hooks +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vp_alias/snapshots/command_vp_alias.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vp_alias/snapshots/command_vp_alias.md index c9202244ec..404a127ddb 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vp_alias/snapshots/command_vp_alias.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vp_alias/snapshots/command_vp_alias.md @@ -24,6 +24,7 @@ Core Commands: preview Preview production build cache Manage the task cache config Configure hooks and agent integration + hooks Manage the Git hook dispatcher staged Run linters on staged files Package Manager Commands: diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/vp_help/snapshots/help.global.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/vp_help/snapshots/help.global.md index fc51e24490..5d591abceb 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/vp_help/snapshots/help.global.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/vp_help/snapshots/help.global.md @@ -13,6 +13,7 @@ Start: create Create a new project from a template migrate Migrate an existing project to Vite+ config Configure hooks and agent integration + hooks Manage the Git hook dispatcher staged Run linters on staged files install, i Install all dependencies, or add packages if package names are provided env Manage Node.js versions diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/vp_help/snapshots/help.local.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/vp_help/snapshots/help.local.md index 166ac74621..c9c06048ec 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/vp_help/snapshots/help.local.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/vp_help/snapshots/help.local.md @@ -24,6 +24,7 @@ Core Commands: preview Preview production build cache Manage the task cache config Configure hooks and agent integration + hooks Manage the Git hook dispatcher staged Run linters on staged files Package Manager Commands: diff --git a/crates/vp_global_cli/src/cli.rs b/crates/vp_global_cli/src/cli.rs index ce60057396..fa347d9a36 100644 --- a/crates/vp_global_cli/src/cli.rs +++ b/crates/vp_global_cli/src/cli.rs @@ -97,6 +97,13 @@ pub enum Commands { args: Vec, }, + /// Manage the Vite+ Git hook dispatcher + #[command(disable_help_flag = true)] + Hooks { + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + /// Run vite-staged on Git staged files #[command(disable_help_flag = true, name = "staged")] Staged { @@ -967,6 +974,8 @@ pub async fn run_command_with_options( Commands::Config { args } => commands::config::execute(cwd, &args, raw_subcommand).await, + Commands::Hooks { args } => commands::hooks::execute(cwd, &args, raw_subcommand).await, + Commands::Staged { args } => commands::staged::execute(cwd, &args, raw_subcommand).await, // Category C: Local CLI Delegation (forwarded to the local vite-plus CLI) diff --git a/crates/vp_global_cli/src/command_picker.rs b/crates/vp_global_cli/src/command_picker.rs index ae2ce31ca9..0f9ddaa581 100644 --- a/crates/vp_global_cli/src/command_picker.rs +++ b/crates/vp_global_cli/src/command_picker.rs @@ -94,6 +94,12 @@ const COMMANDS: &[CommandEntry] = &[ summary: "Configure hooks and agent integration.", append_help: false, }, + CommandEntry { + label: "hooks", + command: "hooks", + summary: "Manage the Git hook dispatcher.", + append_help: false, + }, CommandEntry { label: "outdated", command: "outdated", diff --git a/crates/vp_global_cli/src/commands/hooks.rs b/crates/vp_global_cli/src/commands/hooks.rs new file mode 100644 index 0000000000..1949c55c83 --- /dev/null +++ b/crates/vp_global_cli/src/commands/hooks.rs @@ -0,0 +1,16 @@ +//! Hooks command (Category B: JavaScript Command). + +use std::process::ExitStatus; + +use vt_path::AbsolutePathBuf; + +use crate::error::Error; + +/// Execute the `hooks` command by delegating to local or global vite-plus. +pub async fn execute( + cwd: AbsolutePathBuf, + args: &[String], + raw_subcommand: Option<&str>, +) -> Result { + super::delegate::execute(cwd, "hooks", args, raw_subcommand).await +} diff --git a/crates/vp_global_cli/src/commands/mod.rs b/crates/vp_global_cli/src/commands/mod.rs index 47b97046a1..df1491abc9 100644 --- a/crates/vp_global_cli/src/commands/mod.rs +++ b/crates/vp_global_cli/src/commands/mod.rs @@ -91,6 +91,7 @@ pub mod global; // Category B: JS Script Commands pub mod config; pub mod create; +pub mod hooks; pub mod migrate; pub mod staged; pub mod version; diff --git a/crates/vp_global_cli/src/help.rs b/crates/vp_global_cli/src/help.rs index 01dd5705e7..62a1b5aed3 100644 --- a/crates/vp_global_cli/src/help.rs +++ b/crates/vp_global_cli/src/help.rs @@ -395,6 +395,7 @@ pub fn top_level_help_doc() -> HelpDoc { row("create", "Create a new project from a template"), row("migrate", "Migrate an existing project to Vite+"), row("config", "Configure hooks and agent integration"), + row("hooks", "Manage the Git hook dispatcher"), row("staged", "Run linters on staged files"), row( "install, i", @@ -562,6 +563,7 @@ fn skip_clap_unified_help(command: &str) -> bool { "create" | "migrate" | "config" + | "hooks" | "staged" | "dev" | "build" diff --git a/packages/cli/binding/src/cli/help.rs b/packages/cli/binding/src/cli/help.rs index 58db52c68a..2dc2d1df62 100644 --- a/packages/cli/binding/src/cli/help.rs +++ b/packages/cli/binding/src/cli/help.rs @@ -202,6 +202,7 @@ pub(super) fn print_help() { {bold}preview{reset} Preview production build {bold}cache{reset} Manage the task cache {bold}config{reset} Configure hooks and agent integration + {bold}hooks{reset} Manage the Git hook dispatcher {bold}staged{reset} Run linters on staged files {bold_underline}Package Manager Commands:{reset} @@ -311,7 +312,7 @@ mod tests { fn global_subcommands_produce_invalid_subcommand_error() { use clap::error::ErrorKind; - for subcommand in ["config", "create", "env", "migrate"] { + for subcommand in ["config", "create", "env", "hooks", "migrate"] { let error = CLIArgs::try_parse_from(["vp", subcommand]) .expect_err(&format!("expected error for global subcommand '{subcommand}'")); assert_eq!( From bd566c7607e5178a1576cdff3b37fb8b2929b53c Mon Sep 17 00:00:00 2001 From: Denny Biasiolli Date: Wed, 5 Aug 2026 12:52:29 +0200 Subject: [PATCH 04/10] docs: document vp hooks workflow for users Add setup/disable/enable/status to the commit-hooks guide with a quick start, and point create, migrate, troubleshooting, and the guide index at the new commands so users can discover and operate them easily. --- docs/guide/commit-hooks.md | 103 +++++++++++++++++++++++++++++----- docs/guide/create.md | 7 ++- docs/guide/index.md | 1 + docs/guide/migrate.md | 4 +- docs/guide/troubleshooting.md | 11 +++- 5 files changed, 105 insertions(+), 21 deletions(-) diff --git a/docs/guide/commit-hooks.md b/docs/guide/commit-hooks.md index e37c6f0b36..38c57deef2 100644 --- a/docs/guide/commit-hooks.md +++ b/docs/guide/commit-hooks.md @@ -1,7 +1,7 @@ # Commit Hooks -Use `vp config` to install the Git hook dispatcher, and `vp staged` to run checks on staged -files. +Use `vp hooks` to manage the Git hook dispatcher, `vp config` for project setup +(hooks + agent integration), and `vp staged` to run checks on staged files. ## Overview @@ -9,18 +9,67 @@ Vite+ supports commit hooks and staged-file checks without additional tooling. Use: -- `vp config` to install generated hook infrastructure and related integrations +- `vp hooks setup` / `enable` / `disable` / `status` to manage the generated hook dispatcher +- `vp config` to install the dispatcher (when not disabled) and update agent integration - `vp staged` to run checks against the files currently staged in Git If you use [`vp create`](/guide/create) or [`vp migrate`](/guide/migrate), Vite+ prompts you to set this up for your project automatically. +### Quick start + +```bash +# Install or refresh the dispatcher +vp hooks setup + +# Check what is active in this clone +vp hooks status + +# Turn hooks off in this clone (survives npm install / prepare) +vp hooks disable + +# Turn them back on +vp hooks enable +``` + ## Commands +### `vp hooks` + +Manage the Vite+ Git hook dispatcher for the current repository: + +```bash +vp hooks setup +vp hooks setup --hooks-dir .custom-hooks +vp hooks disable +vp hooks enable +vp hooks status +``` + +| Command | Behavior | +| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `setup` | Install or refresh the generated dispatcher under `/_` and set `core.hooksPath`. Clears a previous disable preference. | +| `disable` | Tear down the dispatcher (unset `core.hooksPath` when it points at Vite+, remove `/_`) and **persist** the disable decision in local git config so `vp config` / lifecycle scripts do not reinstall it. | +| `enable` | Re-enable after `disable` (same as `setup`). | +| `status` | Show preference, `core.hooksPath`, dispatcher presence, and project-owned hook scripts. | + +By default, project hooks live in `.vite-hooks`. Pass `--hooks-dir` to use another subdirectory. After the first successful setup, the directory is remembered in local git config for later `enable` / `disable` / `status` / `vp config` calls in this clone. + +`status` reports preference as: + +- `not set` — no disable preference and no prior setup in this clone +- `enabled` — setup/enable has run (or the dispatcher is currently owned) +- `disabled (local)` — after `vp hooks disable` + +Check the `Dispatcher` and `core.hooksPath` lines to see whether hooks are actually active. + +`disable` / `setup` do **not** delete project-owned hook scripts (for example `.vite-hooks/pre-commit`), the `staged` block in `vite.config.ts`, or lifecycle scripts that call `vp config`. + ### `vp config` `vp config` configures Vite+ for the current project. It installs the generated Git hook -dispatcher and can also handle related project integration such as agent setup. By default, -project hooks are read from `.vite-hooks`: +dispatcher (unless hooks were disabled with `vp hooks disable`) and can also handle related +project integration such as agent setup. The hooks directory defaults to `.vite-hooks`, or the +last directory used by `vp hooks` / `vp config` in this clone: ```bash vp config @@ -31,14 +80,17 @@ vp config --no-agent Use `--no-hooks` when you want `vp config` to leave the Git hook dispatcher unchanged. Use `--no-agent` when you want it to skip updates to existing coding agent instruction files. You can -pass both flags when you want `vp config` to skip both setup steps. +pass both flags when you want `vp config` to skip both setup steps. After `vp hooks disable`, +`vp config` skips reinstalling the dispatcher and points you at `vp hooks enable` instead of +prompting again. 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`. -`vp config` does not create or modify project hook scripts or staged-file configuration. +The generated dispatcher and shims under `.vite-hooks/_` are ignored and recreated by `vp config` +or `vp hooks setup`. Neither command creates or modifies project hook scripts or staged-file +configuration. ### `vp staged` @@ -88,7 +140,7 @@ Set `VP_GIT_HOOKS=0` in the environment of the process that runs `git commit`, a VP_GIT_HOOKS=0 git commit -m "content update" ``` -`HUSKY=0` is honored the same way for ecosystem tooling compatibility. Setting `VP_GIT_HOOKS=0` in an environment also keeps `vp config` from reinstalling hooks there when a lifecycle script such as `prepare` runs. +`HUSKY=0` is honored the same way for ecosystem tooling compatibility. Setting `VP_GIT_HOOKS=0` in an environment also keeps `vp config` / `vp hooks setup` from reinstalling hooks there when a lifecycle script such as `prepare` runs. ### Init script @@ -107,21 +159,44 @@ Because the hook itself reads this file, it works even when the committing proce ## Removing commit hooks -To stop using the Vite+ hook dispatcher: +To stop using the Vite+ hook dispatcher in this clone (and keep `prepare` / `vp config` from +reinstalling it): -1. Remove `vp config` from the `prepare` or `postinstall` script in `package.json`. +```bash +vp hooks disable +# or, if you used a custom directory: +vp hooks disable --hooks-dir .custom-hooks +``` -2. Unset the Git hooks path that points at the Vite+ dispatcher: +This: + +1. Unsets `core.hooksPath` when it points at the Vite+ dispatcher +2. Removes the generated `/_` directory +3. Records a **local** disable preference so lifecycle scripts skip reinstall until you run + `vp hooks enable` (or `vp hooks setup`) again + +To re-enable: ```bash -git config --unset core.hooksPath +vp hooks enable ``` -3. Remove the generated dispatcher directory (use your `--hooks-dir` value if you changed it): +If you no longer want hooks for the project at all (shared with teammates), also remove `vp config` +from the `prepare` or `postinstall` script in `package.json`. + +### Manual equivalent + +If you prefer to do it by hand: ```bash +git config --unset core.hooksPath rm -rf .vite-hooks/_ +# optional: prevent prepare/vp config from reinstalling in this clone +git config --local vp.hooks.disabled true +# optional: remembered hooks directory (set by setup/enable/disable) +# git config --local vp.hooks.dir .vite-hooks ``` Project-owned scripts such as `.vite-hooks/pre-commit` and the `staged` block in `vite.config.ts` can remain for later use, or you can remove them separately if the project no longer needs them. +`vp hooks disable` does **not** delete those project-owned files. diff --git a/docs/guide/create.md b/docs/guide/create.md index cb044ba665..ecd7e78f7a 100644 --- a/docs/guide/create.md +++ b/docs/guide/create.md @@ -49,13 +49,16 @@ Run `vp create --list` to see the built-in templates and the common shorthand te - `--no-editor` skips editor config setup - `--git` initialize a git repository - `--no-git` skips git repository initialization -- `--hooks` enables pre-commit hook setup -- `--no-hooks` skips hook setup - `--package-manager ` uses a specified package manager (`pnpm`, `npm`, `yarn`, or `bun`) - `--approve-builds` approves and runs gated dependency build scripts without prompting - `--no-interactive` runs without prompts - `--verbose` shows detailed scaffolding output - `--list` prints the available built-in and popular templates +- `--hooks` enables pre-commit hook setup (dispatcher + `.vite-hooks` + `staged` config) +- `--no-hooks` skips hook setup + +After create, manage the dispatcher with `vp hooks status`, `vp hooks disable`, and `vp hooks enable`. +See the [Commit hooks guide](/guide/commit-hooks). ### Dependency build scripts diff --git a/docs/guide/index.md b/docs/guide/index.md index 1ee398444a..a16b190e9c 100644 --- a/docs/guide/index.md +++ b/docs/guide/index.md @@ -93,6 +93,7 @@ Vite+ can handle the entire local frontend development cycle from starting a pro - [`vp create`](/guide/create) creates new apps, packages, and monorepos. - [`vp migrate`](/guide/migrate) moves existing projects onto Vite+. - [`vp config`](/guide/commit-hooks) installs the Git hook dispatcher and configures agent integration. +- [`vp hooks`](/guide/commit-hooks) manages the Git hook dispatcher (`setup`, `disable`, `enable`, `status`). - [`vp staged`](/guide/commit-hooks) runs checks on staged files. - [`vp install`](/guide/install) installs dependencies with the right package manager. - [`vp env`](/guide/env) manages Node.js versions. diff --git a/docs/guide/migrate.md b/docs/guide/migrate.md index 900441b117..2713b5ebfe 100644 --- a/docs/guide/migrate.md +++ b/docs/guide/migrate.md @@ -184,9 +184,9 @@ only when no existing hook policy is found. If your project currently uses `lefthook`, `simple-git-hooks`, or `yorkie`, `vp migrate` will leave your existing configuration alone and show a warning. This happens even if you choose to set up hooks during the prompt or include the `--hooks` flag. -If you want to move one of those tools over to Vite+ manually, you can follow these steps. First, move your staged-file commands into the `staged` block within `vite.config.ts`. Then, update your lifecycle script so it runs `vp config`. You will also need to create a Vite+ hook at `.vite-hooks/pre-commit` that runs `vp staged`. Finally, once you have confirmed that the Vite+ hook is working as expected, you can remove the old tool's configuration and dependency. +If you want to move one of those tools over to Vite+ manually, you can follow these steps. First, move your staged-file commands into the `staged` block within `vite.config.ts`. Then, update your lifecycle script so it runs `vp config`. You will also need to create a Vite+ hook at `.vite-hooks/pre-commit` that runs `vp staged`. Run `vp hooks setup` (or `vp config`) to install the dispatcher and set `core.hooksPath`. Finally, once you have confirmed that the Vite+ hook is working as expected, you can remove the old tool's configuration and dependency. -You can find more details about the full Vite+ hook setup in the [Commit hooks guide](/guide/commit-hooks). +Use `vp hooks status` to verify the dispatcher is active, and `vp hooks disable` if you need to turn it off again in this clone. You can find more details about the full Vite+ hook setup in the [Commit hooks guide](/guide/commit-hooks). ## Examples diff --git a/docs/guide/troubleshooting.md b/docs/guide/troubleshooting.md index 68bc1ba79c..de7d93136a 100644 --- a/docs/guide/troubleshooting.md +++ b/docs/guide/troubleshooting.md @@ -53,9 +53,14 @@ You can also run custom tasks defined in `vite.config.ts` and migrate away from If `vp staged` fails or your pre-commit hook does not run: - make sure `vite.config.ts` contains a `staged` block -- make sure the project-owned pre-commit hook runs `vp staged` -- run `vp config` to install the hook dispatcher -- check whether hook installation was skipped intentionally through `VP_GIT_HOOKS=0` +- make sure the project-owned pre-commit hook runs `vp staged` (for example `.vite-hooks/pre-commit`) +- run `vp hooks status` to see preference, `core.hooksPath`, and whether the dispatcher is installed +- run `vp hooks setup` (or `vp config`) to install the hook dispatcher +- if status shows `Preference: disabled (local)`, re-enable with `vp hooks enable` +- check whether hooks were skipped intentionally through `VP_GIT_HOOKS=0` + +To stop hooks in this clone without deleting project policy files, run `vp hooks disable`. +See the [Commit hooks guide](/guide/commit-hooks) for the full workflow. A minimal staged config looks like this: From 66af7c2a37b912053763080d8935c7c1018c9980 Mon Sep 17 00:00:00 2001 From: Denny Biasiolli Date: Fri, 7 Aug 2026 16:50:49 +0200 Subject: [PATCH 05/10] feat(cli): drop vp hooks setup in favor of enable Keep enable/disable/status as the public surface. enable installs or refreshes the dispatcher and clears a disable preference. --- .../snapshots/command_hooks_help.global.md | 30 ++++++++----------- .../snapshots/command_hooks_help.local.md | 30 ++++++++----------- .../command_hooks_lifecycle/snapshots.toml | 6 ++-- .../snapshots/command_hooks_lifecycle.md | 6 ++-- docs/guide/commit-hooks.md | 28 ++++++++--------- docs/guide/index.md | 2 +- docs/guide/migrate.md | 2 +- docs/guide/troubleshooting.md | 2 +- .../cli/src/config/__tests__/hooks.spec.ts | 11 ++++--- packages/cli/src/config/hooks.ts | 17 ++++------- packages/cli/src/hooks/bin.ts | 19 ++++-------- 11 files changed, 61 insertions(+), 92 deletions(-) diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_hooks_help/snapshots/command_hooks_help.global.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_hooks_help/snapshots/command_hooks_help.global.md index 74eee4590d..fcd8511606 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_hooks_help/snapshots/command_hooks_help.global.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_hooks_help/snapshots/command_hooks_help.global.md @@ -10,9 +10,8 @@ Usage: vp hooks [OPTIONS] Manage the Vite+ Git hook dispatcher for this repository. Commands: - setup Install or refresh the hook dispatcher (sets core.hooksPath) + enable Install or refresh the hook dispatcher (sets core.hooksPath) disable Disable hooks: unset core.hooksPath, remove /_, persist preference - enable Re-enable hooks after disable (same as setup) status Show preference, core.hooksPath, and dispatcher state Options: @@ -20,13 +19,12 @@ Options: -h, --help Show this help message Environment: - VP_GIT_HOOKS=0 Skip dispatcher install in setup/enable (and skip hooks at commit time) + VP_GIT_HOOKS=0 Skip dispatcher install in enable (and skip hooks at commit time) Examples: - vp hooks setup - vp hooks setup --hooks-dir .custom-hooks - vp hooks disable vp hooks enable + vp hooks enable --hooks-dir .custom-hooks + vp hooks disable vp hooks status Documentation: https://viteplus.dev/guide/commit-hooks @@ -42,9 +40,8 @@ Usage: vp hooks [OPTIONS] Manage the Vite+ Git hook dispatcher for this repository. Commands: - setup Install or refresh the hook dispatcher (sets core.hooksPath) + enable Install or refresh the hook dispatcher (sets core.hooksPath) disable Disable hooks: unset core.hooksPath, remove /_, persist preference - enable Re-enable hooks after disable (same as setup) status Show preference, core.hooksPath, and dispatcher state Options: @@ -52,13 +49,12 @@ Options: -h, --help Show this help message Environment: - VP_GIT_HOOKS=0 Skip dispatcher install in setup/enable (and skip hooks at commit time) + VP_GIT_HOOKS=0 Skip dispatcher install in enable (and skip hooks at commit time) Examples: - vp hooks setup - vp hooks setup --hooks-dir .custom-hooks - vp hooks disable vp hooks enable + vp hooks enable --hooks-dir .custom-hooks + vp hooks disable vp hooks status Documentation: https://viteplus.dev/guide/commit-hooks @@ -74,9 +70,8 @@ Usage: vp hooks [OPTIONS] Manage the Vite+ Git hook dispatcher for this repository. Commands: - setup Install or refresh the hook dispatcher (sets core.hooksPath) + enable Install or refresh the hook dispatcher (sets core.hooksPath) disable Disable hooks: unset core.hooksPath, remove /_, persist preference - enable Re-enable hooks after disable (same as setup) status Show preference, core.hooksPath, and dispatcher state Options: @@ -84,13 +79,12 @@ Options: -h, --help Show this help message Environment: - VP_GIT_HOOKS=0 Skip dispatcher install in setup/enable (and skip hooks at commit time) + VP_GIT_HOOKS=0 Skip dispatcher install in enable (and skip hooks at commit time) Examples: - vp hooks setup - vp hooks setup --hooks-dir .custom-hooks - vp hooks disable vp hooks enable + vp hooks enable --hooks-dir .custom-hooks + vp hooks disable vp hooks status Documentation: https://viteplus.dev/guide/commit-hooks diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_hooks_help/snapshots/command_hooks_help.local.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_hooks_help/snapshots/command_hooks_help.local.md index 74eee4590d..fcd8511606 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_hooks_help/snapshots/command_hooks_help.local.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_hooks_help/snapshots/command_hooks_help.local.md @@ -10,9 +10,8 @@ Usage: vp hooks [OPTIONS] Manage the Vite+ Git hook dispatcher for this repository. Commands: - setup Install or refresh the hook dispatcher (sets core.hooksPath) + enable Install or refresh the hook dispatcher (sets core.hooksPath) disable Disable hooks: unset core.hooksPath, remove /_, persist preference - enable Re-enable hooks after disable (same as setup) status Show preference, core.hooksPath, and dispatcher state Options: @@ -20,13 +19,12 @@ Options: -h, --help Show this help message Environment: - VP_GIT_HOOKS=0 Skip dispatcher install in setup/enable (and skip hooks at commit time) + VP_GIT_HOOKS=0 Skip dispatcher install in enable (and skip hooks at commit time) Examples: - vp hooks setup - vp hooks setup --hooks-dir .custom-hooks - vp hooks disable vp hooks enable + vp hooks enable --hooks-dir .custom-hooks + vp hooks disable vp hooks status Documentation: https://viteplus.dev/guide/commit-hooks @@ -42,9 +40,8 @@ Usage: vp hooks [OPTIONS] Manage the Vite+ Git hook dispatcher for this repository. Commands: - setup Install or refresh the hook dispatcher (sets core.hooksPath) + enable Install or refresh the hook dispatcher (sets core.hooksPath) disable Disable hooks: unset core.hooksPath, remove /_, persist preference - enable Re-enable hooks after disable (same as setup) status Show preference, core.hooksPath, and dispatcher state Options: @@ -52,13 +49,12 @@ Options: -h, --help Show this help message Environment: - VP_GIT_HOOKS=0 Skip dispatcher install in setup/enable (and skip hooks at commit time) + VP_GIT_HOOKS=0 Skip dispatcher install in enable (and skip hooks at commit time) Examples: - vp hooks setup - vp hooks setup --hooks-dir .custom-hooks - vp hooks disable vp hooks enable + vp hooks enable --hooks-dir .custom-hooks + vp hooks disable vp hooks status Documentation: https://viteplus.dev/guide/commit-hooks @@ -74,9 +70,8 @@ Usage: vp hooks [OPTIONS] Manage the Vite+ Git hook dispatcher for this repository. Commands: - setup Install or refresh the hook dispatcher (sets core.hooksPath) + enable Install or refresh the hook dispatcher (sets core.hooksPath) disable Disable hooks: unset core.hooksPath, remove /_, persist preference - enable Re-enable hooks after disable (same as setup) status Show preference, core.hooksPath, and dispatcher state Options: @@ -84,13 +79,12 @@ Options: -h, --help Show this help message Environment: - VP_GIT_HOOKS=0 Skip dispatcher install in setup/enable (and skip hooks at commit time) + VP_GIT_HOOKS=0 Skip dispatcher install in enable (and skip hooks at commit time) Examples: - vp hooks setup - vp hooks setup --hooks-dir .custom-hooks - vp hooks disable vp hooks enable + vp hooks enable --hooks-dir .custom-hooks + vp hooks disable vp hooks status Documentation: https://viteplus.dev/guide/commit-hooks diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_hooks_lifecycle/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_hooks_lifecycle/snapshots.toml index 6a2dec4674..364a70ded3 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_hooks_lifecycle/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_hooks_lifecycle/snapshots.toml @@ -4,9 +4,9 @@ vp = "local" skip-platforms = ["windows"] steps = [ { argv = ["git", "init"], snapshot = false, continue-on-failure = true }, - { argv = ["vp", "hooks", "status"], comment = "preference not set before setup", continue-on-failure = true }, - { argv = ["vp", "hooks", "setup"], comment = "install dispatcher", continue-on-failure = true }, - { argv = ["vp", "hooks", "status"], comment = "preference enabled after setup", continue-on-failure = true }, + { argv = ["vp", "hooks", "status"], comment = "preference not set before enable", continue-on-failure = true }, + { argv = ["vp", "hooks", "enable"], comment = "install dispatcher", continue-on-failure = true }, + { argv = ["vp", "hooks", "status"], comment = "preference enabled after enable", continue-on-failure = true }, { argv = ["git", "config", "--local", "core.hooksPath"], comment = "should be .vite-hooks/_", continue-on-failure = true }, { argv = ["vp", "hooks", "disable"], comment = "tear down and persist preference", continue-on-failure = true }, { argv = ["vp", "hooks", "status"], comment = "preference disabled (local)", continue-on-failure = true }, diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_hooks_lifecycle/snapshots/command_hooks_lifecycle.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_hooks_lifecycle/snapshots/command_hooks_lifecycle.md index c05fe6af80..353c7b2c0a 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_hooks_lifecycle/snapshots/command_hooks_lifecycle.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_hooks_lifecycle/snapshots/command_hooks_lifecycle.md @@ -5,7 +5,7 @@ ## `vp hooks status` -preference not set before setup +preference not set before enable ``` Preference: not set @@ -15,7 +15,7 @@ Dispatcher: missing (.vite-hooks/_) Project hooks: pre-commit ``` -## `vp hooks setup` +## `vp hooks enable` install dispatcher @@ -25,7 +25,7 @@ Git hook dispatcher installed at .vite-hooks/_ ## `vp hooks status` -preference enabled after setup +preference enabled after enable ``` Preference: enabled diff --git a/docs/guide/commit-hooks.md b/docs/guide/commit-hooks.md index 38c57deef2..36d4584d0a 100644 --- a/docs/guide/commit-hooks.md +++ b/docs/guide/commit-hooks.md @@ -9,7 +9,7 @@ Vite+ supports commit hooks and staged-file checks without additional tooling. Use: -- `vp hooks setup` / `enable` / `disable` / `status` to manage the generated hook dispatcher +- `vp hooks enable` / `disable` / `status` to manage the generated hook dispatcher - `vp config` to install the dispatcher (when not disabled) and update agent integration - `vp staged` to run checks against the files currently staged in Git @@ -19,7 +19,7 @@ If you use [`vp create`](/guide/create) or [`vp migrate`](/guide/migrate), Vite+ ```bash # Install or refresh the dispatcher -vp hooks setup +vp hooks enable # Check what is active in this clone vp hooks status @@ -38,31 +38,29 @@ vp hooks enable Manage the Vite+ Git hook dispatcher for the current repository: ```bash -vp hooks setup -vp hooks setup --hooks-dir .custom-hooks -vp hooks disable vp hooks enable +vp hooks enable --hooks-dir .custom-hooks +vp hooks disable vp hooks status ``` | Command | Behavior | | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `setup` | Install or refresh the generated dispatcher under `/_` and set `core.hooksPath`. Clears a previous disable preference. | +| `enable` | Install or refresh the generated dispatcher under `/_` and set `core.hooksPath`. Clears a previous disable preference. | | `disable` | Tear down the dispatcher (unset `core.hooksPath` when it points at Vite+, remove `/_`) and **persist** the disable decision in local git config so `vp config` / lifecycle scripts do not reinstall it. | -| `enable` | Re-enable after `disable` (same as `setup`). | | `status` | Show preference, `core.hooksPath`, dispatcher presence, and project-owned hook scripts. | -By default, project hooks live in `.vite-hooks`. Pass `--hooks-dir` to use another subdirectory. After the first successful setup, the directory is remembered in local git config for later `enable` / `disable` / `status` / `vp config` calls in this clone. +By default, project hooks live in `.vite-hooks`. Pass `--hooks-dir` to use another subdirectory. After the first successful enable, the directory is remembered in local git config for later `enable` / `disable` / `status` / `vp config` calls in this clone. `status` reports preference as: -- `not set` — no disable preference and no prior setup in this clone -- `enabled` — setup/enable has run (or the dispatcher is currently owned) +- `not set` — no disable preference and no prior enable in this clone +- `enabled` — enable has run (or the dispatcher is currently owned) - `disabled (local)` — after `vp hooks disable` Check the `Dispatcher` and `core.hooksPath` lines to see whether hooks are actually active. -`disable` / `setup` do **not** delete project-owned hook scripts (for example `.vite-hooks/pre-commit`), the `staged` block in `vite.config.ts`, or lifecycle scripts that call `vp config`. +`disable` / `enable` do **not** delete project-owned hook scripts (for example `.vite-hooks/pre-commit`), the `staged` block in `vite.config.ts`, or lifecycle scripts that call `vp config`. ### `vp config` @@ -89,7 +87,7 @@ You can also set `VP_GIT_HOOKS=0` to disable hook installation from lifecycle sc 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 setup`. Neither command creates or modifies project hook scripts or staged-file +or `vp hooks enable`. Neither command creates or modifies project hook scripts or staged-file configuration. ### `vp staged` @@ -140,7 +138,7 @@ Set `VP_GIT_HOOKS=0` in the environment of the process that runs `git commit`, a VP_GIT_HOOKS=0 git commit -m "content update" ``` -`HUSKY=0` is honored the same way for ecosystem tooling compatibility. Setting `VP_GIT_HOOKS=0` in an environment also keeps `vp config` / `vp hooks setup` from reinstalling hooks there when a lifecycle script such as `prepare` runs. +`HUSKY=0` is honored the same way for ecosystem tooling compatibility. Setting `VP_GIT_HOOKS=0` in an environment also keeps `vp config` / `vp hooks enable` from reinstalling hooks there when a lifecycle script such as `prepare` runs. ### Init script @@ -173,7 +171,7 @@ This: 1. Unsets `core.hooksPath` when it points at the Vite+ dispatcher 2. Removes the generated `/_` directory 3. Records a **local** disable preference so lifecycle scripts skip reinstall until you run - `vp hooks enable` (or `vp hooks setup`) again + `vp hooks enable` again To re-enable: @@ -193,7 +191,7 @@ git config --unset core.hooksPath rm -rf .vite-hooks/_ # optional: prevent prepare/vp config from reinstalling in this clone git config --local vp.hooks.disabled true -# optional: remembered hooks directory (set by setup/enable/disable) +# optional: remembered hooks directory (set by enable/disable) # git config --local vp.hooks.dir .vite-hooks ``` diff --git a/docs/guide/index.md b/docs/guide/index.md index a16b190e9c..874304a90a 100644 --- a/docs/guide/index.md +++ b/docs/guide/index.md @@ -93,7 +93,7 @@ Vite+ can handle the entire local frontend development cycle from starting a pro - [`vp create`](/guide/create) creates new apps, packages, and monorepos. - [`vp migrate`](/guide/migrate) moves existing projects onto Vite+. - [`vp config`](/guide/commit-hooks) installs the Git hook dispatcher and configures agent integration. -- [`vp hooks`](/guide/commit-hooks) manages the Git hook dispatcher (`setup`, `disable`, `enable`, `status`). +- [`vp hooks`](/guide/commit-hooks) manages the Git hook dispatcher (`enable`, `disable`, `status`). - [`vp staged`](/guide/commit-hooks) runs checks on staged files. - [`vp install`](/guide/install) installs dependencies with the right package manager. - [`vp env`](/guide/env) manages Node.js versions. diff --git a/docs/guide/migrate.md b/docs/guide/migrate.md index 2713b5ebfe..7bc7888d81 100644 --- a/docs/guide/migrate.md +++ b/docs/guide/migrate.md @@ -184,7 +184,7 @@ only when no existing hook policy is found. If your project currently uses `lefthook`, `simple-git-hooks`, or `yorkie`, `vp migrate` will leave your existing configuration alone and show a warning. This happens even if you choose to set up hooks during the prompt or include the `--hooks` flag. -If you want to move one of those tools over to Vite+ manually, you can follow these steps. First, move your staged-file commands into the `staged` block within `vite.config.ts`. Then, update your lifecycle script so it runs `vp config`. You will also need to create a Vite+ hook at `.vite-hooks/pre-commit` that runs `vp staged`. Run `vp hooks setup` (or `vp config`) to install the dispatcher and set `core.hooksPath`. Finally, once you have confirmed that the Vite+ hook is working as expected, you can remove the old tool's configuration and dependency. +If you want to move one of those tools over to Vite+ manually, you can follow these steps. First, move your staged-file commands into the `staged` block within `vite.config.ts`. Then, update your lifecycle script so it runs `vp config`. You will also need to create a Vite+ hook at `.vite-hooks/pre-commit` that runs `vp staged`. Run `vp hooks enable` (or `vp config`) to install the dispatcher and set `core.hooksPath`. Finally, once you have confirmed that the Vite+ hook is working as expected, you can remove the old tool's configuration and dependency. Use `vp hooks status` to verify the dispatcher is active, and `vp hooks disable` if you need to turn it off again in this clone. You can find more details about the full Vite+ hook setup in the [Commit hooks guide](/guide/commit-hooks). diff --git a/docs/guide/troubleshooting.md b/docs/guide/troubleshooting.md index de7d93136a..529e8c8b48 100644 --- a/docs/guide/troubleshooting.md +++ b/docs/guide/troubleshooting.md @@ -55,7 +55,7 @@ If `vp staged` fails or your pre-commit hook does not run: - make sure `vite.config.ts` contains a `staged` block - make sure the project-owned pre-commit hook runs `vp staged` (for example `.vite-hooks/pre-commit`) - run `vp hooks status` to see preference, `core.hooksPath`, and whether the dispatcher is installed -- run `vp hooks setup` (or `vp config`) to install the hook dispatcher +- run `vp hooks enable` (or `vp config`) to install the hook dispatcher - if status shows `Preference: disabled (local)`, re-enable with `vp hooks enable` - check whether hooks were skipped intentionally through `VP_GIT_HOOKS=0` diff --git a/packages/cli/src/config/__tests__/hooks.spec.ts b/packages/cli/src/config/__tests__/hooks.spec.ts index cc99b8587a..ebfb4809e9 100644 --- a/packages/cli/src/config/__tests__/hooks.spec.ts +++ b/packages/cli/src/config/__tests__/hooks.spec.ts @@ -23,7 +23,6 @@ import { install, isHooksUserDisabled, resolveHooksDir, - setup, status, } from '../hooks.js'; @@ -248,9 +247,9 @@ describe('install', () => { }); }); -describe('setup / disable / enable / status', () => { +describe('enable / disable / status', () => { it.skipIf(process.platform === 'win32')( - 'setup installs dispatcher; disable tears down and persists preference; enable restores', + 'enable installs dispatcher; disable tears down and persists preference; enable restores', () => { const tmp = mkdtempSync(join(tmpdir(), 'hooks-lifecycle-')); const originalCwd = process.cwd(); @@ -262,7 +261,7 @@ describe('setup / disable / enable / status', () => { mkdirSync(hooksDir, { recursive: true }); writeFileSync(join(hooksDir, 'pre-commit'), 'vp staged\n'); - expect(setup(hooksDir).isError).toBe(false); + expect(enable(hooksDir).isError).toBe(false); expect(existsSync(join(tmp, hooksDir, '_', 'pre-commit'))).toBe(true); expect(execSync('git config --get core.hooksPath', { cwd: tmp }).toString().trim()).toBe( '.vite-hooks/_', @@ -342,7 +341,7 @@ describe('setup / disable / enable / status', () => { expect(unset.isError).toBe(false); expect(unset.message).toContain('Preference: not set'); - expect(setup().isError).toBe(false); + expect(enable().isError).toBe(false); const active = status(); expect(active.isError).toBe(false); expect(active.status?.userDisabled).toBe(false); @@ -375,7 +374,7 @@ describe('setup / disable / enable / status', () => { mkdirSync(customDir, { recursive: true }); writeFileSync(join(tmp, customDir, 'pre-commit'), 'vp staged\n'); - expect(setup(customDir).isError).toBe(false); + expect(enable(customDir).isError).toBe(false); expect(resolveHooksDir()).toBe(customDir); expect(existsSync(join(tmp, customDir, '_', 'pre-commit'))).toBe(true); expect(execSync('git config --get core.hooksPath', { cwd: tmp }).toString().trim()).toBe( diff --git a/packages/cli/src/config/hooks.ts b/packages/cli/src/config/hooks.ts index 029ae29de9..96c74d2745 100644 --- a/packages/cli/src/config/hooks.ts +++ b/packages/cli/src/config/hooks.ts @@ -31,7 +31,7 @@ export const DEFAULT_HOOKS_DIR = '.vite-hooks'; /** Local git config: user chose `vp hooks disable` (survives prepare / vp config). */ const PREFERENCE_DISABLED_KEY = 'vp.hooks.disabled'; -/** Local git config: last hooks directory used by setup/enable. */ +/** Local git config: last hooks directory used by enable. */ const PREFERENCE_DIR_KEY = 'vp.hooks.dir'; // Build nested dirname expression: depth 3 → dirname "$(dirname "$(dirname "$0"))" @@ -327,7 +327,7 @@ function unsetOwnedHooksPath(target: string): InstallResult | null { export interface InstallOptions { /** - * When true, ignore a user-disabled preference (used by `vp hooks setup` / `enable`). + * When true, ignore a user-disabled preference (used by `vp hooks enable`). * Still honors `VP_GIT_HOOKS=0` / `HUSKY=0`. */ ignoreUserPreference?: boolean; @@ -413,7 +413,7 @@ export function install(dir = DEFAULT_HOOKS_DIR, options: InstallOptions = {}): * Install (or refresh) the Vite+ hook dispatcher and mark hooks as enabled. * Clears a previous `vp hooks disable` preference. */ -export function setup(dir = DEFAULT_HOOKS_DIR): InstallResult { +export function enable(dir = DEFAULT_HOOKS_DIR): InstallResult { const result = install(dir, { ignoreUserPreference: true }); if (result.isError) { return result; @@ -428,13 +428,6 @@ export function setup(dir = DEFAULT_HOOKS_DIR): InstallResult { }; } -/** - * Re-enable hooks after `disable` (same as setup). - */ -export function enable(dir = DEFAULT_HOOKS_DIR): InstallResult { - return setup(dir); -} - /** * Disable Vite+ hooks in this repo and tear down the dispatcher. * @@ -564,8 +557,8 @@ export function status(dir?: string): InstallResult & { status?: HooksStatus } { // Preference is the stored user decision, not runtime activity. // - disabled (local): user ran `vp hooks disable` - // - enabled: setup/enable ran (or hooks are currently owned/installed) - // - not set: no disable preference and no evidence of a prior setup + // - enabled: enable ran (or hooks are currently owned/installed) + // - not set: no disable preference and no evidence of a prior enable const preferenceLabel = userDisabled ? 'disabled (local)' : getStoredHooksDir() || dispatcherInstalled || ownsHooksPath diff --git a/packages/cli/src/hooks/bin.ts b/packages/cli/src/hooks/bin.ts index fa44bfc0e8..16307b425f 100644 --- a/packages/cli/src/hooks/bin.ts +++ b/packages/cli/src/hooks/bin.ts @@ -5,13 +5,12 @@ import { disable, enable, resolveHooksDir, - setup, status, } from '../config/hooks.ts'; import { renderCliDoc } from '../utils/help.ts'; import { log, printHeader } from '../utils/terminal.ts'; -const SUBCOMMANDS = ['setup', 'disable', 'enable', 'status'] as const; +const SUBCOMMANDS = ['enable', 'disable', 'status'] as const; type Subcommand = (typeof SUBCOMMANDS)[number]; function isSubcommand(value: string | undefined): value is Subcommand { @@ -28,17 +27,13 @@ function printHelp(): void { title: 'Commands', rows: [ { - label: 'setup', + label: 'enable', description: 'Install or refresh the hook dispatcher (sets core.hooksPath)', }, { label: 'disable', description: 'Disable hooks: unset core.hooksPath, remove /_, persist preference', }, - { - label: 'enable', - description: 'Re-enable hooks after disable (same as setup)', - }, { label: 'status', description: 'Show preference, core.hooksPath, and dispatcher state', @@ -60,17 +55,16 @@ function printHelp(): void { rows: [ { label: 'VP_GIT_HOOKS=0', - description: 'Skip dispatcher install in setup/enable (and skip hooks at commit time)', + description: 'Skip dispatcher install in enable (and skip hooks at commit time)', }, ], }, { title: 'Examples', lines: [ - ' vp hooks setup', - ' vp hooks setup --hooks-dir .custom-hooks', - ' vp hooks disable', ' vp hooks enable', + ' vp hooks enable --hooks-dir .custom-hooks', + ' vp hooks disable', ' vp hooks status', ], }, @@ -122,9 +116,6 @@ async function main() { const dir = resolveHooksDir(dirFlag); switch (subcommand) { - case 'setup': - applyResult(setup(dir)); - return; case 'enable': applyResult(enable(dir)); return; From 44080a9081b548565859fd4b92b7d02be413afbb Mon Sep 17 00:00:00 2001 From: Denny Biasiolli Date: Fri, 7 Aug 2026 16:52:00 +0200 Subject: [PATCH 06/10] fix(cli): treat absolute core.hooksPath as owned when it matches Resolve relative and absolute hooksPath spellings against the git worktree root (via realpath) before deciding ownership, so disable and install do not treat /repo/.vite-hooks/_ as a foreign path. --- .../cli/src/config/__tests__/hooks.spec.ts | 47 ++++++++++++++ packages/cli/src/config/hooks.ts | 62 ++++++++++++++++--- 2 files changed, 100 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/config/__tests__/hooks.spec.ts b/packages/cli/src/config/__tests__/hooks.spec.ts index ebfb4809e9..778e59b0d2 100644 --- a/packages/cli/src/config/__tests__/hooks.spec.ts +++ b/packages/cli/src/config/__tests__/hooks.spec.ts @@ -414,6 +414,53 @@ describe('enable / disable / status', () => { }); }); + it.skipIf(process.platform === 'win32')( + 'treats an absolute core.hooksPath spelling as owned', + () => { + const tmp = mkdtempSync(join(tmpdir(), 'hooks-abs-path-')); + const originalCwd = process.cwd(); + try { + execSync('git init', { cwd: tmp, stdio: 'ignore' }); + process.chdir(tmp); + + expect(enable().isError).toBe(false); + const absoluteHooksPath = join(tmp, '.vite-hooks', '_'); + execSync(`git config core.hooksPath ${JSON.stringify(absoluteHooksPath)}`, { cwd: tmp }); + + expect(status().status?.ownsHooksPath).toBe(true); + expect(install()).toEqual({ message: '', isError: false }); + } finally { + process.chdir(originalCwd); + rmSync(tmp, { recursive: true, force: true }); + } + }, + ); + + it.skipIf(process.platform === 'win32')( + 'disable unsets an absolute spelling of the owned hooks path', + () => { + const tmp = mkdtempSync(join(tmpdir(), 'hooks-abs-path-disable-')); + const originalCwd = process.cwd(); + try { + execSync('git init', { cwd: tmp, stdio: 'ignore' }); + process.chdir(tmp); + + expect(enable().isError).toBe(false); + const absoluteHooksPath = join(tmp, '.vite-hooks', '_'); + execSync(`git config core.hooksPath ${JSON.stringify(absoluteHooksPath)}`, { cwd: tmp }); + + const result = disable(); + expect(result.isError).toBe(false); + expect(result.message).toContain('unset core.hooksPath'); + expect(existsSync(join(tmp, '.vite-hooks', '_'))).toBe(false); + expect(() => execSync('git config --get core.hooksPath', { cwd: tmp })).toThrow(); + } finally { + process.chdir(originalCwd); + rmSync(tmp, { recursive: true, force: true }); + } + }, + ); + it.skipIf(process.platform === 'win32')( 'disable unsets only the worktree Vite+ path and leaves a foreign local path', () => { diff --git a/packages/cli/src/config/hooks.ts b/packages/cli/src/config/hooks.ts index 96c74d2745..8ecdeeb38b 100644 --- a/packages/cli/src/config/hooks.ts +++ b/packages/cli/src/config/hooks.ts @@ -5,6 +5,7 @@ import { lstatSync, mkdirSync, readdirSync, + realpathSync, rmSync, writeFileSync, } from 'node:fs'; @@ -116,6 +117,36 @@ export function normalizeHooksPath(hooksPath: string): string { return normalized; } +function getGitToplevel(): string | InstallResult { + const result = spawnSync('git', ['rev-parse', '--show-toplevel']); + if (result.status == null) { + return { message: 'git command not found', isError: true }; + } + if (result.status !== 0) { + return { message: ".git can't be found", isError: false }; + } + const toplevel = result.stdout.toString().trim(); + try { + return realpathSync(toplevel); + } catch { + return toplevel; + } +} + +/** Resolve a core.hooksPath value against the worktree root for ownership checks. */ +function resolveHooksPath(hooksPath: string, gitRoot: string): string { + const resolved = isAbsolute(hooksPath) ? hooksPath : resolve(gitRoot, hooksPath); + try { + return normalizeHooksPath(realpathSync(resolved)); + } catch { + return normalizeHooksPath(resolved); + } +} + +function hooksPathsEqual(a: string, b: string, gitRoot: string): boolean { + return resolveHooksPath(a, gitRoot) === resolveHooksPath(b, gitRoot); +} + export function findUnsafeHookInstallPath(root: string, dir: string): UnsafeHookInstallPath | null { const projectRoot = resolve(root); const internalPath = resolve(projectRoot, dir, '_'); @@ -301,11 +332,14 @@ function unsetScopedHooksPath(scope: 'local' | 'worktree'): InstallResult | null * while worktree holds the Vite+ target). */ function unsetOwnedHooksPath(target: string): InstallResult | null { - const normalizedTarget = normalizeHooksPath(target); + const toplevel = getGitToplevel(); + if (typeof toplevel !== 'string') { + return toplevel; + } for (const scope of ['local', 'worktree'] as const) { const scopedPath = getScopedHooksPath(scope); - if (!scopedPath || normalizeHooksPath(scopedPath) !== normalizedTarget) { + if (!scopedPath || !hooksPathsEqual(scopedPath, target, toplevel)) { continue; } const unsetError = unsetScopedHooksPath(scope); @@ -315,7 +349,7 @@ function unsetOwnedHooksPath(target: string): InstallResult | null { } const finalPath = getEffectiveHooksPath(); - if (finalPath && normalizeHooksPath(finalPath) === normalizedTarget) { + if (finalPath && hooksPathsEqual(finalPath, target, toplevel)) { return { message: `could not unset core.hooksPath (still "${finalPath}"); remove it with git config --unset core.hooksPath`, isError: true, @@ -369,7 +403,11 @@ export function install(dir = DEFAULT_HOOKS_DIR, options: InstallOptions = {}): // Read the effective value so a worktree-scoped setting cannot silently // override the local value we are about to write. const existingHooksPath = getEffectiveHooksPath(); - if (existingHooksPath && normalizeHooksPath(existingHooksPath) !== normalizeHooksPath(target)) { + const toplevel = getGitToplevel(); + if (typeof toplevel !== 'string') { + return toplevel; + } + if (existingHooksPath && !hooksPathsEqual(existingHooksPath, target, toplevel)) { return { message: `core.hooksPath is already set to "${existingHooksPath}", skipping`, isError: false, @@ -463,10 +501,13 @@ export function disable(dir = DEFAULT_HOOKS_DIR): InstallResult { } const existingHooksPath = getEffectiveHooksPath(); - const ownsHooksPath = - !!existingHooksPath && normalizeHooksPath(existingHooksPath) === normalizeHooksPath(target); + const toplevel = getGitToplevel(); + if (typeof toplevel !== 'string') { + return toplevel; + } + const ownsHooksPath = !!existingHooksPath && hooksPathsEqual(existingHooksPath, target, toplevel); const foreignHooksPath = - !!existingHooksPath && normalizeHooksPath(existingHooksPath) !== normalizeHooksPath(target); + !!existingHooksPath && !hooksPathsEqual(existingHooksPath, target, toplevel); const actions: string[] = []; const notes: string[] = []; @@ -540,8 +581,11 @@ export function status(dir?: string): InstallResult & { status?: HooksStatus } { const existingHooksPath = getEffectiveHooksPath(); const userDisabled = isHooksUserDisabled(); const dispatcherInstalled = existsSync(join(hooksDir, '_', 'h')); - const ownsHooksPath = - !!existingHooksPath && normalizeHooksPath(existingHooksPath) === normalizeHooksPath(target); + const toplevel = getGitToplevel(); + if (typeof toplevel !== 'string') { + return toplevel; + } + const ownsHooksPath = !!existingHooksPath && hooksPathsEqual(existingHooksPath, target, toplevel); let projectHooks: string[] = []; if (existsSync(hooksDir)) { From 613f68b0e5c1437d606d0e35ea34f72e03e7e196 Mon Sep 17 00:00:00 2001 From: Denny Biasiolli Date: Fri, 7 Aug 2026 16:54:29 +0200 Subject: [PATCH 07/10] fix(cli): remember hooks dir against the git worktree root Persist the setup-time git prefix with the hooks directory so enable, disable, status, and vp config resolve the same dispatcher from a nested cwd instead of treating root .vite-hooks/_ as foreign. --- .../cli/src/config/__tests__/hooks.spec.ts | 105 +++++- packages/cli/src/config/bin.ts | 55 ++-- packages/cli/src/config/hooks.ts | 308 ++++++++++++------ packages/cli/src/hooks/bin.ts | 15 +- 4 files changed, 355 insertions(+), 128 deletions(-) diff --git a/packages/cli/src/config/__tests__/hooks.spec.ts b/packages/cli/src/config/__tests__/hooks.spec.ts index 778e59b0d2..9d3d4b2513 100644 --- a/packages/cli/src/config/__tests__/hooks.spec.ts +++ b/packages/cli/src/config/__tests__/hooks.spec.ts @@ -381,8 +381,8 @@ describe('enable / disable / status', () => { `${customDir}/_`, ); - // Callers that only have resolveHooksDir() (CLI without --hooks-dir) must hit the custom tree. - const disabled = disable(resolveHooksDir()); + // Callers that omit the dir (CLI without --hooks-dir) must hit the custom tree. + const disabled = disable(); expect(disabled.isError).toBe(false); expect(existsSync(join(tmp, customDir, '_'))).toBe(false); expect(existsSync(join(tmp, customDir, 'pre-commit'))).toBe(true); @@ -394,7 +394,7 @@ describe('enable / disable / status', () => { expect(inactive.message).toContain('Preference: disabled (local)'); expect(inactive.message).toContain(`Hooks dir: ${customDir}`); - expect(enable(resolveHooksDir()).isError).toBe(false); + expect(enable().isError).toBe(false); expect(existsSync(join(tmp, customDir, '_', 'pre-commit'))).toBe(true); expect(execSync('git config --get core.hooksPath', { cwd: tmp }).toString().trim()).toBe( `${customDir}/_`, @@ -461,6 +461,105 @@ describe('enable / disable / status', () => { }, ); + it.skipIf(process.platform === 'win32')( + 'disable and status from a nested cwd find a root dispatcher with no stored prefix', + () => { + const tmp = mkdtempSync(join(tmpdir(), 'hooks-nested-unstored-')); + const originalCwd = process.cwd(); + try { + execSync('git init', { cwd: tmp, stdio: 'ignore' }); + process.chdir(tmp); + + // Pre-`vp hooks` clone: dispatcher + core.hooksPath, no vp.hooks.* keys. + mkdirSync(join(tmp, '.vite-hooks', '_'), { recursive: true }); + writeFileSync(join(tmp, '.vite-hooks', '_', 'h'), '#!/usr/bin/env sh\n'); + writeFileSync(join(tmp, '.vite-hooks', '_', 'pre-commit'), '#!/usr/bin/env sh\n'); + execSync('git config core.hooksPath .vite-hooks/_', { cwd: tmp }); + + mkdirSync(join(tmp, 'pkg')); + process.chdir(join(tmp, 'pkg')); + + const before = status(); + expect(before.status?.ownsHooksPath).toBe(true); + expect(before.status?.dispatcherInstalled).toBe(true); + expect(before.status?.hooksDir).toBe('.vite-hooks'); + + const result = disable(); + expect(result.isError).toBe(false); + expect(result.message).toContain('unset core.hooksPath'); + expect(existsSync(join(tmp, '.vite-hooks', '_'))).toBe(false); + expect(existsSync(join(tmp, 'pkg', '.vite-hooks'))).toBe(false); + expect(() => execSync('git config --get core.hooksPath', { cwd: tmp })).toThrow(); + expect(isHooksUserDisabled()).toBe(true); + expect( + execSync('git config --local --get vp.hooks.prefix', { cwd: tmp }).toString().trim(), + ).toBe('.'); + + process.chdir(tmp); + expect(enable().isError).toBe(false); + expect(existsSync(join(tmp, '.vite-hooks', '_', 'pre-commit'))).toBe(true); + } finally { + process.chdir(originalCwd); + rmSync(tmp, { recursive: true, force: true }); + } + }, + ); + + it.skipIf(process.platform === 'win32')( + 'disable from a nested cwd tears down the remembered root dispatcher', + () => { + const tmp = mkdtempSync(join(tmpdir(), 'hooks-nested-cwd-')); + const originalCwd = process.cwd(); + try { + execSync('git init', { cwd: tmp, stdio: 'ignore' }); + process.chdir(tmp); + expect(enable().isError).toBe(false); + expect(existsSync(join(tmp, '.vite-hooks', '_', 'pre-commit'))).toBe(true); + + mkdirSync(join(tmp, 'pkg')); + process.chdir(join(tmp, 'pkg')); + + const result = disable(); + expect(result.isError).toBe(false); + expect(existsSync(join(tmp, '.vite-hooks', '_'))).toBe(false); + expect(existsSync(join(tmp, 'pkg', '.vite-hooks'))).toBe(false); + expect(() => execSync('git config --get core.hooksPath', { cwd: tmp })).toThrow(); + expect(isHooksUserDisabled()).toBe(true); + } finally { + process.chdir(originalCwd); + rmSync(tmp, { recursive: true, force: true }); + } + }, + ); + + it.skipIf(process.platform === 'win32')( + 'remembers a subdirectory install when later commands run from the repo root', + () => { + const tmp = mkdtempSync(join(tmpdir(), 'hooks-subdir-install-')); + const originalCwd = process.cwd(); + try { + execSync('git init', { cwd: tmp, stdio: 'ignore' }); + mkdirSync(join(tmp, 'pkg')); + process.chdir(join(tmp, 'pkg')); + + expect(enable().isError).toBe(false); + expect(existsSync(join(tmp, 'pkg', '.vite-hooks', '_', 'pre-commit'))).toBe(true); + expect(execSync('git config --get core.hooksPath', { cwd: tmp }).toString().trim()).toBe( + 'pkg/.vite-hooks/_', + ); + + process.chdir(tmp); + const result = disable(); + expect(result.isError).toBe(false); + expect(existsSync(join(tmp, 'pkg', '.vite-hooks', '_'))).toBe(false); + expect(() => execSync('git config --get core.hooksPath', { cwd: tmp })).toThrow(); + } finally { + process.chdir(originalCwd); + rmSync(tmp, { recursive: true, force: true }); + } + }, + ); + it.skipIf(process.platform === 'win32')( 'disable unsets only the worktree Vite+ path and leaves a foreign local path', () => { diff --git a/packages/cli/src/config/bin.ts b/packages/cli/src/config/bin.ts index 0b3b986025..4766e5fcd3 100644 --- a/packages/cli/src/config/bin.ts +++ b/packages/cli/src/config/bin.ts @@ -7,7 +7,7 @@ import { updateExistingAgentInstructions } from '../utils/agent.ts'; import { renderCliDoc } from '../utils/help.ts'; import { defaultInteractive, promptGitHooks } from '../utils/prompts.ts'; import { log, printHeader } from '../utils/terminal.ts'; -import { install, isHooksUserDisabled, resolveHooksDir } from './hooks.ts'; +import { install, isHooksUserDisabled, resolveHooksLocation } from './hooks.ts'; async function main() { const args = mri(process.argv.slice(3), { @@ -56,28 +56,41 @@ async function main() { // --- Step 1: Hooks setup --- // Prefer CLI flag, then last-used dir from local git config, then default. - const hooksDir = resolveHooksDir(dir); - const isFirstHooksRun = !existsSync(join(root, hooksDir, '_', 'pre-commit')); + // Skip location resolution entirely when `--no-hooks` so agent-only runs + // do not fail on a missing git repo or invalid `--hooks-dir`. + if (!skipHooks) { + const location = resolveHooksLocation(dir); + if ('isError' in location) { + if (location.message) { + log(location.message); + } + if (location.isError) { + process.exit(1); + } + } else { + const isFirstHooksRun = !existsSync(join(location.baseDir, location.dir, '_', 'pre-commit')); - let shouldSetupHooks = !skipHooks; - if (shouldSetupHooks && isHooksUserDisabled()) { - // Honor `vp hooks disable` without re-prompting (option A). - log('skip install (hooks disabled; run `vp hooks enable` to re-enable)'); - shouldSetupHooks = false; - } else if (shouldSetupHooks && interactive && isFirstHooksRun && !dir && !isLifecycleScript) { - // Explicit directories and lifecycle scripts already opt in. - shouldSetupHooks = await promptGitHooks({ - interactive, - message: 'Install the Git hook dispatcher for this project?', - }); - } + let shouldSetupHooks = true; + if (isHooksUserDisabled()) { + // Honor `vp hooks disable` without re-prompting (option A). + log('skip install (hooks disabled; run `vp hooks enable` to re-enable)'); + shouldSetupHooks = false; + } else if (interactive && isFirstHooksRun && !dir && !isLifecycleScript) { + // Explicit directories and lifecycle scripts already opt in. + shouldSetupHooks = await promptGitHooks({ + interactive, + message: 'Install the Git hook dispatcher for this project?', + }); + } - if (shouldSetupHooks) { - const { message, isError } = install(hooksDir); - if (message) { - log(message); - if (isError) { - process.exit(1); + if (shouldSetupHooks) { + const { message, isError } = install(dir); + if (message) { + log(message); + if (isError) { + process.exit(1); + } + } } } } diff --git a/packages/cli/src/config/hooks.ts b/packages/cli/src/config/hooks.ts index 8ecdeeb38b..56bd748ae3 100644 --- a/packages/cli/src/config/hooks.ts +++ b/packages/cli/src/config/hooks.ts @@ -32,8 +32,11 @@ export const DEFAULT_HOOKS_DIR = '.vite-hooks'; /** Local git config: user chose `vp hooks disable` (survives prepare / vp config). */ const PREFERENCE_DISABLED_KEY = 'vp.hooks.disabled'; -/** Local git config: last hooks directory used by enable. */ +/** Local git config: last hooks directory used by enable (relative to setup cwd). */ const PREFERENCE_DIR_KEY = 'vp.hooks.dir'; +/** Local git config: `git rev-parse --show-prefix` when the dir was stored. `.` = worktree root. */ +const PREFERENCE_PREFIX_KEY = 'vp.hooks.prefix'; +const ROOT_PREFIX_TOKEN = '.'; // Build nested dirname expression: depth 3 → dirname "$(dirname "$(dirname "$0"))" function nestedDirname(depth: number): string { @@ -264,6 +267,25 @@ export function setStoredHooksDir(dir: string): { ok: boolean; error?: string } return gitConfigSet(PREFERENCE_DIR_KEY, dir); } +function getStoredHooksPrefix(): string | null { + const value = gitConfigGet(PREFERENCE_PREFIX_KEY, { local: true }); + if (value == null) { + return null; + } + if (value === ROOT_PREFIX_TOKEN) { + return ''; + } + return value.replace(/\/$/, ''); +} + +function setStoredHooksLocation(dir: string, prefix: string): { ok: boolean; error?: string } { + const storedDir = gitConfigSet(PREFERENCE_DIR_KEY, dir); + if (!storedDir.ok) { + return storedDir; + } + return gitConfigSet(PREFERENCE_PREFIX_KEY, prefix || ROOT_PREFIX_TOKEN); +} + /** * Resolve the hooks directory: CLI flag > stored preference > default. */ @@ -274,6 +296,146 @@ export function resolveHooksDir(dir?: string): string { return getStoredHooksDir() ?? DEFAULT_HOOKS_DIR; } +export type HooksLocation = { + toplevel: string; + /** Absolute directory that was cwd when the hooks dir was chosen. */ + baseDir: string; + /** Hooks directory relative to `baseDir`. */ + dir: string; + /** `git rev-parse --show-prefix` of `baseDir` (empty at worktree root). */ + prefix: string; + /** `core.hooksPath` value (git-root relative). */ + target: string; +}; + +function displayHooksDir(location: HooksLocation): string { + return location.prefix ? `${location.prefix}/${location.dir}` : location.dir; +} + +function getGitWorktree(): { toplevel: string; prefix: string } | InstallResult { + const toplevel = getGitToplevel(); + if (typeof toplevel !== 'string') { + return toplevel; + } + const prefixResult = spawnSync('git', ['rev-parse', '--show-prefix']); + if (prefixResult.status == null) { + return { message: 'git command not found', isError: true }; + } + if (prefixResult.status !== 0) { + return { message: ".git can't be found", isError: false }; + } + const prefix = prefixResult.stdout.toString().trim().replace(/\/$/, ''); + return { toplevel, prefix }; +} + +export type ResolveHooksLocationOptions = { + /** + * When nothing is stored, bind the default dir to the worktree root (`root`) + * or to the current git prefix (`cwd`). `enable` / `install` use `cwd` so a + * subdirectory install still works; `status` / `disable` use `root` so a + * nested cwd still finds the usual root dispatcher. + */ + unstoredPrefix?: 'cwd' | 'root'; + /** + * When nothing is stored, adopt `core.hooksPath` if it already points at a + * Vite+ dispatcher (`_/h`). Covers custom dirs and pre-`vp hooks` clones. + */ + adoptEffectiveDispatcher?: boolean; +}; + +function buildHooksLocation( + git: { toplevel: string; prefix: string }, + hooksDir: string, + prefix: string, +): HooksLocation | InstallResult { + const dirError = validateHooksDir(hooksDir); + if (dirError) { + return dirError; + } + const baseDir = prefix ? resolve(git.toplevel, prefix) : git.toplevel; + const target = prefix ? `${prefix}/${hooksDir}/_` : `${hooksDir}/_`; + return { toplevel: git.toplevel, baseDir, dir: hooksDir, prefix, target }; +} + +function tryAdoptEffectiveDispatcher(git: { + toplevel: string; + prefix: string; +}): HooksLocation | null { + const existing = getEffectiveHooksPath(); + if (!existing) { + return null; + } + const abs = isAbsolute(existing) ? existing : resolve(git.toplevel, existing); + let dispatcherDir: string; + try { + dispatcherDir = realpathSync(abs); + } catch { + return null; + } + if (!existsSync(join(dispatcherDir, 'h'))) { + return null; + } + const hooksAbs = resolve(dispatcherDir, '..'); + const relHooks = relative(git.toplevel, hooksAbs); + if (!relHooks || relHooks === '.' || relHooks.startsWith('..')) { + return null; + } + const posixRel = relHooks.split(sep).join('/'); + let prefix = ''; + let dir = posixRel; + if (posixRel === DEFAULT_HOOKS_DIR) { + prefix = ''; + dir = DEFAULT_HOOKS_DIR; + } else if (posixRel.endsWith(`/${DEFAULT_HOOKS_DIR}`)) { + prefix = posixRel.slice(0, -(DEFAULT_HOOKS_DIR.length + 1)); + dir = DEFAULT_HOOKS_DIR; + } + const location = buildHooksLocation(git, dir, prefix); + return 'isError' in location ? null : location; +} + +/** + * Resolve where hook files live. + * + * An explicit `dir` is relative to the current working directory (current + * git prefix). Omitting it uses the stored dir + the prefix recorded at + * enable/disable time, so later commands find the same tree from any cwd. + */ +export function resolveHooksLocation( + dir?: string, + options: ResolveHooksLocationOptions = {}, +): HooksLocation | InstallResult { + const git = getGitWorktree(); + if ('isError' in git) { + return git; + } + + let prefix: string; + let hooksDir: string; + if (dir !== undefined) { + prefix = git.prefix; + hooksDir = dir; + } else { + const storedDir = getStoredHooksDir(); + if (storedDir) { + hooksDir = storedDir; + prefix = getStoredHooksPrefix() ?? git.prefix; + } else if (options.adoptEffectiveDispatcher) { + const adopted = tryAdoptEffectiveDispatcher(git); + if (adopted) { + return adopted; + } + hooksDir = DEFAULT_HOOKS_DIR; + prefix = options.unstoredPrefix === 'root' ? '' : git.prefix; + } else { + hooksDir = DEFAULT_HOOKS_DIR; + prefix = options.unstoredPrefix === 'root' ? '' : git.prefix; + } + } + + return buildHooksLocation(git, hooksDir, prefix); +} + function validateHooksDir(dir: string): InstallResult | null { if (dir.includes('..')) { return { message: '.. not allowed', isError: true }; @@ -287,19 +449,6 @@ function validateHooksDir(dir: string): InstallResult | null { return null; } -function computeTarget(dir: string): { target: string } | InstallResult { - const prefixResult = spawnSync('git', ['rev-parse', '--show-prefix']); - if (prefixResult.status == null) { - return { message: 'git command not found', isError: true }; - } - if (prefixResult.status !== 0) { - return { message: ".git can't be found", isError: false }; - } - const rel = prefixResult.stdout.toString().trim().replace(/\/$/, ''); - const target = rel ? `${rel}/${dir}/_` : `${dir}/_`; - return { target }; -} - function getEffectiveHooksPath(): string { const checkResult = spawnSync('git', ['config', '--get', 'core.hooksPath']); return checkResult.status === 0 ? checkResult.stdout?.toString().trim() : ''; @@ -367,7 +516,7 @@ export interface InstallOptions { ignoreUserPreference?: boolean; } -export function install(dir = DEFAULT_HOOKS_DIR, options: InstallOptions = {}): InstallResult { +export function install(dir?: string, options: InstallOptions = {}): InstallResult { // VP_GIT_HOOKS is the canonical name; VITE_GIT_HOOKS is kept for backwards compatibility. if ( process.env.HUSKY === '0' || @@ -382,32 +531,23 @@ export function install(dir = DEFAULT_HOOKS_DIR, options: InstallOptions = {}): isError: false, }; } - const dirError = validateHooksDir(dir); - if (dirError) { - return dirError; + const location = resolveHooksLocation(dir); + if ('isError' in location) { + return location; } - const unsafeInstallPath = findUnsafeHookInstallPath(process.cwd(), dir); + const unsafeInstallPath = findUnsafeHookInstallPath(location.baseDir, location.dir); if (unsafeInstallPath) { return { message: describeUnsafeHookInstallPath(unsafeInstallPath), isError: false }; } - // Use --show-prefix to get the relative path from git root to cwd. - // This avoids Windows path normalization issues (MSYS paths, 8.3 short names) - // that make path.relative() unreliable across git and Node.js representations. - const targetResult = computeTarget(dir); - if ('message' in targetResult) { - return targetResult; - } - const { target } = targetResult; - const internal = (x = '') => join(dir, '_', x); + const internal = (x = '') => join(location.baseDir, location.dir, '_', x); // Read the effective value so a worktree-scoped setting cannot silently // override the local value we are about to write. const existingHooksPath = getEffectiveHooksPath(); - const toplevel = getGitToplevel(); - if (typeof toplevel !== 'string') { - return toplevel; - } - if (existingHooksPath && !hooksPathsEqual(existingHooksPath, target, toplevel)) { + if ( + existingHooksPath && + !hooksPathsEqual(existingHooksPath, location.target, location.toplevel) + ) { return { message: `core.hooksPath is already set to "${existingHooksPath}", skipping`, isError: false, @@ -417,13 +557,13 @@ export function install(dir = DEFAULT_HOOKS_DIR, options: InstallOptions = {}): rmSync(internal('husky.sh'), { force: true }); mkdirSync(internal(), { recursive: true }); writeFileSync(internal('.gitignore'), '*'); - writeFileSync(internal('h'), hookScript(dir), { mode: 0o755 }); + writeFileSync(internal('h'), hookScript(location.dir), { mode: 0o755 }); chmodSync(internal('h'), 0o755); for (const hook of SUPPORTED_GIT_HOOK_NAMES) { writeFileSync(internal(hook), `#!/usr/bin/env sh\n. "$(dirname "$0")/h"`, { mode: 0o755 }); chmodSync(internal(hook), 0o755); } - const { status, stderr } = spawnSync('git', ['config', 'core.hooksPath', target]); + const { status, stderr } = spawnSync('git', ['config', 'core.hooksPath', location.target]); if (status == null) { return { message: 'git command not found', isError: true }; } @@ -439,7 +579,7 @@ export function install(dir = DEFAULT_HOOKS_DIR, options: InstallOptions = {}): isError: true, }; } - const storeDir = setStoredHooksDir(dir); + const storeDir = setStoredHooksLocation(location.dir, location.prefix); if (!storeDir.ok) { return { message: storeDir.error || 'failed to store hooks directory', isError: true }; } @@ -451,7 +591,11 @@ export function install(dir = DEFAULT_HOOKS_DIR, options: InstallOptions = {}): * Install (or refresh) the Vite+ hook dispatcher and mark hooks as enabled. * Clears a previous `vp hooks disable` preference. */ -export function enable(dir = DEFAULT_HOOKS_DIR): InstallResult { +export function enable(dir?: string): InstallResult { + const location = resolveHooksLocation(dir); + if ('isError' in location) { + return location; + } const result = install(dir, { ignoreUserPreference: true }); if (result.isError) { return result; @@ -461,7 +605,7 @@ export function enable(dir = DEFAULT_HOOKS_DIR): InstallResult { return result; } return { - message: `Git hook dispatcher installed at ${dir}/_`, + message: `Git hook dispatcher installed at ${displayHooksDir(location)}/_`, isError: false, }; } @@ -474,24 +618,22 @@ export function enable(dir = DEFAULT_HOOKS_DIR): InstallResult { * - Removes the generated `/_` directory * - Leaves project-owned hooks, staged config, and package.json scripts alone */ -export function disable(dir = DEFAULT_HOOKS_DIR): InstallResult { - const dirError = validateHooksDir(dir); - if (dirError) { - return dirError; - } - - const targetResult = computeTarget(dir); - if ('message' in targetResult) { - return targetResult; - } - const { target } = targetResult; - const internalDir = join(dir, '_'); +export function disable(dir?: string): InstallResult { + const location = resolveHooksLocation(dir, { + unstoredPrefix: 'root', + adoptEffectiveDispatcher: true, + }); + if ('isError' in location) { + return location; + } + const displayedDir = displayHooksDir(location); + const internalDir = join(location.baseDir, location.dir, '_'); const hasInternalDir = existsSync(internalDir); // Refuse unsafe trees before any git config mutation so we never leave a // partial teardown (hooksPath cleared but `_/` still present). if (hasInternalDir) { - const unsafeInstallPath = findUnsafeHookInstallPath(process.cwd(), dir); + const unsafeInstallPath = findUnsafeHookInstallPath(location.baseDir, location.dir); if (unsafeInstallPath) { return { message: describeUnsafeHookInstallPath(unsafeInstallPath), @@ -501,13 +643,10 @@ export function disable(dir = DEFAULT_HOOKS_DIR): InstallResult { } const existingHooksPath = getEffectiveHooksPath(); - const toplevel = getGitToplevel(); - if (typeof toplevel !== 'string') { - return toplevel; - } - const ownsHooksPath = !!existingHooksPath && hooksPathsEqual(existingHooksPath, target, toplevel); + const ownsHooksPath = + !!existingHooksPath && hooksPathsEqual(existingHooksPath, location.target, location.toplevel); const foreignHooksPath = - !!existingHooksPath && !hooksPathsEqual(existingHooksPath, target, toplevel); + !!existingHooksPath && !hooksPathsEqual(existingHooksPath, location.target, location.toplevel); const actions: string[] = []; const notes: string[] = []; @@ -518,14 +657,14 @@ export function disable(dir = DEFAULT_HOOKS_DIR): InstallResult { if (!pref.ok) { return { message: pref.error || 'failed to persist hooks disabled preference', isError: true }; } - const storeDir = setStoredHooksDir(dir); + const storeDir = setStoredHooksLocation(location.dir, location.prefix); if (!storeDir.ok) { return { message: storeDir.error || 'failed to store hooks directory', isError: true }; } actions.push('recorded disable preference (local git config)'); if (ownsHooksPath) { - const unsetError = unsetOwnedHooksPath(target); + const unsetError = unsetOwnedHooksPath(location.target); if (unsetError) { return { message: `${unsetError.message}; disable preference was recorded (local git config). Run \`vp hooks enable\` to clear it, or \`git config --local --unset vp.hooks.disabled\``, @@ -535,16 +674,16 @@ export function disable(dir = DEFAULT_HOOKS_DIR): InstallResult { actions.push(`unset core.hooksPath (was "${existingHooksPath}")`); } else if (foreignHooksPath) { notes.push( - `core.hooksPath is set to "${existingHooksPath}" (not Vite+ dispatcher "${target}"), left unchanged`, + `core.hooksPath is set to "${existingHooksPath}" (not Vite+ dispatcher "${location.target}"), left unchanged`, ); } if (hasInternalDir) { rmSync(internalDir, { recursive: true, force: true }); - actions.push(`removed ${internalDir}`); + actions.push(`removed ${displayedDir}/_`); } - const summary = `Git hooks disabled: ${actions.join('; ')}. Project-owned hooks under ${dir}/ and staged config were left unchanged. Run \`vp hooks enable\` to re-enable.`; + const summary = `Git hooks disabled: ${actions.join('; ')}. Project-owned hooks under ${displayedDir}/ and staged config were left unchanged. Run \`vp hooks enable\` to re-enable.`; if (notes.length > 0) { return { message: `${summary} ${notes.join('; ')}.`, isError: false }; } @@ -555,42 +694,25 @@ export function disable(dir = DEFAULT_HOOKS_DIR): InstallResult { * Report whether Vite+ hooks are set up, disabled by preference, and active. */ export function status(dir?: string): InstallResult & { status?: HooksStatus } { - const hooksDir = resolveHooksDir(dir); - if (dir) { - const dirError = validateHooksDir(dir); - if (dirError) { - return dirError; - } - } else { - const dirError = validateHooksDir(hooksDir); - if (dirError) { - return dirError; - } - } - - const prefixResult = spawnSync('git', ['rev-parse', '--show-prefix']); - if (prefixResult.status == null) { - return { message: 'git command not found', isError: true }; - } - if (prefixResult.status !== 0) { - return { message: ".git can't be found", isError: false }; - } - - const rel = prefixResult.stdout.toString().trim().replace(/\/$/, ''); - const target = rel ? `${rel}/${hooksDir}/_` : `${hooksDir}/_`; + const location = resolveHooksLocation(dir, { + unstoredPrefix: 'root', + adoptEffectiveDispatcher: true, + }); + if ('isError' in location) { + return location; + } + const hooksDir = displayHooksDir(location); const existingHooksPath = getEffectiveHooksPath(); const userDisabled = isHooksUserDisabled(); - const dispatcherInstalled = existsSync(join(hooksDir, '_', 'h')); - const toplevel = getGitToplevel(); - if (typeof toplevel !== 'string') { - return toplevel; - } - const ownsHooksPath = !!existingHooksPath && hooksPathsEqual(existingHooksPath, target, toplevel); + const dispatcherInstalled = existsSync(join(location.baseDir, location.dir, '_', 'h')); + const ownsHooksPath = + !!existingHooksPath && hooksPathsEqual(existingHooksPath, location.target, location.toplevel); let projectHooks: string[] = []; - if (existsSync(hooksDir)) { + const hooksDirPath = join(location.baseDir, location.dir); + if (existsSync(hooksDirPath)) { try { - projectHooks = readdirSync(hooksDir, { withFileTypes: true }) + projectHooks = readdirSync(hooksDirPath, { withFileTypes: true }) .filter((entry) => entry.isFile() && SUPPORTED_GIT_HOOK_NAMES.includes(entry.name)) .map((entry) => entry.name) .toSorted(); diff --git a/packages/cli/src/hooks/bin.ts b/packages/cli/src/hooks/bin.ts index 16307b425f..b7a5f677cb 100644 --- a/packages/cli/src/hooks/bin.ts +++ b/packages/cli/src/hooks/bin.ts @@ -1,12 +1,6 @@ import mri from 'mri'; -import { - DEFAULT_HOOKS_DIR, - disable, - enable, - resolveHooksDir, - status, -} from '../config/hooks.ts'; +import { DEFAULT_HOOKS_DIR, disable, enable, status } from '../config/hooks.ts'; import { renderCliDoc } from '../utils/help.ts'; import { log, printHeader } from '../utils/terminal.ts'; @@ -113,17 +107,16 @@ async function main() { } const dirFlag = args['hooks-dir'] as string | undefined; - const dir = resolveHooksDir(dirFlag); switch (subcommand) { case 'enable': - applyResult(enable(dir)); + applyResult(enable(dirFlag)); return; case 'disable': - applyResult(disable(dir)); + applyResult(disable(dirFlag)); return; case 'status': - applyResult(status(dirFlag ? dir : undefined)); + applyResult(status(dirFlag)); return; } } From 9d43c1aecb67f8fe383a23f329c0e7c06151b920 Mon Sep 17 00:00:00 2001 From: Denny Biasiolli Date: Fri, 7 Aug 2026 16:56:24 +0200 Subject: [PATCH 08/10] fix(cli): unset owned hooksPath scopes hidden by a worktree override disable now always walks local and worktree scopes instead of only the effective value, so a foreign worktree path cannot leave a stale local Vite+ hooksPath behind after the dispatcher is removed. --- .../cli/src/config/__tests__/hooks.spec.ts | 32 +++++++++++++++++++ packages/cli/src/config/hooks.ts | 17 ++++++---- 2 files changed, 42 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/config/__tests__/hooks.spec.ts b/packages/cli/src/config/__tests__/hooks.spec.ts index 9d3d4b2513..9746e8f3f4 100644 --- a/packages/cli/src/config/__tests__/hooks.spec.ts +++ b/packages/cli/src/config/__tests__/hooks.spec.ts @@ -560,6 +560,38 @@ describe('enable / disable / status', () => { }, ); + it.skipIf(process.platform === 'win32')( + 'disable unsets a local Vite+ path hidden by a foreign worktree path', + () => { + const tmp = mkdtempSync(join(tmpdir(), 'hooks-worktree-hidden-local-')); + const originalCwd = process.cwd(); + try { + execSync('git init', { cwd: tmp, stdio: 'ignore' }); + execSync('git config extensions.worktreeConfig true', { cwd: tmp }); + execSync('git config --local core.hooksPath .vite-hooks/_', { cwd: tmp }); + execSync('git config --worktree core.hooksPath .husky/_', { cwd: tmp }); + mkdirSync(join(tmp, '.vite-hooks', '_'), { recursive: true }); + writeFileSync(join(tmp, '.vite-hooks', '_', 'h'), 'stale\n'); + process.chdir(tmp); + + const result = disable(); + expect(result.isError).toBe(false); + expect(isHooksUserDisabled()).toBe(true); + expect(existsSync(join(tmp, '.vite-hooks', '_'))).toBe(false); + expect(() => execSync('git config --local --get core.hooksPath', { cwd: tmp })).toThrow(); + expect( + execSync('git config --worktree --get core.hooksPath', { cwd: tmp }).toString().trim(), + ).toBe('.husky/_'); + expect(execSync('git config --get core.hooksPath', { cwd: tmp }).toString().trim()).toBe( + '.husky/_', + ); + } finally { + process.chdir(originalCwd); + rmSync(tmp, { recursive: true, force: true }); + } + }, + ); + it.skipIf(process.platform === 'win32')( 'disable unsets only the worktree Vite+ path and leaves a foreign local path', () => { diff --git a/packages/cli/src/config/hooks.ts b/packages/cli/src/config/hooks.ts index 56bd748ae3..338ab41b1c 100644 --- a/packages/cli/src/config/hooks.ts +++ b/packages/cli/src/config/hooks.ts @@ -663,14 +663,17 @@ export function disable(dir?: string): InstallResult { } actions.push('recorded disable preference (local git config)'); + // Always inspect each scope. A foreign worktree value can hide an owned + // local path; leaving that local value behind would come back if the + // worktree override is later removed. + const unsetError = unsetOwnedHooksPath(location.target); + if (unsetError) { + return { + message: `${unsetError.message}; disable preference was recorded (local git config). Run \`vp hooks enable\` to clear it, or \`git config --local --unset vp.hooks.disabled\``, + isError: true, + }; + } if (ownsHooksPath) { - const unsetError = unsetOwnedHooksPath(location.target); - if (unsetError) { - return { - message: `${unsetError.message}; disable preference was recorded (local git config). Run \`vp hooks enable\` to clear it, or \`git config --local --unset vp.hooks.disabled\``, - isError: true, - }; - } actions.push(`unset core.hooksPath (was "${existingHooksPath}")`); } else if (foreignHooksPath) { notes.push( From 196a281a6b75e10bf2837a4c1482b74db3ffa2d3 Mon Sep 17 00:00:00 2001 From: Denny Biasiolli Date: Fri, 7 Aug 2026 16:56:34 +0200 Subject: [PATCH 09/10] fix(cli): clear all matching git config values when unsetting hooks keys git config --unset exits 5 when a key has multiple values, which we treated as success. Use --unset-all for the disable preference and scoped core.hooksPath so enable cannot leave a stale disabled state. --- .../cli/src/config/__tests__/hooks.spec.ts | 24 +++++++++++++++++++ packages/cli/src/config/hooks.ts | 4 ++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/config/__tests__/hooks.spec.ts b/packages/cli/src/config/__tests__/hooks.spec.ts index 9746e8f3f4..41c3908ebd 100644 --- a/packages/cli/src/config/__tests__/hooks.spec.ts +++ b/packages/cli/src/config/__tests__/hooks.spec.ts @@ -297,6 +297,30 @@ describe('enable / disable / status', () => { }, ); + it.skipIf(process.platform === 'win32')( + 'enable clears duplicate disable-preference values', + () => { + const tmp = mkdtempSync(join(tmpdir(), 'hooks-multi-disabled-')); + const originalCwd = process.cwd(); + try { + execSync('git init', { cwd: tmp, stdio: 'ignore' }); + process.chdir(tmp); + execSync('git config --local --add vp.hooks.disabled true', { cwd: tmp }); + execSync('git config --local --add vp.hooks.disabled true', { cwd: tmp }); + expect(isHooksUserDisabled()).toBe(true); + + expect(enable().isError).toBe(false); + expect(isHooksUserDisabled()).toBe(false); + expect(() => + execSync('git config --local --get vp.hooks.disabled', { cwd: tmp }), + ).toThrow(); + } finally { + process.chdir(originalCwd); + rmSync(tmp, { recursive: true, force: true }); + } + }, + ); + it.skipIf(process.platform === 'win32')( 'disable leaves a foreign core.hooksPath alone but still records preference', () => { diff --git a/packages/cli/src/config/hooks.ts b/packages/cli/src/config/hooks.ts index 338ab41b1c..8139f78c11 100644 --- a/packages/cli/src/config/hooks.ts +++ b/packages/cli/src/config/hooks.ts @@ -233,7 +233,7 @@ function gitConfigSet(key: string, value: string): { ok: boolean; error?: string } function gitConfigUnset(key: string): { ok: boolean; error?: string } { - const result = spawnSync('git', ['config', '--local', '--unset', key]); + const result = spawnSync('git', ['config', '--local', '--unset-all', key]); if (result.status == null) { return { ok: false, error: 'git command not found' }; } @@ -460,7 +460,7 @@ function getScopedHooksPath(scope: 'local' | 'worktree'): string { } function unsetScopedHooksPath(scope: 'local' | 'worktree'): InstallResult | null { - const result = spawnSync('git', ['config', `--${scope}`, '--unset', 'core.hooksPath']); + const result = spawnSync('git', ['config', `--${scope}`, '--unset-all', 'core.hooksPath']); if (result.status == null) { return { message: 'git command not found', isError: true }; } From ca54fec32d5b967ef3f3ced214366f8c806e4a93 Mon Sep 17 00:00:00 2001 From: Denny Biasiolli Date: Fri, 7 Aug 2026 16:57:00 +0200 Subject: [PATCH 10/10] fix(cli): reject unexpected vp hooks operands before mutating state Positional directories and unknown flags were ignored, so `vp hooks disable .custom-hooks` could tear down the default dispatcher. Fail fast and point at --hooks-dir instead. --- packages/cli/src/hooks/__tests__/args.spec.ts | 23 +++++++++++++++++++ packages/cli/src/hooks/args.ts | 20 ++++++++++++++++ packages/cli/src/hooks/bin.ts | 7 ++++++ 3 files changed, 50 insertions(+) create mode 100644 packages/cli/src/hooks/__tests__/args.spec.ts create mode 100644 packages/cli/src/hooks/args.ts diff --git a/packages/cli/src/hooks/__tests__/args.spec.ts b/packages/cli/src/hooks/__tests__/args.spec.ts new file mode 100644 index 0000000000..cc514e8689 --- /dev/null +++ b/packages/cli/src/hooks/__tests__/args.spec.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; + +import { unexpectedHooksArgsError } from '../args.js'; + +describe('unexpectedHooksArgsError', () => { + it('rejects leftover positional operands', () => { + expect(unexpectedHooksArgsError({ _: ['.custom-hooks'] })).toBe( + 'Unexpected argument ".custom-hooks". Use --hooks-dir to set a custom hooks directory.', + ); + }); + + it('rejects unknown options', () => { + expect(unexpectedHooksArgsError({ _: [], dir: '.custom-hooks' })).toBe( + 'Unknown option "--dir".', + ); + }); + + it('allows known flags', () => { + expect( + unexpectedHooksArgsError({ _: [], 'hooks-dir': '.custom-hooks', help: false, h: false }), + ).toBeNull(); + }); +}); diff --git a/packages/cli/src/hooks/args.ts b/packages/cli/src/hooks/args.ts new file mode 100644 index 0000000000..0ad965f8dd --- /dev/null +++ b/packages/cli/src/hooks/args.ts @@ -0,0 +1,20 @@ +const KNOWN_HOOKS_ARG_KEYS = new Set(['_', 'help', 'h', 'hooks-dir']); + +/** + * Reject leftover positionals and unknown flags before enable/disable mutate state. + */ +export function unexpectedHooksArgsError(args: { + _: Array; + [key: string]: unknown; +}): string | null { + const extra = args._.map(String).filter((value) => value !== ''); + if (extra.length > 0) { + return `Unexpected argument "${extra[0]}". Use --hooks-dir to set a custom hooks directory.`; + } + for (const key of Object.keys(args)) { + if (!KNOWN_HOOKS_ARG_KEYS.has(key)) { + return `Unknown option "--${key}".`; + } + } + return null; +} diff --git a/packages/cli/src/hooks/bin.ts b/packages/cli/src/hooks/bin.ts index b7a5f677cb..1a1c9c6d1e 100644 --- a/packages/cli/src/hooks/bin.ts +++ b/packages/cli/src/hooks/bin.ts @@ -3,6 +3,7 @@ import mri from 'mri'; import { DEFAULT_HOOKS_DIR, disable, enable, status } from '../config/hooks.ts'; import { renderCliDoc } from '../utils/help.ts'; import { log, printHeader } from '../utils/terminal.ts'; +import { unexpectedHooksArgsError } from './args.ts'; const SUBCOMMANDS = ['enable', 'disable', 'status'] as const; type Subcommand = (typeof SUBCOMMANDS)[number]; @@ -106,6 +107,12 @@ async function main() { return; } + const unexpected = unexpectedHooksArgsError(args); + if (unexpected) { + log(unexpected); + process.exit(1); + } + const dirFlag = args['hooks-dir'] as string | undefined; switch (subcommand) {