diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_existing_vite_hooks/.vite-hooks/commit-msg b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_existing_vite_hooks/.vite-hooks/commit-msg new file mode 100644 index 0000000000..90defd2f5d --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_existing_vite_hooks/.vite-hooks/commit-msg @@ -0,0 +1 @@ +echo project-owned commit-msg diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_existing_vite_hooks/.vite-hooks/pre-commit b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_existing_vite_hooks/.vite-hooks/pre-commit new file mode 100644 index 0000000000..3080a51a98 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_existing_vite_hooks/.vite-hooks/pre-commit @@ -0,0 +1 @@ +echo project-owned pre-commit diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_existing_vite_hooks/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_existing_vite_hooks/package.json new file mode 100644 index 0000000000..d16354968d --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_existing_vite_hooks/package.json @@ -0,0 +1,10 @@ +{ + "name": "migration-existing-vite-hooks", + "packageManager": "pnpm@12.11.2", + "scripts": { + "prepare": "echo vp config --no-hooks" + }, + "devDependencies": { + "vite-plus": "latest" + } +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_existing_vite_hooks/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_existing_vite_hooks/snapshots.toml new file mode 100644 index 0000000000..5e771f9e6c --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_existing_vite_hooks/snapshots.toml @@ -0,0 +1,13 @@ +[[case]] +name = "migration_existing_vite_hooks" +vp = "global" +steps = [ + { argv = ["git", "init"], snapshot = false }, + { argv = ["pnpm", "--version"], snapshot = false }, + { argv = ["vp", "migrate", "--full", "--no-agent", "--no-editor"], comment = "Complete the existing hook setup without prompting, replacing project hooks, or changing echo arguments." }, + { argv = ["vpt", "print-file", "package.json", ".vite-hooks/pre-commit", ".vite-hooks/commit-msg"] }, + { argv = ["git", "config", "--local", "core.hooksPath"] }, + { argv = ["vpt", "stat-file", ".vite-hooks/_/h", "--assert", "file"] }, + { argv = ["vp", "migrate", "--full", "--no-agent", "--no-editor"], comment = "A complete hook setup is detected and skipped without prompting on the next migration." }, + { argv = ["vpt", "print-file", "package.json", ".vite-hooks/pre-commit", ".vite-hooks/commit-msg"] }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_existing_vite_hooks/snapshots/migration_existing_vite_hooks.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_existing_vite_hooks/snapshots/migration_existing_vite_hooks.md new file mode 100644 index 0000000000..5b12ffce10 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_existing_vite_hooks/snapshots/migration_existing_vite_hooks.md @@ -0,0 +1,79 @@ +# migration_existing_vite_hooks + +## `git init` + + +## `pnpm --version` + + +## `vp migrate --full --no-agent --no-editor` + +Complete the existing hook setup without prompting, replacing project hooks, or changing echo arguments. + +``` +VITE+ - The Unified Toolchain for the Web + +◇ Updated . to Vite+ +• Node pnpm +• Dependencies: + vite-plus latest → + vite → +• Git hooks configured +• Package manager settings configured +``` + +## `vpt print-file package.json .vite-hooks/pre-commit .vite-hooks/commit-msg` + +``` +{ + "name": "migration-existing-vite-hooks", + "packageManager": "pnpm@12.11.2", + "scripts": { + "prepare": "vp config && echo vp config --no-hooks" + }, + "devDependencies": { + "vite-plus": "catalog:" + } +} +echo project-owned pre-commit +echo project-owned commit-msg +``` + +## `git config --local core.hooksPath` + +``` +.vite-hooks/_ +``` + +## `vpt stat-file .vite-hooks/_/h --assert file` + +``` +.vite-hooks/_/h: file +``` + +## `vp migrate --full --no-agent --no-editor` + +A complete hook setup is detected and skipped without prompting on the next migration. + +``` +VITE+ - The Unified Toolchain for the Web + +This project is already using Vite+! Happy coding! +``` + +## `vpt print-file package.json .vite-hooks/pre-commit .vite-hooks/commit-msg` + +``` +{ + "name": "migration-existing-vite-hooks", + "packageManager": "pnpm@12.11.2", + "scripts": { + "prepare": "vp config && echo vp config --no-hooks" + }, + "devDependencies": { + "vite-plus": "catalog:" + } +} +echo project-owned pre-commit +echo project-owned commit-msg +``` diff --git a/packages/cli/src/config/hooks.ts b/packages/cli/src/config/hooks.ts index 3c475abdad..b67603c4d6 100644 --- a/packages/cli/src/config/hooks.ts +++ b/packages/cli/src/config/hooks.ts @@ -83,6 +83,9 @@ c=$? exit $c`; } +/** The per-hook stub written to `/_/`; it sources the `h` dispatcher. */ +export const HOOK_STUB_SCRIPT = '#!/usr/bin/env sh\n. "$(dirname "$0")/h"'; + export interface InstallResult { message: string; isError: boolean; @@ -569,7 +572,7 @@ export function install(dir?: string, options: InstallOptions = {}): InstallResu 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 }); + writeFileSync(internal(hook), HOOK_STUB_SCRIPT, { mode: 0o755 }); chmodSync(internal(hook), 0o755); } if (!gitConfigLocalHas('core.hooksPath', location.target)) { diff --git a/packages/cli/src/migration/__tests__/setup-plan-git-hooks.spec.ts b/packages/cli/src/migration/__tests__/setup-plan-git-hooks.spec.ts new file mode 100644 index 0000000000..d4a98ae2c9 --- /dev/null +++ b/packages/cli/src/migration/__tests__/setup-plan-git-hooks.spec.ts @@ -0,0 +1,453 @@ +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import * as prompts from '@voidzero-dev/vite-plus-prompts'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { HOOK_STUB_SCRIPT, hookScript } from '../../config/hooks.ts'; +import { PackageManager } from '../../types/index.ts'; +import { log } from '../../utils/terminal.ts'; +import { installGitHooks, isViteHooksSetupComplete } from '../migrator.ts'; +import { collectMigrationSetupPlan, type MigrationSetupPlan } from '../setup-plan.ts'; + +vi.mock('@voidzero-dev/vite-plus-prompts', async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + select: vi.fn(), + confirm: vi.fn(), + log: { ...mod.log, info: vi.fn(), warn: vi.fn() }, + }; +}); + +vi.mock('../../utils/terminal.ts', async (importOriginal) => { + const mod = await importOriginal(); + return { ...mod, log: vi.fn() }; +}); + +const mockSelect = vi.mocked(prompts.select); +const mockConfirm = vi.mocked(prompts.confirm); +const mockLog = vi.mocked(log); + +const skipMessage = expect.stringContaining('Git hooks already configured, skipping'); + +function git(cwd: string, ...args: string[]): void { + const result = spawnSync('git', args, { cwd, stdio: 'pipe' }); + if (result.status !== 0) { + throw new Error(`git ${args.join(' ')} failed: ${result.stderr.toString()}`); + } +} + +function writePackageJson(dir: string, pkg: Record): void { + fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify(pkg)); +} + +function readPackageJson(dir: string): { scripts?: Record } { + return JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')); +} + +/** Simulate what `vp config` leaves behind: dispatcher files and core.hooksPath. */ +function installDispatcher(dir: string, hookNames: string[]): void { + const dispatcherDir = path.join(dir, '.vite-hooks', '_'); + fs.mkdirSync(dispatcherDir, { recursive: true }); + function writeScript(name: string, content: string): void { + const filePath = path.join(dispatcherDir, name); + fs.writeFileSync(filePath, content, { mode: 0o755 }); + fs.chmodSync(filePath, 0o755); + } + writeScript('h', hookScript('.vite-hooks')); + for (const hookName of hookNames) { + writeScript(hookName, HOOK_STUB_SCRIPT); + } + git(dir, 'config', 'core.hooksPath', '.vite-hooks/_'); +} + +function expectNoHooksPrompt(): void { + expect(mockConfirm).not.toHaveBeenCalled(); + expect(mockSelect).not.toHaveBeenCalled(); +} + +describe('collectMigrationSetupPlan existing Vite+ hooks', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vp-test-setup-plan-hooks-')); + writePackageJson(tmpDir, {}); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + vi.clearAllMocks(); + }); + + function writeProjectHooks(): void { + fs.mkdirSync(path.join(tmpDir, '.vite-hooks'), { recursive: true }); + fs.writeFileSync(path.join(tmpDir, '.vite-hooks', 'commit-msg'), 'echo msg\n'); + fs.writeFileSync(path.join(tmpDir, '.vite-hooks', 'pre-commit'), 'npm test\n'); + } + + function setUpFullyConfiguredHooks(): void { + git(tmpDir, 'init', '-q'); + writePackageJson(tmpDir, { scripts: { prepare: 'vp config' } }); + writeProjectHooks(); + installDispatcher(tmpDir, ['commit-msg', 'pre-commit']); + } + + function collect(options: { + interactive: boolean; + hooks?: boolean; + }): Promise { + return collectMigrationSetupPlan( + tmpDir, + PackageManager.pnpm, + { ...options, agent: false, editor: false }, + [], + false, + ); + } + + describe('fully configured hooks', () => { + beforeEach(setUpFullyConfiguredHooks); + + it('is detected as complete', () => { + expect(isViteHooksSetupComplete(tmpDir)).toBe(true); + }); + + it.each([true, false])( + 'skips the hooks step without prompting (interactive=%s)', + async (interactive) => { + const plan = await collect({ interactive }); + + expect(plan.shouldSetupHooks).toBe(false); + expectNoHooksPrompt(); + expect(mockLog).toHaveBeenCalledWith(skipMessage); + }, + ); + + it('accepts a postinstall script that runs vp config', async () => { + writePackageJson(tmpDir, { scripts: { postinstall: 'vp config' } }); + + expect(isViteHooksSetupComplete(tmpDir)).toBe(true); + const plan = await collect({ interactive: true }); + expect(plan.shouldSetupHooks).toBe(false); + expectNoHooksPrompt(); + expect(mockLog).toHaveBeenCalledWith(skipMessage); + }); + + it.each([ + 'tsc -b && vp config', + 'vp config --no-hooks --hooks', + 'pnpm exec vp config', + 'vp "config" --hooks-dir="./.vite-hooks"', + 'echo "build; done" && vp config', + ])('accepts a lifecycle script running %s', (prepare) => { + writePackageJson(tmpDir, { scripts: { prepare } }); + + expect(isViteHooksSetupComplete(tmpDir)).toBe(true); + }); + + it.each([ + 'echo vp config', + 'node setup.js vp config', + 'echo "build && vp config && done"', + 'vp config "--no-hooks"', + 'vp config --help', + 'vp config --hooks-dir .custom-hooks', + 'VP_GIT_HOOKS=0 vp config', + 'cd packages/app && vp config', + ])('does not count %j as a hook refresh', async (prepare) => { + writePackageJson(tmpDir, { scripts: { prepare } }); + + expect(isViteHooksSetupComplete(tmpDir)).toBe(false); + expect((await collect({ interactive: true })).shouldSetupHooks).toBe(true); + expectNoHooksPrompt(); + }); + + it.each([true, false])('honors --hooks=%s when setup is complete', async (hooks) => { + const plan = await collect({ interactive: true, hooks }); + + expect(plan.shouldSetupHooks).toBe(hooks); + expectNoHooksPrompt(); + expect(mockLog).not.toHaveBeenCalledWith(skipMessage); + }); + }); + + describe('partially configured hooks', () => { + it.each([ + [ + 'no prepare script', + () => { + git(tmpDir, 'init', '-q'); + writeProjectHooks(); + installDispatcher(tmpDir, ['commit-msg', 'pre-commit']); + }, + ], + [ + 'core.hooksPath unset', + () => { + git(tmpDir, 'init', '-q'); + writePackageJson(tmpDir, { scripts: { prepare: 'vp config' } }); + writeProjectHooks(); + }, + ], + [ + 'dispatcher missing', + () => { + setUpFullyConfiguredHooks(); + fs.rmSync(path.join(tmpDir, '.vite-hooks', '_'), { recursive: true }); + }, + ], + [ + 'dispatcher stub missing for a project hook', + () => { + setUpFullyConfiguredHooks(); + fs.rmSync(path.join(tmpDir, '.vite-hooks', '_', 'commit-msg')); + }, + ], + [ + 'the dispatcher is not executable', + () => { + setUpFullyConfiguredHooks(); + fs.chmodSync(path.join(tmpDir, '.vite-hooks', '_', 'h'), 0o644); + }, + ], + [ + 'a dispatcher stub is not executable', + () => { + setUpFullyConfiguredHooks(); + fs.chmodSync(path.join(tmpDir, '.vite-hooks', '_', 'pre-commit'), 0o644); + }, + ], + [ + 'the dispatcher content is stale', + () => { + setUpFullyConfiguredHooks(); + fs.writeFileSync(path.join(tmpDir, '.vite-hooks', '_', 'h'), '#!/usr/bin/env sh\n'); + }, + ], + [ + 'a dispatcher stub content is stale', + () => { + setUpFullyConfiguredHooks(); + fs.writeFileSync(path.join(tmpDir, '.vite-hooks', '_', 'commit-msg'), 'exit 0\n'); + }, + ], + [ + 'prepare runs vp config --no-hooks', + () => { + setUpFullyConfiguredHooks(); + writePackageJson(tmpDir, { scripts: { prepare: 'vp config --no-hooks' } }); + }, + ], + [ + 'postinstall runs vp config --hooks --no-hooks', + () => { + setUpFullyConfiguredHooks(); + writePackageJson(tmpDir, { + scripts: { postinstall: 'tsc -b && vp config --hooks --no-hooks' }, + }); + }, + ], + [ + 'no git repository', + () => { + writePackageJson(tmpDir, { scripts: { prepare: 'vp config' } }); + writeProjectHooks(); + }, + ], + ])('completes setup without prompting when %s', async (testCase, setUp) => { + if (process.platform === 'win32' && testCase.includes('not executable')) { + // Windows has no POSIX execute bits, so these cases do not apply. + return; + } + setUp(); + expect(isViteHooksSetupComplete(tmpDir)).toBe(false); + + for (const interactive of [true, false]) { + const plan = await collect({ interactive }); + expect(plan.shouldSetupHooks).toBe(true); + } + expectNoHooksPrompt(); + expect(mockLog).not.toHaveBeenCalledWith(skipMessage); + }); + + it('does not treat hook files alone as fully configured', () => { + writeProjectHooks(); + + expect(isViteHooksSetupComplete(tmpDir)).toBe(false); + }); + + it('preserves existing hook files while completing setup', async () => { + writeProjectHooks(); + + const plan = await collect({ interactive: true }); + expect(plan.shouldSetupHooks).toBe(true); + expect(installGitHooks(tmpDir, true)).toBe(true); + + expect(fs.readFileSync(path.join(tmpDir, '.vite-hooks', 'pre-commit'), 'utf8')).toBe( + 'npm test\n', + ); + expect(fs.readFileSync(path.join(tmpDir, '.vite-hooks', 'commit-msg'), 'utf8')).toBe( + 'echo msg\n', + ); + expect(readPackageJson(tmpDir).scripts?.prepare).toBe('vp config'); + expect(fs.existsSync(path.join(tmpDir, 'vite.config.ts'))).toBe(false); + }); + + it('does not add a redundant prepare script when postinstall runs vp config', async () => { + writePackageJson(tmpDir, { scripts: { postinstall: 'vp config', prepare: 'tsc -b' } }); + writeProjectHooks(); + + const plan = await collect({ interactive: true }); + expect(plan.shouldSetupHooks).toBe(true); + expect(installGitHooks(tmpDir, true)).toBe(true); + + expect(readPackageJson(tmpDir).scripts).toEqual({ + postinstall: 'vp config', + prepare: 'tsc -b', + }); + }); + + it('adds a refreshing prepare script when postinstall runs vp config --no-hooks', () => { + writePackageJson(tmpDir, { scripts: { postinstall: 'vp config --no-hooks' } }); + writeProjectHooks(); + + expect(installGitHooks(tmpDir, true)).toBe(true); + + expect(readPackageJson(tmpDir).scripts).toEqual({ + postinstall: 'vp config --no-hooks', + prepare: 'vp config', + }); + }); + + it.each([ + ['vp config --no-hooks', 'vp config'], + ['tsc -b && vp config --no-hooks', 'tsc -b && vp config'], + [ + 'echo "build && --no-hooks" && vp "config" "--no-hooks" && echo --no-hooks', + 'echo "build && --no-hooks" && vp "config" && echo --no-hooks', + ], + [ + 'vp config --no-hooks --hooks-dir .vite-hooks --no-agent && tsc -b', + 'vp config --hooks-dir .vite-hooks --no-agent && tsc -b', + ], + ])('rewrites prepare %j in place to re-enable hooks', (prepare, expected) => { + writePackageJson(tmpDir, { scripts: { prepare, postinstall: 'node setup.js' } }); + writeProjectHooks(); + + expect(installGitHooks(tmpDir, true)).toBe(true); + + expect(readPackageJson(tmpDir).scripts).toEqual({ + prepare: expected, + postinstall: 'node setup.js', + }); + }); + + it('prepends vp config to an existing prepare script', () => { + writePackageJson(tmpDir, { scripts: { prepare: 'tsc -b' } }); + writeProjectHooks(); + + expect(installGitHooks(tmpDir, true)).toBe(true); + + expect(readPackageJson(tmpDir).scripts?.prepare).toBe('vp config && tsc -b'); + }); + + it('still runs the existing preflight checks', async () => { + git(tmpDir, 'init', '-q'); + writeProjectHooks(); + git(tmpDir, 'config', 'core.hooksPath', '.githooks'); + + const plan = await collect({ interactive: true }); + + expect(plan.shouldSetupHooks).toBe(false); + expectNoHooksPrompt(); + expect(prompts.log.warn).toHaveBeenCalledWith( + expect.stringContaining('core.hooksPath is already set to ".githooks"'), + ); + }); + + it('runs preflight when the dispatcher directory is a regular file', async () => { + setUpFullyConfiguredHooks(); + const dispatcherDir = path.join(tmpDir, '.vite-hooks', '_'); + fs.rmSync(dispatcherDir, { recursive: true }); + fs.writeFileSync(dispatcherDir, 'preserve this file'); + + expect(isViteHooksSetupComplete(tmpDir)).toBe(false); + const plan = await collect({ interactive: true }); + + expect(plan.shouldSetupHooks).toBe(false); + expectNoHooksPrompt(); + expect(prompts.log.warn).toHaveBeenCalledWith( + expect.stringContaining( + `Git hook dispatcher path "${path.join('.vite-hooks', '_')}" is unsafe`, + ), + ); + expect(fs.readFileSync(dispatcherDir, 'utf8')).toBe('preserve this file'); + }); + + it.each([ + 'echo vp config --no-hooks', + 'node setup.js vp config --no-hooks', + 'echo "build && vp config --no-hooks && done"', + 'cd tools && vp config', + 'VP_GIT_HOOKS=0 vp config', + 'echo "$BUILD_STATUS"', + ])('preserves arguments in %j when adding a refresh command', (prepare) => { + writePackageJson(tmpDir, { scripts: { prepare } }); + writeProjectHooks(); + + expect(installGitHooks(tmpDir, true)).toBe(true); + expect(readPackageJson(tmpDir).scripts?.prepare).toBe(`vp config && ${prepare}`); + expect(installGitHooks(tmpDir, true)).toBe(true); + expect(readPackageJson(tmpDir).scripts?.prepare).toBe(`vp config && ${prepare}`); + }); + + it.each([true, false])('respects --hooks=%s', async (hooks) => { + writeProjectHooks(); + + const plan = await collect({ interactive: true, hooks }); + + expect(plan.shouldSetupHooks).toBe(hooks); + expectNoHooksPrompt(); + }); + }); + + describe('no existing hooks', () => { + it('does not treat non-hook files in .vite-hooks/ as existing hooks', async () => { + fs.mkdirSync(path.join(tmpDir, '.vite-hooks')); + fs.writeFileSync(path.join(tmpDir, '.vite-hooks', 'README.md'), 'not a hook\n'); + mockConfirm.mockResolvedValueOnce(true); + + const plan = await collect({ interactive: true }); + + expect(plan.shouldSetupHooks).toBe(true); + expect(mockConfirm).toHaveBeenCalledTimes(1); + }); + + it.each([true, false])('asks the original confirm prompt (answer=%s)', async (answer) => { + mockConfirm.mockResolvedValueOnce(answer); + + const plan = await collect({ interactive: true }); + + expect(plan.shouldSetupHooks).toBe(answer); + expect(mockConfirm).toHaveBeenCalledTimes(1); + expect(mockSelect).not.toHaveBeenCalled(); + }); + + it('sets up hooks by default in non-interactive mode', async () => { + const plan = await collect({ interactive: false }); + + expect(plan.shouldSetupHooks).toBe(true); + expectNoHooksPrompt(); + }); + + it.each([true, false])('respects --hooks=%s without prompting', async (hooks) => { + const plan = await collect({ interactive: true, hooks }); + + expect(plan.shouldSetupHooks).toBe(hooks); + expectNoHooksPrompt(); + }); + }); +}); diff --git a/packages/cli/src/migration/migrator/git-hooks.ts b/packages/cli/src/migration/migrator/git-hooks.ts index b465aed44f..02560157c3 100644 --- a/packages/cli/src/migration/migrator/git-hooks.ts +++ b/packages/cli/src/migration/migrator/git-hooks.ts @@ -4,12 +4,23 @@ import path from 'node:path'; import * as prompts from '@voidzero-dev/vite-plus-prompts'; import spawn from 'cross-spawn'; -import { rewriteScripts } from '../../../binding/index.js'; -import { findUnsafeHookInstallPath, SUPPORTED_GIT_HOOK_NAMES } from '../../config/hooks.ts'; +import { parseConfigArgs, rewriteScripts } from '../../../binding/index.js'; +import { + findUnsafeHookInstallPath, + HOOK_STUB_SCRIPT, + hookScript, + SUPPORTED_GIT_HOOK_NAMES, +} from '../../config/hooks.ts'; import type { PackageManager, WorkspacePackage } from '../../types/index.ts'; import { documentationUrl } from '../../utils/documentation.ts'; import { findGitRoot } from '../../utils/git.ts'; import { editJsonFile, isJsonFile, readJsonFile } from '../../utils/json.ts'; +import { + type CommandToken, + getCommandStartIndex, + literalArgv, + literalCommands, +} from '../../utils/shell-command.ts'; import { hasStagedConfigInViteConfig, mergeStagedConfigToViteConfig, @@ -86,6 +97,139 @@ export function hasExistingViteHooksPolicy(projectPath: string): boolean { .some((entry) => entry.isFile() && SUPPORTED_GIT_HOOK_NAME_SET.has(entry.name)); } +/** + * List the supported hook files present in `.vite-hooks/`, in canonical order. + */ +export function listExistingViteHookNames(projectPath: string): string[] { + const hooksDir = path.join(projectPath, '.vite-hooks'); + return SUPPORTED_GIT_HOOK_NAMES.filter((hookName) => + fs.lstatSync(path.join(hooksDir, hookName), { throwIfNoEntry: false })?.isFile(), + ); +} + +/** Full setup requires project hooks, a refresh lifecycle, and a current, + * executable dispatcher selected by core.hooksPath at the repository root. */ +export function isViteHooksSetupComplete(projectPath: string): boolean { + if (!hasExistingViteHooksPolicy(projectPath)) { + return false; + } + const packageJsonPath = path.join(projectPath, 'package.json'); + if (!fs.existsSync(packageJsonPath)) { + return false; + } + const pkg = readJsonFile(packageJsonPath) as { scripts?: Record }; + if (!hasVpConfigLifecycleScript(pkg.scripts)) { + return false; + } + const gitRoot = findGitRoot(projectPath); + if (!gitRoot || path.resolve(projectPath) !== path.resolve(gitRoot)) { + return false; + } + if (normalizeGitHooksPath(getConfiguredHooksPath(projectPath)) !== '.vite-hooks/_') { + return false; + } + if (findUnsafeHookInstallPath(projectPath, '.vite-hooks')) { + return false; + } + const dispatcherDir = path.join(projectPath, '.vite-hooks', '_'); + for (const name of ['h', ...listExistingViteHookNames(projectPath)]) { + const filePath = path.join(dispatcherDir, name); + const stats = fs.lstatSync(filePath, { throwIfNoEntry: false }); + if (!stats?.isFile()) { + return false; + } + // `vp config` chmods the dispatcher files to 0755; git ignores hooks that + // are not executable. Windows has no POSIX execute bits to check. + if (process.platform !== 'win32' && (stats.mode & 0o111) === 0) { + return false; + } + const expectedContent = name === 'h' ? hookScript('.vite-hooks') : HOOK_STUB_SCRIPT; + if (fs.readFileSync(filePath, 'utf8') !== expectedContent) { + return false; + } + } + return true; +} + +function hasVpConfigLifecycleScript(scripts: Record | undefined): boolean { + return [scripts?.prepare, scripts?.postinstall].some((script) => + vpConfigHookCommands(script).some((command) => command.hooksEnabled), + ); +} + +interface VpConfigHookCommand { + args: CommandToken[]; + hooksEnabled: boolean; +} + +/** Only recognize literal invocations in the project directory. Text passed + * to echo, node, or another command must not count as a dispatcher refresh. */ +function vpConfigHookCommands(script: string | undefined): VpConfigHookCommand[] { + if (!script) { + return []; + } + let commands = literalCommands(script); + // Preserve a leading refresh even when later commands need shell evaluation. + // setupGitHooks prepends this form when it cannot rewrite an existing script. + const separator = script.indexOf('&&'); + if (!commands && separator !== -1) { + const first = literalArgv(script.slice(0, separator)); + commands = first?.length ? [first] : undefined; + } + if (!commands) { + return []; + } + const result: VpConfigHookCommand[] = []; + for (const tokens of commands) { + const values = tokens.map(({ value }) => value); + if (/^[A-Za-z_]\w*=/.test(values[0] ?? '')) { + // Environment overrides can disable hooks or redirect Git operations. + return result; + } + if ( + ['cd', 'pushd', 'popd', '.', 'source', 'eval', 'export', 'set', 'unset'].includes(values[0]) + ) { + return result; + } + const start = getCommandStartIndex(values); + if (values[start] !== 'vp' || values[start + 1] !== 'config') { + continue; + } + const args = tokens.slice(start + 2); + // The native parser prints help; do not invoke it for help-only scripts. + if (args.some(({ value }) => value === '--help' || value === '-h')) { + continue; + } + const parsed = parseConfigArgs(args.map(({ value }) => value)); + if ( + parsed.status === 'ok' && + (parsed.value.hooksDir === undefined || + normalizeGitHooksPath(parsed.value.hooksDir) === '.vite-hooks') + ) { + result.push({ args, hooksEnabled: parsed.value.hooks !== false }); + } + } + return result; +} + +/** + * Drop `--no-hooks` from every `vp config` invocation in a script, keeping + * other commands and flags intact. Returns null when the script has no + * `vp config` invocation to rewrite. + */ +function enableVpConfigHooks(script: string): string | null { + const flags = vpConfigHookCommands(script).flatMap(({ args }) => + args.filter(({ value }) => value === '--no-hooks'), + ); + if (!flags.length) { + return null; + } + for (const flag of flags.toReversed()) { + script = script.slice(0, flag.start).trimEnd() + script.slice(flag.end); + } + return script; +} + function findNonRegularViteHookPath(projectPath: string): string | null { for (const hookName of SUPPORTED_GIT_HOOK_NAMES) { const hookPath = path.join(projectPath, '.vite-hooks', hookName); @@ -282,10 +426,13 @@ export function setupGitHooks( if (!pkg.scripts) { pkg.scripts = {}; } - if (!pkg.scripts.prepare) { - pkg.scripts.prepare = 'vp config'; - } else if (!pkg.scripts.prepare.includes('vp config')) { - pkg.scripts.prepare = `vp config && ${pkg.scripts.prepare}`; + if (!hasVpConfigLifecycleScript(pkg.scripts)) { + const prepare = pkg.scripts.prepare; + if (prepare) { + pkg.scripts.prepare = enableVpConfigHooks(prepare) ?? `vp config && ${prepare}`; + } else { + pkg.scripts.prepare = 'vp config'; + } } return pkg; diff --git a/packages/cli/src/migration/setup-plan.ts b/packages/cli/src/migration/setup-plan.ts index c7abeeb1eb..35cb580ee0 100644 --- a/packages/cli/src/migration/setup-plan.ts +++ b/packages/cli/src/migration/setup-plan.ts @@ -15,12 +15,15 @@ import { selectEditor, } from '../utils/editor.ts'; import { cancelAndExit, promptGitHooks } from '../utils/prompts.ts'; +import { log, muted } from '../utils/terminal.ts'; import { confirmEslintMigration, confirmTsupMigration, detectEslintProject, detectIncompatibleEslintIntegration, detectTsupProject, + hasExistingViteHooksPolicy, + isViteHooksSetupComplete, preflightGitHooksSetup, warnIncompatibleEslintIntegration, warnLegacyEslintConfig, @@ -45,15 +48,22 @@ async function collectGitHooksDecision( options: MigrationOptions, packages: readonly WorkspacePackage[] = [], ): Promise { - let shouldSetupHooks = await promptGitHooks(options); - if (shouldSetupHooks) { - const reason = preflightGitHooksSetup(rootDir, packageManager, packages); - if (reason) { - prompts.log.warn(`⚠ ${reason}`); - shouldSetupHooks = false; - } + const hasExistingHooks = options.hooks === undefined && hasExistingViteHooksPolicy(rootDir); + if (hasExistingHooks && isViteHooksSetupComplete(rootDir)) { + log(`${muted('•')} Git hooks already configured, skipping`); + return false; + } + + // Existing hook files opt into setup; preflight still protects their policy. + if (!hasExistingHooks && !(await promptGitHooks(options))) { + return false; + } + const reason = preflightGitHooksSetup(rootDir, packageManager, packages); + if (reason) { + prompts.log.warn(`⚠ ${reason}`); + return false; } - return shouldSetupHooks; + return true; } async function collectAgentInstructionPlan( diff --git a/packages/cli/src/migration/vitest-v5/commands.ts b/packages/cli/src/migration/vitest-v5/commands.ts index 20e17d8eca..475cf7e335 100644 --- a/packages/cli/src/migration/vitest-v5/commands.ts +++ b/packages/cli/src/migration/vitest-v5/commands.ts @@ -1,76 +1,14 @@ +import { getCommandStartIndex, literalArgv, literalCommands } from '../../utils/shell-command.ts'; import type { RewriteResult, VitestV5Finding } from './ast.ts'; -interface CommandToken { - value: string; - start: number; - end: number; -} - -/** Parse only a single literal argv command. Shell expansion, pipelines, and - * compound commands need review; treating them as argv could change behavior. */ -export function literalArgv(command: string): CommandToken[] | undefined { - const tokens: CommandToken[] = []; - let value = ''; - let start = 0; - let quote = ''; - let active = false; - for (let i = 0; i < command.length; i++) { - const character = command[i]; - if (quote) { - if (character === quote) { - quote = ''; - } else if ( - character === '\\' || - (quote === '"' && (character === '$' || character === '`')) - ) { - return undefined; - } else { - value += character; - } - } else if (character === "'" || character === '"') { - if (!active) { - start = i; - } - quote = character; - active = true; - } else if (character === '\n' || character === '\r') { - return undefined; - } else if (/\s/.test(character)) { - if (active) { - tokens.push({ value, start, end: i }); - } - active = false; - value = ''; - } else if (/[|&;<>($`\\)]/.test(character)) { - return undefined; - } else { - if (!active) { - start = i; - } - value += character; - active = true; - } - } - if (quote) { +/** Read test commands without evaluating shell state or directory changes. */ +export function literalTestCommands(command: string): string[][] | undefined { + const commands = literalCommands(command); + if (!commands) { return undefined; } - if (active) { - tokens.push({ value, start, end: command.length }); - } - return tokens; -} - -/** Read command lists without executing a shell. Directory-changing builtins, - * expansions, pipelines, and shell programs remain unresolved. */ -export function literalTestCommands(command: string): string[][] | undefined { - const commands: string[][] = []; - let quote = ''; - let start = 0; - const append = (end: number) => { - const tokens = literalArgv(command.slice(start, end)); - if (!tokens?.length) { - return false; - } + const result: string[][] = []; + for (const tokens of commands) { const values = tokens.map(({ value }) => value); while (/^[A-Za-z_]\w*=/.test(values[0] ?? '')) { values.shift(); @@ -79,53 +17,16 @@ export function literalTestCommands(command: string): string[][] | undefined { !values.length || ['cd', 'pushd', 'popd', '.', 'source', 'eval', 'export', 'set', 'unset'].includes(values[0]) ) { - return false; - } - commands.push(values); - return true; - }; - for (let index = 0; index < command.length; index++) { - const char = command[index]; - if (char === '\\') { - return undefined; - } - if (quote) { - if (char === quote) { - quote = ''; - } - } else if (char === '"' || char === "'") { - quote = char; - } else if (char === '#') { return undefined; - } else if ( - char === ';' || - command.slice(index, index + 2) === '&&' || - command.slice(index, index + 2) === '||' - ) { - if (!append(index)) { - return undefined; - } - if (char !== ';') { - index++; - } - start = index + 1; } + result.push(values); } - return !quote && append(command.length) ? commands : undefined; + return result; } /** Locate the runner's arguments after a supported package-manager wrapper. */ export function vitestCommandArgsStart(values: readonly string[]): number { - let start = 0; - if (['pnpm', 'npm', 'yarn', 'bun', 'npx', 'bunx'].includes(values[0])) { - start = 1; - if (values[start] === 'exec' || values[start] === 'x') { - start++; - } - if (values[start] === '--') { - start++; - } - } + const start = getCommandStartIndex(values); if (values[start] === 'vitest') { return start + 1; } diff --git a/packages/cli/src/utils/shell-command.ts b/packages/cli/src/utils/shell-command.ts new file mode 100644 index 0000000000..13697a409c --- /dev/null +++ b/packages/cli/src/utils/shell-command.ts @@ -0,0 +1,121 @@ +export interface CommandToken { + value: string; + start: number; + end: number; +} + +/** Locate the executable after a supported package-manager wrapper. */ +export function getCommandStartIndex(argv: readonly string[]): number { + if (!['pnpm', 'npm', 'yarn', 'bun', 'npx', 'bunx'].includes(argv[0])) { + return 0; + } + let start = 1; + if (argv[start] === 'exec' || argv[start] === 'x') { + start++; + } + if (argv[start] === '--') { + start++; + } + return start; +} + +/** Parse only a single literal argv command. Shell expansion, pipelines, and + * compound commands need review; treating them as argv could change behavior. */ +export function literalArgv(command: string): CommandToken[] | undefined { + const tokens: CommandToken[] = []; + let value = ''; + let start = 0; + let quote = ''; + let active = false; + for (let i = 0; i < command.length; i++) { + const character = command[i]; + if (quote) { + if (character === quote) { + quote = ''; + } else if ( + character === '\\' || + (quote === '"' && (character === '$' || character === '`')) + ) { + return undefined; + } else { + value += character; + } + } else if (character === "'" || character === '"') { + if (!active) { + start = i; + } + quote = character; + active = true; + } else if (character === '\n' || character === '\r') { + return undefined; + } else if (/\s/.test(character)) { + if (active) { + tokens.push({ value, start, end: i }); + } + active = false; + value = ''; + } else if (/[|&;<>($`\\)]/.test(character)) { + return undefined; + } else { + if (!active) { + start = i; + } + value += character; + active = true; + } + } + if (quote) { + return undefined; + } + if (active) { + tokens.push({ value, start, end: command.length }); + } + return tokens; +} + +/** Read literal command lists without executing a shell. Token offsets refer + * to the original script so callers can preserve its quoting and spacing. */ +export function literalCommands(command: string): CommandToken[][] | undefined { + const commands: CommandToken[][] = []; + let quote = ''; + let start = 0; + function appendCommand(end: number): boolean { + const tokens = literalArgv(command.slice(start, end)); + if (!tokens?.length) { + return false; + } + for (const token of tokens) { + token.start += start; + token.end += start; + } + commands.push(tokens); + return true; + } + for (let index = 0; index < command.length; index++) { + const char = command[index]; + if (char === '\\') { + return undefined; + } + if (quote) { + if (char === quote) { + quote = ''; + } + } else if (char === '"' || char === "'") { + quote = char; + } else if (char === '#') { + return undefined; + } else if (char === ';' || command.startsWith('&&', index) || command.startsWith('||', index)) { + if (!appendCommand(index)) { + return undefined; + } + if (char !== ';') { + index++; + } + start = index + 1; + } + } + if (quote || !appendCommand(command.length)) { + return undefined; + } + return commands; +}