diff --git a/src/ClaudeDo.Data/Git/GitService.cs b/src/ClaudeDo.Data/Git/GitService.cs index 1b43c45..7ba2cf9 100644 --- a/src/ClaudeDo.Data/Git/GitService.cs +++ b/src/ClaudeDo.Data/Git/GitService.cs @@ -7,6 +7,11 @@ public sealed record MergePreview(bool Supported, bool Clean, IReadOnlyList/commondir". Serialize them + // process-wide so parallel task starts don't collide. + private static readonly SemaphoreSlim WorktreeAddGate = new(1, 1); + public async Task IsGitRepoAsync(string dir, CancellationToken ct = default) { 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) { - var (exitCode, _, stderr) = await RunGitAsync(repoDir, - ["worktree", "add", "-b", branchName, worktreePath, baseCommit], ct); - if (exitCode != 0) - throw new InvalidOperationException($"git worktree add failed (exit {exitCode}): {stderr}"); + 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) + 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 GetStatusPorcelainAsync(string workingDirectory, CancellationToken ct = default)