perf(worker): read untracked-worktree sweep from .git instead of spawning git per list

This commit is contained in:
mika kuns
2026-10-02 15:37:35 +02:00
parent a75acbb2c0
commit 59d990c267
2 changed files with 74 additions and 9 deletions
@@ -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<ClaudeDoDbContext> _dbFactory;
private readonly GitService _git;
private readonly HubBroadcaster _broadcaster;
private readonly WorkerConfig _cfg;
private readonly ILogger<UntrackedWorktreeRecovery> _logger;
public UntrackedWorktreeRecovery(
IDbContextFactory<ClaudeDoDbContext> dbFactory,
GitService git,
HubBroadcaster broadcaster,
WorkerConfig cfg,
ILogger<UntrackedWorktreeRecovery> 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;
/// <summary>
/// Linked worktrees of the repo at <paramref name="repoDir"/>, read straight from <c>.git/worktrees/*</c>
/// (what <c>git worktree list</c> reports minus the main checkout); Branch is null when detached.
/// </summary>
// 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: <path>" 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;
}
/// <summary>Registered worktree paths not present among <paramref name="dbPaths"/>, comparing normalized full paths.</summary>
public static List<string> FindUntrackedPaths(IEnumerable<string> registeredPaths, IEnumerable<string> dbPaths)
{
@@ -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<UntrackedWorktreeRecovery>.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()));
}
}