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); }