From dd19c6ec18d5136cfc759d4eae1200bf590a6383 Mon Sep 17 00:00:00 2001 From: naokihaba <59875779+naokihaba@users.noreply.github.com> Date: Fri, 23 Oct 2026 00:59:27 +0900 Subject: [PATCH 1/3] fix(config): wait for a shared git config lock on first run Linked worktrees share one `.git/config`. When `vp config` runs for the first time in several worktrees at once, a write can find `config.lock` held by another worktree. Git older than 2.56 then fails at once, so the `prepare` script exits with code 1 and the install stops. Write git config through `writeGitConfig`. When Git exits with 255 (CONFIG_NO_LOCK), wait 50-800 ms and read the value again. All worktrees write the same values, so the run succeeds when another run has already stored them. Pass `-c core.configLockTimeout=0` so that the wait is the same on every Git version. `install()` now writes `core.hooksPath` through `gitConfigSet`, so this write also uses `--local`, like the other hook keys. Closes #2959 --- .../cli/src/config/__tests__/hooks.spec.ts | 61 ++++++++++++++++- packages/cli/src/config/hooks.ts | 68 ++++++++++++++----- 2 files changed, 110 insertions(+), 19 deletions(-) diff --git a/packages/cli/src/config/__tests__/hooks.spec.ts b/packages/cli/src/config/__tests__/hooks.spec.ts index 5f6c543a2e..e9230de2f8 100644 --- a/packages/cli/src/config/__tests__/hooks.spec.ts +++ b/packages/cli/src/config/__tests__/hooks.spec.ts @@ -1,4 +1,5 @@ -import { execSync } from 'node:child_process'; +import { execSync, spawn } from 'node:child_process'; +import { once } from 'node:events'; import { chmodSync, existsSync, @@ -170,6 +171,64 @@ describe('install', () => { } }); + it('waits for a concurrent first run to release the git config lock', async () => { + const tmp = mkdtempSync(join(tmpdir(), 'hooks-config-lock-wait-test-')); + const originalCwd = process.cwd(); + try { + execSync('git init', { cwd: tmp, stdio: 'ignore' }); + process.chdir(tmp); + const lockPath = join(tmp, '.git', 'config.lock'); + writeFileSync(lockPath, ''); + + // Another worktree's first `vp config` releases the shared lock soon. + const holder = spawn(process.execPath, [ + '-e', + `setTimeout(() => require('node:fs').rmSync(${JSON.stringify(lockPath)}), 200)`, + ]); + expect(install()).toEqual({ message: '', isError: false }); + await once(holder, 'exit'); + + expect(execSync('git config --local core.hooksPath', { cwd: tmp }).toString().trim()).toBe( + '.vite-hooks/_', + ); + expect(execSync('git config --local vp.hooks.prefix', { cwd: tmp }).toString().trim()).toBe( + '.', + ); + } finally { + process.chdir(originalCwd); + rmSync(tmp, { recursive: true, force: true }); + } + }); + + it('accepts values that a concurrent first run wrote while it held the lock', async () => { + const tmp = mkdtempSync(join(tmpdir(), 'hooks-config-lock-reread-test-')); + const originalCwd = process.cwd(); + try { + execSync('git init', { cwd: tmp, stdio: 'ignore' }); + process.chdir(tmp); + const configPath = join(tmp, '.git', 'config'); + writeFileSync(`${configPath}.lock`, ''); + + // The other run stores the same values but still holds the lock afterwards. + const sameValues = + '[core]\n\thooksPath = .vite-hooks/_\n[vp "hooks"]\n\tdir = .vite-hooks\n\tprefix = .\n'; + const writer = spawn(process.execPath, [ + '-e', + `setTimeout(() => require('node:fs').appendFileSync(${JSON.stringify(configPath)}, ${JSON.stringify(sameValues)}), 100)`, + ]); + expect(install()).toEqual({ message: '', isError: false }); + await once(writer, 'exit'); + + expect(existsSync(`${configPath}.lock`)).toBe(true); + expect(execSync('git config --local core.hooksPath', { cwd: tmp }).toString().trim()).toBe( + '.vite-hooks/_', + ); + } finally { + process.chdir(originalCwd); + rmSync(tmp, { recursive: true, force: true }); + } + }); + it.skipIf(process.platform === 'win32')( 'does not claim success over a worktree-scoped hooks path', () => { diff --git a/packages/cli/src/config/hooks.ts b/packages/cli/src/config/hooks.ts index b67603c4d6..5ea4863e74 100644 --- a/packages/cli/src/config/hooks.ts +++ b/packages/cli/src/config/hooks.ts @@ -224,19 +224,57 @@ function gitConfigLocalMissing(key: string): boolean { return spawnSync('git', ['config', '--local', '--get-all', key]).status === 1; } +// `git config` exits with 255 (CONFIG_NO_LOCK) when it cannot take `config.lock`. +// https://github.com/git/git/blob/47fdf2208c5b94c8cc01c106b8f7c85efb91c926/config.h#L30-L31 +const GIT_CONFIG_LOCK_STATUS = 255; + +// Git 2.56+ retries the lock for `core.configLockTimeout` (1s by default) and older Git +// does not retry. Turn off the Git retry so the wait below is the same on every version. +// https://git-scm.com/docs/git-config#Documentation/git-config.txt-coreconfigLockTimeout +const GIT_CONFIG_NO_LOCK_RETRY = ['-c', 'core.configLockTimeout=0']; + +// Doubling delays: a writer holds the lock for a few ms, so most runs finish after +// the first wait. The total (1.55s) is close to Git's own 1s default. +const GIT_CONFIG_LOCK_RETRY_DELAYS_MS = [50, 100, 200, 400, 800]; + +// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Atomics/wait +function sleepSync(ms: number): void { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +} + +// First runs in linked worktrees race for the shared lock. Every worktree writes +// the same values, so wait and stop as soon as another run has applied ours. +function writeGitConfig( + args: string[], + isApplied: () => boolean, +): { status: number | null; stderr: string } { + const gitArgs = [...GIT_CONFIG_NO_LOCK_RETRY, ...args]; + let result = spawnSync('git', gitArgs); + for (const delay of GIT_CONFIG_LOCK_RETRY_DELAYS_MS) { + if (result.status !== GIT_CONFIG_LOCK_STATUS) { + break; + } + sleepSync(delay); + if (isApplied()) { + return { status: 0, stderr: '' }; + } + result = spawnSync('git', gitArgs); + } + return { status: result.status, stderr: result.stderr?.toString().trim() ?? '' }; +} + function gitConfigSet(key: string, value: string): { ok: boolean; error?: string } { if (gitConfigLocalHas(key, value)) { return { ok: true }; } - const result = spawnSync('git', ['config', '--local', key, value]); + const result = writeGitConfig(['config', '--local', key, value], () => + gitConfigLocalHas(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: false, error: result.stderr || `failed to set ${key}` }; } return { ok: true }; } @@ -245,16 +283,15 @@ function gitConfigUnset(key: string): { ok: boolean; error?: string } { if (gitConfigLocalMissing(key)) { return { ok: true }; } - const result = spawnSync('git', ['config', '--local', '--unset-all', key]); + const result = writeGitConfig(['config', '--local', '--unset-all', key], () => + gitConfigLocalMissing(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: false, error: result.stderr || `failed to unset ${key}` }; } return { ok: true }; } @@ -575,14 +612,9 @@ export function install(dir?: string, options: InstallOptions = {}): InstallResu writeFileSync(internal(hook), HOOK_STUB_SCRIPT, { mode: 0o755 }); chmodSync(internal(hook), 0o755); } - if (!gitConfigLocalHas('core.hooksPath', location.target)) { - const { status, stderr } = spawnSync('git', ['config', 'core.hooksPath', location.target]); - if (status == null) { - return { message: 'git command not found', isError: true }; - } - if (status) { - return { message: '' + stderr, isError: true }; - } + const storeHooksPath = gitConfigSet('core.hooksPath', location.target); + if (!storeHooksPath.ok) { + return { message: storeHooksPath.error || 'failed to set core.hooksPath', isError: true }; } // Persist enabled state + directory for later enable/disable/status. From 64469e2711787aa41ffc92da963662b61f034800 Mon Sep 17 00:00:00 2001 From: naokihaba <59875779+naokihaba@users.noreply.github.com> Date: Fri, 23 Oct 2026 01:05:47 +0900 Subject: [PATCH 2/3] test(config): snapshot a first run that cannot take the config lock Record what `vp config` prints when another run holds `config.lock` for the whole first run: it retries, then exits with code 1 and the Git error. After the lock is released, the next run writes the git config. --- .../package.json | 3 ++ .../snapshots.toml | 11 ++++++ ...mmand_config_first_run_with_config_lock.md | 39 +++++++++++++++++++ 3 files changed, 53 insertions(+) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_config_first_run_with_config_lock/package.json create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_config_first_run_with_config_lock/snapshots.toml create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_config_first_run_with_config_lock/snapshots/command_config_first_run_with_config_lock.md diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_config_first_run_with_config_lock/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_config_first_run_with_config_lock/package.json new file mode 100644 index 0000000000..c402fe6e34 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_config_first_run_with_config_lock/package.json @@ -0,0 +1,3 @@ +{ + "name": "command-config-first-run-with-config-lock" +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_config_first_run_with_config_lock/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_config_first_run_with_config_lock/snapshots.toml new file mode 100644 index 0000000000..0f658a5f82 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_config_first_run_with_config_lock/snapshots.toml @@ -0,0 +1,11 @@ +[[case]] +name = "command_config_first_run_with_config_lock" +vp = "global" +steps = [ + { argv = ["git", "init"], snapshot = false, continue-on-failure = true }, + { argv = ["vpt", "touch-file", ".git/config.lock"], snapshot = false, comment = "a concurrent run in a linked worktree holds the shared config lock and does not release it" }, + { argv = ["vp", "config", "--no-agent"], tty = false, comment = "first run retries the lock, then reports the Git error", continue-on-failure = true }, + { argv = ["vpt", "rm", ".git/config.lock"], snapshot = false, comment = "the other run releases the lock" }, + { argv = ["vp", "config", "--no-agent"], tty = false, comment = "next run writes the git config", continue-on-failure = true }, + { argv = ["git", "config", "--local", "core.hooksPath"], comment = "should be .vite-hooks/_", continue-on-failure = true }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_config_first_run_with_config_lock/snapshots/command_config_first_run_with_config_lock.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_config_first_run_with_config_lock/snapshots/command_config_first_run_with_config_lock.md new file mode 100644 index 0000000000..2bee089095 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_config_first_run_with_config_lock/snapshots/command_config_first_run_with_config_lock.md @@ -0,0 +1,39 @@ +# command_config_first_run_with_config_lock + +## `git init` + + +## `vpt touch-file .git/config.lock` + +a concurrent run in a linked worktree holds the shared config lock and does not release it + + +## `vp config --no-agent` + +first run retries the lock, then reports the Git error + +**Exit code:** 1 + +``` +error: could not lock config file .git/config: File exists +``` + +## `vpt rm .git/config.lock` + +the other run releases the lock + + +## `vp config --no-agent` + +next run writes the git config + +``` +``` + +## `git config --local core.hooksPath` + +should be .vite-hooks/_ + +``` +.vite-hooks/_ +``` From 67a0671e9a5e63dc15034e8bfa664984dc22afd8 Mon Sep 17 00:00:00 2001 From: naokihaba <59875779+naokihaba@users.noreply.github.com> Date: Fri, 23 Oct 2026 01:23:01 +0900 Subject: [PATCH 3/3] fix(config): treat the 32-bit lock status from old Git for Windows as a lock failure Git returns CONFIG_NO_LOCK (-1) when it cannot take `config.lock`. Git 2.37+ keeps the low 8 bits on every platform, so the exit code is 255. Older Git for Windows can keep the full 32-bit value, 4294967295. Retry on both values. --- packages/cli/src/config/hooks.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/config/hooks.ts b/packages/cli/src/config/hooks.ts index 5ea4863e74..a1232af7e0 100644 --- a/packages/cli/src/config/hooks.ts +++ b/packages/cli/src/config/hooks.ts @@ -224,9 +224,12 @@ function gitConfigLocalMissing(key: string): boolean { return spawnSync('git', ['config', '--local', '--get-all', key]).status === 1; } -// `git config` exits with 255 (CONFIG_NO_LOCK) when it cannot take `config.lock`. +// `git config` exits with CONFIG_NO_LOCK (-1) when it cannot take `config.lock`. // https://github.com/git/git/blob/47fdf2208c5b94c8cc01c106b8f7c85efb91c926/config.h#L30-L31 -const GIT_CONFIG_LOCK_STATUS = 255; +// Git 2.37+ keeps the low 8 bits on every platform, so -1 becomes 255. Older Git for +// Windows can keep the full 32-bit value. +// https://github.com/git/git/blob/47fdf2208c5b94c8cc01c106b8f7c85efb91c926/common-exit.c#L14-L20 +const GIT_CONFIG_LOCK_STATUSES: ReadonlySet = new Set([255, 0xffffffff]); // Git 2.56+ retries the lock for `core.configLockTimeout` (1s by default) and older Git // does not retry. Turn off the Git retry so the wait below is the same on every version. @@ -251,7 +254,7 @@ function writeGitConfig( const gitArgs = [...GIT_CONFIG_NO_LOCK_RETRY, ...args]; let result = spawnSync('git', gitArgs); for (const delay of GIT_CONFIG_LOCK_RETRY_DELAYS_MS) { - if (result.status !== GIT_CONFIG_LOCK_STATUS) { + if (!GIT_CONFIG_LOCK_STATUSES.has(result.status)) { break; } sleepSync(delay);