diff --git a/src/ClaudeDo.Data/Git/GitService.cs b/src/ClaudeDo.Data/Git/GitService.cs index eb3febbc..b2c00879 100644 --- a/src/ClaudeDo.Data/Git/GitService.cs +++ b/src/ClaudeDo.Data/Git/GitService.cs @@ -338,6 +338,38 @@ public sealed class GitService return paths; } + /// All git-registered worktrees for , with their branch (short name, null if detached). + public async Task> ListWorktreesAsync(string repoDir, CancellationToken ct = default) + { + var (exitCode, stdout, _) = await RunGitAsync(repoDir, ["worktree", "list", "--porcelain"], ct); + if (exitCode != 0) return new(); + + var result = new List<(string Path, string? Branch)>(); + string? currentPath = null; + string? currentBranch = null; + foreach (var raw in stdout.Split('\n')) + { + var line = raw.TrimEnd('\r'); + if (line.StartsWith("worktree ", StringComparison.Ordinal)) + { + currentPath = line["worktree ".Length..].Trim(); + } + else if (line.StartsWith("branch ", StringComparison.Ordinal)) + { + var b = line["branch ".Length..].Trim(); + currentBranch = b.StartsWith("refs/heads/", StringComparison.Ordinal) ? b["refs/heads/".Length..] : b; + } + else if (string.IsNullOrWhiteSpace(line)) + { + if (currentPath is not null) result.Add((currentPath, currentBranch)); + currentPath = null; + currentBranch = null; + } + } + if (currentPath is not null) result.Add((currentPath, currentBranch)); + return result; + } + public async Task WorktreePruneAsync(string repoDir, CancellationToken ct = default) { var (exitCode, _, stderr) = await RunGitAsync(repoDir, ["worktree", "prune"], ct); diff --git a/src/ClaudeDo.Worker/Lifecycle/UntrackedWorktreeRecovery.cs b/src/ClaudeDo.Worker/Lifecycle/UntrackedWorktreeRecovery.cs new file mode 100644 index 00000000..d97ae76e --- /dev/null +++ b/src/ClaudeDo.Worker/Lifecycle/UntrackedWorktreeRecovery.cs @@ -0,0 +1,89 @@ +using ClaudeDo.Data; +using ClaudeDo.Data.Git; +using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.Hub; +using Microsoft.EntityFrameworkCore; + +namespace ClaudeDo.Worker.Lifecycle; + +/// +/// Startup-only sweep: warns when a list's repo has git-registered claudedo/* worktrees +/// that the worktrees table doesn't know about (e.g. left behind by a manual cleanup +/// outside the app). Detection only — nothing is removed or pruned. +/// +public sealed class UntrackedWorktreeRecovery : IHostedService +{ + public const string Phase = "untracked-worktrees"; + + private const string BranchPrefix = "claudedo/"; + + private readonly IDbContextFactory _dbFactory; + private readonly GitService _git; + private readonly HubBroadcaster _broadcaster; + private readonly ILogger _logger; + + public UntrackedWorktreeRecovery( + IDbContextFactory dbFactory, + GitService git, + HubBroadcaster broadcaster, + ILogger logger) + { + _dbFactory = dbFactory; + _git = git; + _broadcaster = broadcaster; + _logger = logger; + } + + public async Task StartAsync(CancellationToken cancellationToken) + { + await using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken); + var lists = await new ListRepository(ctx).GetAllAsync(cancellationToken); + var dbPaths = (await new WorktreeRepository(ctx).GetAllAsync(cancellationToken)) + .Select(w => w.Path) + .ToList(); + + var foundAny = false; + foreach (var list in lists.Where(l => !string.IsNullOrWhiteSpace(l.WorkingDir))) + { + List<(string Path, string? Branch)> registered; + try + { + registered = await _git.ListWorktreesAsync(list.WorkingDir!, cancellationToken); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Untracked worktree recovery: git worktree list failed for {WorkingDir}", list.WorkingDir); + continue; + } + + var claudedoPaths = registered + .Where(w => w.Branch is not null && w.Branch.StartsWith(BranchPrefix, StringComparison.Ordinal)) + .Select(w => w.Path); + + var untracked = FindUntrackedPaths(claudedoPaths, dbPaths); + if (untracked.Count == 0) continue; + + 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); + } + + if (!foundAny) + _logger.LogInformation("Untracked worktree recovery: no untracked worktrees found"); + + await _broadcaster.OperationProgress(OperationProgressOpKeys.StartupRecovery, Phase, lists.Count, lists.Count); + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + /// Registered worktree paths not present among , comparing normalized full paths. + public static List FindUntrackedPaths(IEnumerable registeredPaths, IEnumerable dbPaths) + { + var known = dbPaths.Select(Normalize).ToHashSet(StringComparer.OrdinalIgnoreCase); + return registeredPaths.Where(p => !known.Contains(Normalize(p))).ToList(); + } + + private static string Normalize(string path) => + Path.GetFullPath(path).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); +} diff --git a/src/ClaudeDo.Worker/Program.cs b/src/ClaudeDo.Worker/Program.cs index 3d28bc59..3c9f2bd9 100644 --- a/src/ClaudeDo.Worker/Program.cs +++ b/src/ClaudeDo.Worker/Program.cs @@ -77,6 +77,7 @@ builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); +builder.Services.AddHostedService(); builder.Services.AddSignalR().AddJsonProtocol(options => { options.PayloadSerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter()); diff --git a/tests/ClaudeDo.Worker.Tests/Lifecycle/UntrackedWorktreeRecoveryTests.cs b/tests/ClaudeDo.Worker.Tests/Lifecycle/UntrackedWorktreeRecoveryTests.cs new file mode 100644 index 00000000..a0d2561d --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Lifecycle/UntrackedWorktreeRecoveryTests.cs @@ -0,0 +1,73 @@ +using ClaudeDo.Worker.Hub; +using ClaudeDo.Worker.Lifecycle; +using ClaudeDo.Worker.Tests.Infrastructure; +using Microsoft.Extensions.Logging.Abstractions; + +namespace ClaudeDo.Worker.Tests.Lifecycle; + +public sealed class UntrackedWorktreeRecoveryTests : IDisposable +{ + private readonly DbFixture _db = new(); + + public void Dispose() => _db.Dispose(); + + [Fact] + public void FindUntrackedPaths_PathNotInDb_IsReturned() + { + var registered = new[] { @"C:\repo\.worktrees\abc123" }; + var db = new[] { @"C:\repo\.worktrees\def456" }; + + var untracked = UntrackedWorktreeRecovery.FindUntrackedPaths(registered, db); + + Assert.Equal(registered, untracked); + } + + [Fact] + public void FindUntrackedPaths_PathInDb_IsExcluded() + { + var registered = new[] { @"C:\repo\.worktrees\abc123" }; + var db = new[] { @"C:\repo\.worktrees\abc123" }; + + var untracked = UntrackedWorktreeRecovery.FindUntrackedPaths(registered, db); + + Assert.Empty(untracked); + } + + [Fact] + public void FindUntrackedPaths_DifferentCasingAndSeparators_StillMatches() + { + var registered = new[] { @"C:\repo\.worktrees\abc123\" }; + var db = new[] { "c:/repo/.worktrees/ABC123" }; + + var untracked = UntrackedWorktreeRecovery.FindUntrackedPaths(registered, db); + + Assert.Empty(untracked); + } + + [Fact] + public void FindUntrackedPaths_EmptyDb_ReturnsAllRegistered() + { + var registered = new[] { @"C:\repo\.worktrees\abc123", @"C:\repo\.worktrees\def456" }; + + var untracked = UntrackedWorktreeRecovery.FindUntrackedPaths(registered, Array.Empty()); + + Assert.Equal(registered, untracked); + } + + [Fact] + public async Task StartAsync_NoLists_DoesNotThrow() + { + var hubContext = new CapturingHubContext(); + var sut = new UntrackedWorktreeRecovery( + _db.CreateFactory(), + new ClaudeDo.Data.Git.GitService(), + new HubBroadcaster(hubContext), + NullLogger.Instance); + + await sut.StartAsync(CancellationToken.None); + await sut.StopAsync(CancellationToken.None); + + Assert.Contains(hubContext.Proxy.Calls, c => + c.Method == "OperationProgress" && (string)c.Args[0]! == "startup-recovery"); + } +}