diff --git a/src/ClaudeDo.Worker/Lifecycle/UntrackedWorktreeRecovery.cs b/src/ClaudeDo.Worker/Lifecycle/UntrackedWorktreeRecovery.cs
index d97ae76e..399826ae 100644
--- a/src/ClaudeDo.Worker/Lifecycle/UntrackedWorktreeRecovery.cs
+++ b/src/ClaudeDo.Worker/Lifecycle/UntrackedWorktreeRecovery.cs
@@ -1,36 +1,38 @@
using ClaudeDo.Data;
using ClaudeDo.Data.Git;
using ClaudeDo.Data.Repositories;
+using ClaudeDo.Worker.Config;
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
+/// Startup-only sweep: warns when a list's repo has git-registered ClaudeDo task worktrees (under
+/// .claudedo-worktrees or the central root, planning sessions excluded) 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 WorkerConfig _cfg;
private readonly ILogger _logger;
public UntrackedWorktreeRecovery(
IDbContextFactory dbFactory,
GitService git,
HubBroadcaster broadcaster,
+ WorkerConfig cfg,
ILogger logger)
{
_dbFactory = dbFactory;
_git = git;
_broadcaster = broadcaster;
+ _cfg = cfg;
_logger = logger;
}
@@ -41,6 +43,10 @@ public sealed class UntrackedWorktreeRecovery : IHostedService
var dbPaths = (await new WorktreeRepository(ctx).GetAllAsync(cancellationToken))
.Select(w => w.Path)
.ToList();
+ var settings = await new AppSettingsRepository(ctx).GetAsync(cancellationToken);
+ var centralRoot = !string.IsNullOrWhiteSpace(settings.CentralWorktreeRoot)
+ ? settings.CentralWorktreeRoot
+ : _cfg.CentralWorktreeRoot;
var foundAny = false;
foreach (var list in lists.Where(l => !string.IsNullOrWhiteSpace(l.WorkingDir)))
@@ -57,7 +63,7 @@ public sealed class UntrackedWorktreeRecovery : IHostedService
}
var claudedoPaths = registered
- .Where(w => w.Branch is not null && w.Branch.StartsWith(BranchPrefix, StringComparison.Ordinal))
+ .Where(w => w.Branch is not null && IsTaskWorktreePath(w.Path, centralRoot))
.Select(w => w.Path);
var untracked = FindUntrackedPaths(claudedoPaths, dbPaths);
@@ -84,6 +90,26 @@ public sealed class UntrackedWorktreeRecovery : IHostedService
return registeredPaths.Where(p => !known.Contains(Normalize(p))).ToList();
}
+ /// True für einen Pfad unter .claudedo-worktrees oder dem Central-Root, außer Planning-Session-Worktrees.
+ public static bool IsTaskWorktreePath(string path, string centralRoot)
+ {
+ var sep = Path.DirectorySeparatorChar;
+ var full = Normalize(path);
+ string? rest = null;
+
+ var central = Normalize(centralRoot);
+ if (full.StartsWith(central + sep, StringComparison.OrdinalIgnoreCase))
+ rest = full[(central.Length + 1)..];
+ else
+ {
+ var marker = $"{sep}.claudedo-worktrees{sep}";
+ var idx = full.IndexOf(marker, StringComparison.OrdinalIgnoreCase);
+ if (idx >= 0) rest = full[(idx + marker.Length)..];
+ }
+
+ return rest is not null && !rest.StartsWith($"planning{sep}", StringComparison.OrdinalIgnoreCase);
+ }
+
private static string Normalize(string path) =>
Path.GetFullPath(path).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
}
diff --git a/tests/ClaudeDo.Worker.Tests/Lifecycle/UntrackedWorktreeRecoveryTests.cs b/tests/ClaudeDo.Worker.Tests/Lifecycle/UntrackedWorktreeRecoveryTests.cs
index a0d2561d..1aad8725 100644
--- a/tests/ClaudeDo.Worker.Tests/Lifecycle/UntrackedWorktreeRecoveryTests.cs
+++ b/tests/ClaudeDo.Worker.Tests/Lifecycle/UntrackedWorktreeRecoveryTests.cs
@@ -54,6 +54,18 @@ public sealed class UntrackedWorktreeRecoveryTests : IDisposable
Assert.Equal(registered, untracked);
}
+ [Theory]
+ [InlineData(@"C:\src\.claudedo-worktrees\lager-app\412-fix", @"C:\Users\x\.claudeDo\worktrees", true)]
+ [InlineData(@"C:\src\.claudedo-worktrees\planning\abc123", @"C:\Users\x\.claudeDo\worktrees", false)]
+ [InlineData(@"C:\Users\x\.claudeDo\worktrees\lager-app\412-fix", @"C:\Users\x\.claudeDo\worktrees", true)]
+ [InlineData(@"C:\Users\x\.claudeDo\worktrees\planning\abc123", @"C:\Users\x\.claudeDo\worktrees", false)]
+ [InlineData(@"C:\src\my-own-worktree", @"C:\Users\x\.claudeDo\worktrees", false)]
+ public void IsTaskWorktreePath(string path, string centralRoot, bool expected)
+ {
+ if (!OperatingSystem.IsWindows()) return;
+ Assert.Equal(expected, UntrackedWorktreeRecovery.IsTaskWorktreePath(path, centralRoot));
+ }
+
[Fact]
public async Task StartAsync_NoLists_DoesNotThrow()
{
@@ -62,6 +74,7 @@ public sealed class UntrackedWorktreeRecoveryTests : IDisposable
_db.CreateFactory(),
new ClaudeDo.Data.Git.GitService(),
new HubBroadcaster(hubContext),
+ new ClaudeDo.Worker.Config.WorkerConfig(),
NullLogger.Instance);
await sut.StartAsync(CancellationToken.None);