refactor(worker): detect untracked worktrees by path instead of branch prefix
This commit is contained in:
@@ -1,36 +1,38 @@
|
|||||||
using ClaudeDo.Data;
|
using ClaudeDo.Data;
|
||||||
using ClaudeDo.Data.Git;
|
using ClaudeDo.Data.Git;
|
||||||
using ClaudeDo.Data.Repositories;
|
using ClaudeDo.Data.Repositories;
|
||||||
|
using ClaudeDo.Worker.Config;
|
||||||
using ClaudeDo.Worker.Hub;
|
using ClaudeDo.Worker.Hub;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace ClaudeDo.Worker.Lifecycle;
|
namespace ClaudeDo.Worker.Lifecycle;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Startup-only sweep: warns when a list's repo has git-registered <c>claudedo/*</c> worktrees
|
/// Startup-only sweep: warns when a list's repo has git-registered ClaudeDo task worktrees (under
|
||||||
/// that the <c>worktrees</c> table doesn't know about (e.g. left behind by a manual cleanup
|
/// <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.
|
/// outside the app). Detection only — nothing is removed or pruned.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class UntrackedWorktreeRecovery : IHostedService
|
public sealed class UntrackedWorktreeRecovery : IHostedService
|
||||||
{
|
{
|
||||||
public const string Phase = "untracked-worktrees";
|
public const string Phase = "untracked-worktrees";
|
||||||
|
|
||||||
private const string BranchPrefix = "claudedo/";
|
|
||||||
|
|
||||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||||
private readonly GitService _git;
|
private readonly GitService _git;
|
||||||
private readonly HubBroadcaster _broadcaster;
|
private readonly HubBroadcaster _broadcaster;
|
||||||
|
private readonly WorkerConfig _cfg;
|
||||||
private readonly ILogger<UntrackedWorktreeRecovery> _logger;
|
private readonly ILogger<UntrackedWorktreeRecovery> _logger;
|
||||||
|
|
||||||
public UntrackedWorktreeRecovery(
|
public UntrackedWorktreeRecovery(
|
||||||
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
||||||
GitService git,
|
GitService git,
|
||||||
HubBroadcaster broadcaster,
|
HubBroadcaster broadcaster,
|
||||||
|
WorkerConfig cfg,
|
||||||
ILogger<UntrackedWorktreeRecovery> logger)
|
ILogger<UntrackedWorktreeRecovery> logger)
|
||||||
{
|
{
|
||||||
_dbFactory = dbFactory;
|
_dbFactory = dbFactory;
|
||||||
_git = git;
|
_git = git;
|
||||||
_broadcaster = broadcaster;
|
_broadcaster = broadcaster;
|
||||||
|
_cfg = cfg;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,6 +43,10 @@ public sealed class UntrackedWorktreeRecovery : IHostedService
|
|||||||
var dbPaths = (await new WorktreeRepository(ctx).GetAllAsync(cancellationToken))
|
var dbPaths = (await new WorktreeRepository(ctx).GetAllAsync(cancellationToken))
|
||||||
.Select(w => w.Path)
|
.Select(w => w.Path)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
var settings = await new AppSettingsRepository(ctx).GetAsync(cancellationToken);
|
||||||
|
var centralRoot = !string.IsNullOrWhiteSpace(settings.CentralWorktreeRoot)
|
||||||
|
? settings.CentralWorktreeRoot
|
||||||
|
: _cfg.CentralWorktreeRoot;
|
||||||
|
|
||||||
var foundAny = false;
|
var foundAny = false;
|
||||||
foreach (var list in lists.Where(l => !string.IsNullOrWhiteSpace(l.WorkingDir)))
|
foreach (var list in lists.Where(l => !string.IsNullOrWhiteSpace(l.WorkingDir)))
|
||||||
@@ -57,7 +63,7 @@ public sealed class UntrackedWorktreeRecovery : IHostedService
|
|||||||
}
|
}
|
||||||
|
|
||||||
var claudedoPaths = registered
|
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);
|
.Select(w => w.Path);
|
||||||
|
|
||||||
var untracked = FindUntrackedPaths(claudedoPaths, dbPaths);
|
var untracked = FindUntrackedPaths(claudedoPaths, dbPaths);
|
||||||
@@ -84,6 +90,26 @@ public sealed class UntrackedWorktreeRecovery : IHostedService
|
|||||||
return registeredPaths.Where(p => !known.Contains(Normalize(p))).ToList();
|
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) =>
|
private static string Normalize(string path) =>
|
||||||
Path.GetFullPath(path).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
Path.GetFullPath(path).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,18 @@ public sealed class UntrackedWorktreeRecoveryTests : IDisposable
|
|||||||
Assert.Equal(registered, untracked);
|
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]
|
[Fact]
|
||||||
public async Task StartAsync_NoLists_DoesNotThrow()
|
public async Task StartAsync_NoLists_DoesNotThrow()
|
||||||
{
|
{
|
||||||
@@ -62,6 +74,7 @@ public sealed class UntrackedWorktreeRecoveryTests : IDisposable
|
|||||||
_db.CreateFactory(),
|
_db.CreateFactory(),
|
||||||
new ClaudeDo.Data.Git.GitService(),
|
new ClaudeDo.Data.Git.GitService(),
|
||||||
new HubBroadcaster(hubContext),
|
new HubBroadcaster(hubContext),
|
||||||
|
new ClaudeDo.Worker.Config.WorkerConfig(),
|
||||||
NullLogger<UntrackedWorktreeRecovery>.Instance);
|
NullLogger<UntrackedWorktreeRecovery>.Instance);
|
||||||
|
|
||||||
await sut.StartAsync(CancellationToken.None);
|
await sut.StartAsync(CancellationToken.None);
|
||||||
|
|||||||
Reference in New Issue
Block a user