Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"name": "command-config-first-run-with-config-lock"
}
Original file line number Diff line number Diff line change
@@ -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 },
]
Original file line number Diff line number Diff line change
@@ -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/_
```
61 changes: 60 additions & 1 deletion packages/cli/src/config/__tests__/hooks.spec.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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',
() => {
Expand Down
71 changes: 53 additions & 18 deletions packages/cli/src/config/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,19 +224,60 @@ function gitConfigLocalMissing(key: string): boolean {
return spawnSync('git', ['config', '--local', '--get-all', key]).status === 1;
}

// `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
// 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<number | null> = 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.
// 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 (!GIT_CONFIG_LOCK_STATUSES.has(result.status)) {
break;
}
sleepSync(delay);
if (isApplied()) {
return { status: 0, stderr: '' };
}
result = spawnSync('git', gitArgs);
Comment thread
naokihaba marked this conversation as resolved.
}
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 };
}
Expand All @@ -245,16 +286,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 };
}
Expand Down Expand Up @@ -575,14 +615,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.
Expand Down
Loading