Repository navigation
Description
Describe the bug
After a repo moves from husky to Vite+ Git hooks (husky removed, .husky/commit-msg moved to .vite-hooks/commit-msg, "prepare": "vp config"), every existing clone still has core.hooksPath=.husky/_, which husky set. In those clones vp install (through prepare), vp config, vp config --hooks and vp hooks enable all print:
core.hooksPath is already set to ".husky/_", skipping
core.hooksPath stays .husky/_, no hook runs, and nothing exits non-zero. Only fresh clones get working hooks, so a migration quietly turns hooks off for everyone who already had the repo checked out.
The skip comes from install() in packages/cli/src/config/hooks.ts: any effective core.hooksPath that isn't the Vite+ target is left alone. #772 used to replace .husky/_ / .husky, but #2280 changed that, and the command_config_replace_husky_hookspath snapshot now expects the husky path to be kept. The docs say the same ("If core.hooksPath points to another hook directory, vp config skips dispatcher installation").
Expected: .husky/_ is a dispatcher husky generated, not one the project owns, and Vite+ already accepts husky's HUSKY=0 and husky/init.sh. So vp config / vp hooks enable should take over a .husky/_ (or .husky) hooksPath, at least when the project no longer depends on husky. Failing that, vp hooks enable --force (and something similar for vp config) would let prepare take it over explicitly. Explicit vp hooks enable has no way to override the skip today.
Reproduction
Self-contained script below (no repo needed).
Steps to reproduce
Run with bash repro.sh. It builds a throwaway repo in a temp dir with .vite-hooks/commit-msg (rejects messages not starting with feat), sets core.hooksPath the way husky leaves it, runs the commands and attempts a bad commit. bash repro.sh --unset clears core.hooksPath first, for contrast.
#!/usr/bin/env bash
# Usage: repro.sh [--unset] (--unset clears core.hooksPath before installing)
set -u
VP_VERSION="${VP_VERSION:-1.4.0}"
DIR="$(mktemp -d)"; cd "$DIR"
git init -q
cat > package.json <<JSON
{
"name": "hookspath-repro",
"private": true,
"packageManager": "pnpm@10.20.0",
"scripts": { "prepare": "vp config" },
"devDependencies": { "vite-plus": "$VP_VERSION" }
}
JSON
mkdir -p .vite-hooks
cat > .vite-hooks/commit-msg <<'HOOK'
#!/usr/bin/env sh
head -1 "$1" | grep -q '^feat' || { echo "commit-msg hook: rejected" >&2; exit 1; }
HOOK
chmod +x .vite-hooks/commit-msg
# What husky leaves behind in an existing clone
git config core.hooksPath .husky/_
mkdir -p .husky/_
[ "${1:-}" = "--unset" ] && git config --unset core.hooksPath
show() { echo " core.hooksPath = $(git config --get core.hooksPath || echo '<unset>')"; }
echo "$ vp install"; vp install 2>&1 | grep -iE 'hooksPath|prepare|vp config|error' ; show
echo "$ vp --version"; vp --version | sed -n '1,4p'
echo "$ vp config"; vp config --no-agent 2>&1; show
echo "$ vp config --hooks"; vp config --hooks --no-agent 2>&1; show
echo "$ vp hooks enable"; vp hooks enable 2>&1; show
git add -A
echo "$ git commit -m 'bad message'"
if git -c user.name=repro -c user.email=repro@example.invalid commit -q -m "bad message" 2>&1; then
echo "=> commit SUCCEEDED (hook did not run)"
else
echo "=> commit REJECTED by hook"
fi
rm -rf "$DIR"Output with husky's hooksPath (bash repro.sh):
$ vp install
> hookspath-repro@ prepare <tmpdir>
> vp config
core.hooksPath is already set to ".husky/_", skipping
core.hooksPath = .husky/_
$ vp --version
vp v1.1.0
Local vite-plus:
vite-plus v1.4.0
$ vp config
core.hooksPath is already set to ".husky/_", skipping
core.hooksPath = .husky/_
$ vp config --hooks
core.hooksPath is already set to ".husky/_", skipping
core.hooksPath = .husky/_
$ vp hooks enable
core.hooksPath is already set to ".husky/_", skipping
core.hooksPath = .husky/_
$ git commit -m 'bad message'
=> commit SUCCEEDED (hook did not run)
Output with bash repro.sh --unset:
$ vp install
> hookspath-repro@ prepare <tmpdir>
> vp config
core.hooksPath = .vite-hooks/_
$ vp --version
vp v1.1.0
Local vite-plus:
vite-plus v1.4.0
$ vp config
core.hooksPath = .vite-hooks/_
$ vp config --hooks
core.hooksPath = .vite-hooks/_
$ vp hooks enable
Git hook dispatcher installed at .vite-hooks/_
core.hooksPath = .vite-hooks/_
$ git commit -m 'bad message'
commit-msg hook: rejected
VITE+ - commit-msg script failed (code 1)
=> commit REJECTED by hook
Workaround: clear husky's value before vp config in prepare:
"prepare": "git config --get core.hooksPath | grep -q '^\\.husky' && git config --unset core.hooksPath; vp config"System Info
$ vp --version
vp v1.1.0
Local vite-plus:
vite-plus v1.4.0
Tools:
vite v8.3.5
rolldown v1.2.15
vitest v5.0.5
oxfmt v0.74.0
oxlint v1.89.0
oxlint-tsgolint v7.0.2004
tsdown v0.23.1
Environment:
Package manager pnpm v10.20.0
Node.js v24.19.0
$ vp env current
Node.js:
Version 24.19.0
Source default
Mode managed
Package Manager:
Name pnpm
Version 10.20.0
Source packageManager
Mode system_first
OS: Ubuntu 26.04.1 LTS (Linux 7.0.0 x86_64), git 2.53.0vite-plus 1.4.0 is the latest release (npm latest dist-tag) as of this report.
Used Package Manager
pnpm
Validations
- Read the Contributing Guidelines.
- Check that there isn't already an issue for the same bug.
- Confirm this is a Vite+ issue and not an upstream issue.
- The provided reproduction is a minimal reproducible example.
Written by claude-opus-5-5 via Claude Code in T3 Code on goobles-agentbox.
Metadata
Metadata
Assignees
Labels
Type
Fields
Priority
Activity
voidzero-bot commentedon Oct 28, 2026
core.hooksPath = .husky/_, and the reporter's workaround (unsetcore.hooksPathbeforevp config) is straightforward.The skip comes from
install()inpackages/cli/src/config/hooks.ts, where any effectivecore.hooksPaththat does not match the Vite+ target is treated as a foreign/project-owned setup and left alone.vp hooks enablejust forwards that skip message, and neithervp confignorvp hooks enablehas a--forceescape hatch. The current behavior is intentionally encoded in thecommand_config_replace_husky_hookspathsnapshot and was introduced by #2280 to preserve user-owned hook directories.I wasn't able to find a fix, but I identified some areas that may be relevant. The most promising places to change if maintainers want to improve husky migrations are:
packages/cli/src/config/hooks.ts(install()andenable())packages/cli/src/config/bin.ts(vp configargument handling)packages/cli/src/hooks/bin.ts(vp hooks enablesubcommand)packages/cli/binding(native argument parsing — a--forceflag would need to be added here)crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_config_replace_husky_hookspath/(snapshot will need updating if takeover behavior changes)A possible enhancement would be to treat normalized
.husky/.husky/_as a husky-generated dispatcher rather than a project-owned directory (for example, only taking over whenhuskyis not listed inpackage.jsondependencies), or to add a--forceflag tovp hooks enableandvp configso an explicit install request can override a stalecore.hooksPath.Full Triage Report
Triage Report — Issue #3264
Summary
When a project migrates from husky to Vite+ Git hooks, existing clones may still have
core.hooksPathset to husky’s dispatcher path (.husky/_). On those clones,vp config,vp config --hooks, andvp hooks enableall print a non-error skip message and leavecore.hooksPathunchanged, so the Vite+ hooks never run. The reporter expects Vite+ to take over a.husky/_(or.husky) hooksPath or provide an explicit override flag.Environment
/home/runner/work/vite-plus/vite-plusvp --version:vp v1.4.0/ localvite-plus 1.4.0v22.23.4v12.11.2Reproduction
Created
/tmp/vite-plus-triage/gh-3264/repro:git initpackage.jsonwith"prepare": "vp config"vite-plusfrom the checkout:pnpm link /home/runner/work/vite-plus/vite-plus/packages/cli.vite-hooks/commit-msgthat rejects commit messages not starting withfeatcore.hooksPath = .husky/_and created.husky/_(simulating an existing clone left by husky)Commands run with husky’s
core.hooksPathControl
After
git config --unset core.hooksPath,vp hooks enableinstalled the dispatcher at.vite-hooks/_, and the same bad commit message was rejected by the.vite-hooks/commit-msghook. This confirms the hook itself works whencore.hooksPathis not blocked by husky’s old value.Result
main) still skips dispatcher installation whenevercore.hooksPathpoints to.husky/_, leaving the repository without working hooks and exiting0.Diagnosis
Affected files
packages/cli/src/config/hooks.ts:install()— lines where it readsgetEffectiveHooksPath()and returns the skip message.enable()— passes throughinstall()and forwards non-error skip messages.packages/cli/src/config/bin.ts—vp configentry point; callsinstall()directly.packages/cli/src/hooks/bin.ts—vp hooks enablesubcommand; callsenable().packages/cli/binding— native argument parser forvp config/vp hooks; no--forceflag currently exists to bypass the foreign-hooksPath guard.crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_config_replace_husky_hookspath/snapshots.toml— snapshot that encodes the current "keep.husky/_" behavior.Incorrect logic
install()inpackages/cli/src/config/hooks.tstreats any effectivecore.hooksPaththat does not equal the Vite+ target as a foreign/user-owned setup:This guard does not distinguish:
.husky/_or.husky).Because the return is non-error (
isError: false) and has no force option, bothvp configandvp hooks enablesilently do nothing. In a migration from husky to Vite+, every existing clone keeps the stale husky dispatcher path, so Vite+ hooks never run.enable()simply forwardsinstall()'s result; there is no override path for an explicitvp hooks enableinvocation.Why this causes the reported behavior
core.hooksPath = .husky/_and installs its own stub dispatcher there..vite-hooks/, and adds"prepare": "vp config".core.hooksPath, sovp config/vp hooks enableinstall the Vite+ dispatcher normally.core.hooksPath = .husky/_is still present. The guard above sees a foreign path, returns the skip message, and leaves the husky dispatcher configured even though husky is no longer wanted..vite-hooks/scripts, so hooks do not run.Suggested fix approach
Two complementary changes are worth considering:
install(). Treat normalized.huskyand.husky/_as Vite+-takeable, because they are generated by husky rather than project-owned. To avoid breaking projects that genuinely still use husky, the takeover could be conditional onhuskynot being present inpackage.jsondependencies/devDependencies. Once taken over, write the Vite+ target and proceed with dispatcher installation.--forceflag tovp hooks enable(and equivalent control forvp config) that overrides any foreigncore.hooksPath. This gives an escape hatch for migrations and makes an explicitenableactually authoritative. The flag would need to be threaded from the native argument parsers throughconfig/bin.tsandhooks/bin.tsintoinstall({ force }).Edge cases
core.hooksPath(local vs. worktree):getEffectiveHooksPath()returns the resolved effective value. A force/takeover should probably write at the same scope or at least rely ongit config --local core.hooksPath, following the existing installer pattern.vp hooks disablethenvp config:install()already honors the disabled preference; that is separate from the husky-path guard.VP_GIT_HOOKS=0,HUSKY=0): handled before the path guard and should remain honored.--hooks-dir .custom-hooksandcore.hooksPathpoints to.husky/_, the takeover should still apply because.husky/_is husky's path, not the user's custom path.Regression evidence
The snapshot fixture
command_config_replace_husky_hookspathexplicitly asserts thatvp config --no-agentpreserves.husky/_and thatgit config --local core.hooksPathstays.husky/_. This behavior was intentionally locked in by #2280. Changing the takeover policy will require updating that snapshot.Confidence
high — the failing branch, the exact skip message, and the silent no-op are all directly observable in both the source code and the reproduction.
Verification
Claim
Reporter:
vp config,vp config --hooks, andvp hooks enableshould take over a stale huskycore.hooksPathvalue (.husky/_or.husky) so that migrated projects get working hooks in existing clones.Observed current behavior: all three commands leave
core.hooksPath = .husky/_and emit a non-error skip message.Evidence
docs/guide/commit-hooks.mdstates:crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_config_replace_husky_hookspath/snapshots.toml(and its recorded.md) expectsvp config --no-agentwithcore.hooksPath = .husky/_to output:git config --local core.hooksPathto remain.husky/_.#2280intentionally introduced the skip for project-owned hooks. Commitcfb59b2f("fix(cli): preserve project-owned git hooks (fix(cli): preserve project-owned git hooks #2280)") changed the earlier husky-exception logic to a generic "preserve any foreign hooksPath" guard. Its parent code (before fix(cli): preserve project-owned git hooks #2280) explicitly allowed replacing.huskyand paths starting with.husky/; fix(cli): preserve project-owned git hooks #2280 removed that special case and addednormalizeHooksPathequality check, which preserves.husky/_.vp configto install hooks without forcingvp staged#2252, indicating the change was a deliberate response to a prior problem wherevp configreplaced user-owned hook directories.Verdict
intended-behavior — the current skip is exactly what the docs, the CLI snapshot, and the #2280 fix describe. The husky migration pain is a real gap, but it should be addressed as an enhancement (e.g., auto-detecting husky-generated paths, checking for the
huskydependency, or adding a--forceflag) rather than as a bug in existing logic.Confidence
high — the intent is directly captured in documentation, a committed snapshot, and the original fix commit.
This report was made by an LLM. The analysis may be wrong, and the potential fix might not work, but is intended as a starting point for exploring the issue.
semimikoh commentedon Nov 4, 2026
I'd like to work on this. I'm considering adding an explicit
--forceoption to allow replacing an existingcore.hooksPath, while preserving the current default behavior. Would this approach be acceptable?