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