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_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/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 052dd3510d..2e8f8a3ca0 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_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/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/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: 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/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!( 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/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, + }; +} 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',