chore(claude-do): merge feat(worktrees): warn about git worktrees ClaudeDo doesn't k

ClaudeDo-Task: 765fed13-db66-44e4-9285-c4b2d415b443
This commit is contained in:
Mika Kuns
2026-09-02 13:01:57 +02:00
4 changed files with 195 additions and 0 deletions
+32
View File
@@ -338,6 +338,38 @@ public sealed class GitService
return paths;
}
/// <summary>All git-registered worktrees for <paramref name="repoDir"/>, with their branch (short name, null if detached).</summary>
public async Task<List<(string Path, string? Branch)>> 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);
@@ -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;
/// <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);
}
+1
View File
@@ -77,6 +77,7 @@ builder.Services.AddHostedService<OrphanRecovery>();
builder.Services.AddHostedService<AttachmentOrphanRecovery>();
builder.Services.AddHostedService<PromptFileRecovery>();
builder.Services.AddHostedService<LegacyWorktreeFolderRecovery>();
builder.Services.AddHostedService<UntrackedWorktreeRecovery>();
builder.Services.AddSignalR().AddJsonProtocol(options =>
{
options.PayloadSerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter());
@@ -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<string>());
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<UntrackedWorktreeRecovery>.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");
}
}