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:
mika kuns
2026-06-09 09:55:39 +02:00
parent 384e058812
commit b672c9aaf3

View File

@@ -7,6 +7,11 @@ public sealed record MergePreview(bool Supported, bool Clean, IReadOnlyList<stri
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)
{
var (exitCode, _, _) = await RunGitAsync(dir, ["rev-parse", "--git-dir"], ct);
@@ -22,11 +27,31 @@ public sealed class GitService
}
public async Task WorktreeAddAsync(string repoDir, string branchName, string worktreePath, string baseCommit, CancellationToken ct = default)
{
await WorktreeAddGate.WaitAsync(ct);
try
{
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)
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)