From 8911408bca34bf1e92d8b7236d4b336b19c01358 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Thu, 1 Oct 2026 10:37:37 +0200 Subject: [PATCH] refactor(worker): detect untracked worktrees by path instead of branch prefix --- .../Lifecycle/UntrackedWorktreeRecovery.cs | 36 ++++++++++++++++--- .../UntrackedWorktreeRecoveryTests.cs | 13 +++++++ 2 files changed, 44 insertions(+), 5 deletions(-) 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);