From 59d990c26732e78f61acae30e2d0d7e3b83763a9 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Fri, 2 Oct 2026 15:37:35 +0200 Subject: [PATCH] perf(worker): read untracked-worktree sweep from .git instead of spawning git per list --- .../Lifecycle/UntrackedWorktreeRecovery.cs | 54 ++++++++++++++++--- .../UntrackedWorktreeRecoveryTests.cs | 29 +++++++++- 2 files changed, 74 insertions(+), 9 deletions(-) diff --git a/src/ClaudeDo.Worker/Lifecycle/UntrackedWorktreeRecovery.cs b/src/ClaudeDo.Worker/Lifecycle/UntrackedWorktreeRecovery.cs index 399826ae..66302e87 100644 --- a/src/ClaudeDo.Worker/Lifecycle/UntrackedWorktreeRecovery.cs +++ b/src/ClaudeDo.Worker/Lifecycle/UntrackedWorktreeRecovery.cs @@ -1,5 +1,4 @@ using ClaudeDo.Data; -using ClaudeDo.Data.Git; using ClaudeDo.Data.Repositories; using ClaudeDo.Worker.Config; using ClaudeDo.Worker.Hub; @@ -17,20 +16,17 @@ public sealed class UntrackedWorktreeRecovery : IHostedService public const string Phase = "untracked-worktrees"; private readonly IDbContextFactory _dbFactory; - private readonly GitService _git; private readonly HubBroadcaster _broadcaster; private readonly WorkerConfig _cfg; private readonly ILogger _logger; public UntrackedWorktreeRecovery( IDbContextFactory dbFactory, - GitService git, HubBroadcaster broadcaster, WorkerConfig cfg, ILogger logger) { _dbFactory = dbFactory; - _git = git; _broadcaster = broadcaster; _cfg = cfg; _logger = logger; @@ -49,16 +45,20 @@ public sealed class UntrackedWorktreeRecovery : IHostedService : _cfg.CentralWorktreeRoot; var foundAny = false; - foreach (var list in lists.Where(l => !string.IsNullOrWhiteSpace(l.WorkingDir))) + var workingDirs = lists + .Where(l => !string.IsNullOrWhiteSpace(l.WorkingDir)) + .Select(l => l.WorkingDir!) + .DistinctBy(Normalize, StringComparer.OrdinalIgnoreCase); + foreach (var workingDir in workingDirs) { List<(string Path, string? Branch)> registered; try { - registered = await _git.ListWorktreesAsync(list.WorkingDir!, cancellationToken); + registered = ReadLinkedWorktrees(workingDir); } catch (Exception ex) { - _logger.LogWarning(ex, "Untracked worktree recovery: git worktree list failed for {WorkingDir}", list.WorkingDir); + _logger.LogWarning(ex, "Untracked worktree recovery: reading worktrees failed for {WorkingDir}", workingDir); continue; } @@ -72,7 +72,7 @@ public sealed class UntrackedWorktreeRecovery : IHostedService foundAny = true; _logger.LogWarning( "Untracked worktree recovery: found {Count} git worktree(s) under '{Repo}' that ClaudeDo's database doesn't know about; not touched", - untracked.Count, list.WorkingDir); + untracked.Count, workingDir); } if (!foundAny) @@ -83,6 +83,44 @@ public sealed class UntrackedWorktreeRecovery : IHostedService public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + /// + /// Linked worktrees of the repo at , read straight from .git/worktrees/* + /// (what git worktree list reports minus the main checkout); Branch is null when detached. + /// + // Read from disk instead of spawning git: one git + wrapper + conhost per list made the worker's startup footprint ~50 processes. + public static List<(string Path, string? Branch)> ReadLinkedWorktrees(string repoDir) + { + var result = new List<(string Path, string? Branch)>(); + var dotGit = Path.Combine(repoDir, ".git"); + string commonDir; + if (Directory.Exists(dotGit)) + commonDir = dotGit; + else if (File.Exists(dotGit)) + { + // repoDir is itself a linked worktree: ".git" is a "gitdir: " file, commondir points back to the shared .git. + var gitDir = Path.GetFullPath(File.ReadAllText(dotGit).Trim()["gitdir:".Length..].Trim(), repoDir); + var commonFile = Path.Combine(gitDir, "commondir"); + commonDir = File.Exists(commonFile) ? Path.GetFullPath(File.ReadAllText(commonFile).Trim(), gitDir) : gitDir; + } + else return result; + + var entries = Path.Combine(commonDir, "worktrees"); + if (!Directory.Exists(entries)) return result; + + foreach (var entry in Directory.EnumerateDirectories(entries)) + { + var gitdirFile = Path.Combine(entry, "gitdir"); + if (!File.Exists(gitdirFile)) continue; + var path = Path.GetDirectoryName(Path.GetFullPath(File.ReadAllText(gitdirFile).Trim()))!; + + var headFile = Path.Combine(entry, "HEAD"); + var head = File.Exists(headFile) ? File.ReadAllText(headFile).Trim() : ""; + const string refPrefix = "ref: refs/heads/"; + result.Add((path, head.StartsWith(refPrefix, StringComparison.Ordinal) ? head[refPrefix.Length..] : null)); + } + return result; + } + /// Registered worktree paths not present among , comparing normalized full paths. public static List FindUntrackedPaths(IEnumerable registeredPaths, IEnumerable dbPaths) { diff --git a/tests/ClaudeDo.Worker.Tests/Lifecycle/UntrackedWorktreeRecoveryTests.cs b/tests/ClaudeDo.Worker.Tests/Lifecycle/UntrackedWorktreeRecoveryTests.cs index 1aad8725..a480fb02 100644 --- a/tests/ClaudeDo.Worker.Tests/Lifecycle/UntrackedWorktreeRecoveryTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Lifecycle/UntrackedWorktreeRecoveryTests.cs @@ -72,7 +72,6 @@ public sealed class UntrackedWorktreeRecoveryTests : IDisposable var hubContext = new CapturingHubContext(); var sut = new UntrackedWorktreeRecovery( _db.CreateFactory(), - new ClaudeDo.Data.Git.GitService(), new HubBroadcaster(hubContext), new ClaudeDo.Worker.Config.WorkerConfig(), NullLogger.Instance); @@ -83,4 +82,32 @@ public sealed class UntrackedWorktreeRecoveryTests : IDisposable Assert.Contains(hubContext.Proxy.Calls, c => c.Method == "OperationProgress" && (string)c.Args[0]! == "startup-recovery"); } + + [Fact] + public void ReadLinkedWorktrees_MatchesGitWorktreeList() + { + if (!GitRepoFixture.IsGitAvailable()) return; + using var repo = new GitRepoFixture(); + var parent = Path.GetDirectoryName(repo.RepoDir)!; + var onBranch = Path.Combine(parent, "wt-branch"); + var detached = Path.Combine(parent, "wt-detached"); + GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", "feature/1-x", onBranch); + GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "--detach", detached); + + var fromMain = UntrackedWorktreeRecovery.ReadLinkedWorktrees(repo.RepoDir); + var fromLinked = UntrackedWorktreeRecovery.ReadLinkedWorktrees(onBranch); + + foreach (var list in new[] { fromMain, fromLinked }) + { + Assert.Equal(2, list.Count); + Assert.Contains(list, w => string.Equals(w.Path, Path.GetFullPath(onBranch), StringComparison.OrdinalIgnoreCase) && w.Branch == "feature/1-x"); + Assert.Contains(list, w => string.Equals(w.Path, Path.GetFullPath(detached), StringComparison.OrdinalIgnoreCase) && w.Branch is null); + } + } + + [Fact] + public void ReadLinkedWorktrees_NotARepo_ReturnsEmpty() + { + Assert.Empty(UntrackedWorktreeRecovery.ReadLinkedWorktrees(Path.GetTempPath())); + } }