From `docs/open.md` (re-verified against the code 2026-09-02, still open): nothing reconciles `git worktree list` against the `worktrees` table. `ExternalMcpService.ListWorktrees` and the Worktrees overview read DB rows only; `WorktreeManager` calls git solely inside its self-heal path (`ListWorktreePathsForBranchAsync`). Observed once: 22 git-registered worktrees + 26 `claudedo/*` branches in `C: ClaudeDo-Task: 765fed13-db66-44e4-9285-c4b2d415b443
90 lines
3.6 KiB
C#
90 lines
3.6 KiB
C#
using ClaudeDo.Data;
|
|
using ClaudeDo.Data.Git;
|
|
using ClaudeDo.Data.Repositories;
|
|
using ClaudeDo.Worker.Hub;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace ClaudeDo.Worker.Lifecycle;
|
|
|
|
/// <summary>
|
|
/// Startup-only sweep: warns when a list's repo has git-registered <c>claudedo/*</c> worktrees
|
|
/// that the <c>worktrees</c> table doesn't know about (e.g. left behind by a manual cleanup
|
|
/// outside the app). Detection only — nothing is removed or pruned.
|
|
/// </summary>
|
|
public sealed class UntrackedWorktreeRecovery : IHostedService
|
|
{
|
|
public const string Phase = "untracked-worktrees";
|
|
|
|
private const string BranchPrefix = "claudedo/";
|
|
|
|
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
|
private readonly GitService _git;
|
|
private readonly HubBroadcaster _broadcaster;
|
|
private readonly ILogger<UntrackedWorktreeRecovery> _logger;
|
|
|
|
public UntrackedWorktreeRecovery(
|
|
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
|
GitService git,
|
|
HubBroadcaster broadcaster,
|
|
ILogger<UntrackedWorktreeRecovery> 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;
|
|
|
|
/// <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)
|
|
{
|
|
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);
|
|
}
|