From b672c9aaf33f6931537bc7fc55b47b4a9b38385d Mon Sep 17 00:00:00 2001 From: mika kuns Date: Tue, 9 Jun 2026 09:55:39 +0200 Subject: [PATCH] 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//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) --- src/ClaudeDo.Data/Git/GitService.cs | 33 +++++++++++++++++++++++++---- 1 file changed, 29 insertions(+), 4 deletions(-) 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)