fix(git): serialize concurrent worktree add to prevent commondir race
Parallel task starts called 'git worktree add' simultaneously; git's shared .git/worktrees metadata mutation isn't concurrency-safe and one add failed with 'failed to read .git/worktrees/<other>/commondir'. Serialize adds behind a process-wide gate plus a bounded retry on the transient error. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,11 @@ public sealed record MergePreview(bool Supported, bool Clean, IReadOnlyList<stri
|
|||||||
|
|
||||||
public sealed class GitService
|
public sealed class GitService
|
||||||
{
|
{
|
||||||
|
// git mutates shared .git/worktrees/ metadata during `worktree add`; concurrent adds
|
||||||
|
// race and fail with "failed to read .git/worktrees/<other>/commondir". Serialize them
|
||||||
|
// process-wide so parallel task starts don't collide.
|
||||||
|
private static readonly SemaphoreSlim WorktreeAddGate = new(1, 1);
|
||||||
|
|
||||||
public async Task<bool> IsGitRepoAsync(string dir, CancellationToken ct = default)
|
public async Task<bool> IsGitRepoAsync(string dir, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var (exitCode, _, _) = await RunGitAsync(dir, ["rev-parse", "--git-dir"], ct);
|
var (exitCode, _, _) = await RunGitAsync(dir, ["rev-parse", "--git-dir"], ct);
|
||||||
@@ -23,10 +28,30 @@ public sealed class GitService
|
|||||||
|
|
||||||
public async Task WorktreeAddAsync(string repoDir, string branchName, string worktreePath, string baseCommit, CancellationToken ct = default)
|
public async Task WorktreeAddAsync(string repoDir, string branchName, string worktreePath, string baseCommit, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var (exitCode, _, stderr) = await RunGitAsync(repoDir,
|
await WorktreeAddGate.WaitAsync(ct);
|
||||||
["worktree", "add", "-b", branchName, worktreePath, baseCommit], ct);
|
try
|
||||||
if (exitCode != 0)
|
{
|
||||||
throw new InvalidOperationException($"git worktree add failed (exit {exitCode}): {stderr}");
|
const int maxAttempts = 3;
|
||||||
|
for (var attempt = 1; ; attempt++)
|
||||||
|
{
|
||||||
|
var (exitCode, _, stderr) = await RunGitAsync(repoDir,
|
||||||
|
["worktree", "add", "-b", branchName, worktreePath, baseCommit], ct);
|
||||||
|
if (exitCode == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Transient races leave a half-written worktree metadata dir; retry briefly.
|
||||||
|
var transient = stderr.Contains("commondir", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| stderr.Contains("failed to read", StringComparison.OrdinalIgnoreCase);
|
||||||
|
if (!transient || attempt >= maxAttempts)
|
||||||
|
throw new InvalidOperationException($"git worktree add failed (exit {exitCode}): {stderr}");
|
||||||
|
|
||||||
|
await Task.Delay(150 * attempt, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
WorktreeAddGate.Release();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<string> GetStatusPorcelainAsync(string workingDirectory, CancellationToken ct = default)
|
public async Task<string> GetStatusPorcelainAsync(string workingDirectory, CancellationToken ct = default)
|
||||||
|
|||||||
Reference in New Issue
Block a user