refactor(worker): detect untracked worktrees by path instead of branch prefix

This commit is contained in:
mika kuns
2026-10-01 10:37:37 +02:00
parent 814b083a4e
commit 8911408bca
2 changed files with 44 additions and 5 deletions
@@ -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;
/// <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
/// Startup-only sweep: warns when a list's repo has git-registered ClaudeDo task worktrees (under
/// <c>.claudedo-worktrees</c> or the central root, planning sessions excluded) 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 WorkerConfig _cfg;
private readonly ILogger<UntrackedWorktreeRecovery> _logger;
public UntrackedWorktreeRecovery(
IDbContextFactory<ClaudeDoDbContext> dbFactory,
GitService git,
HubBroadcaster broadcaster,
WorkerConfig cfg,
ILogger<UntrackedWorktreeRecovery> 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();
}
/// <summary>True für einen Pfad unter <c>.claudedo-worktrees</c> oder dem Central-Root, außer Planning-Session-Worktrees.</summary>
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);
}
@@ -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<UntrackedWorktreeRecovery>.Instance);
await sut.StartAsync(CancellationToken.None);