perf(worker): read untracked-worktree sweep from .git instead of spawning git per list
This commit is contained in:
@@ -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()));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user