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