chore(claude-do): merge feat(worktrees): warn about git worktrees ClaudeDo doesn't k
ClaudeDo-Task: 765fed13-db66-44e4-9285-c4b2d415b443
This commit is contained in:
@@ -338,6 +338,38 @@ public sealed class GitService
|
||||
return paths;
|
||||
}
|
||||
|
||||
/// <summary>All git-registered worktrees for <paramref name="repoDir"/>, with their branch (short name, null if detached).</summary>
|
||||
public async Task<List<(string Path, string? Branch)>> 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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <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
|
||||
/// 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 ILogger<UntrackedWorktreeRecovery> _logger;
|
||||
|
||||
public UntrackedWorktreeRecovery(
|
||||
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
||||
GitService git,
|
||||
HubBroadcaster broadcaster,
|
||||
ILogger<UntrackedWorktreeRecovery> 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;
|
||||
|
||||
/// <summary>Registered worktree paths not present among <paramref name="dbPaths"/>, comparing normalized full paths.</summary>
|
||||
public static List<string> FindUntrackedPaths(IEnumerable<string> registeredPaths, IEnumerable<string> 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);
|
||||
}
|
||||
@@ -77,6 +77,7 @@ builder.Services.AddHostedService<OrphanRecovery>();
|
||||
builder.Services.AddHostedService<AttachmentOrphanRecovery>();
|
||||
builder.Services.AddHostedService<PromptFileRecovery>();
|
||||
builder.Services.AddHostedService<LegacyWorktreeFolderRecovery>();
|
||||
builder.Services.AddHostedService<UntrackedWorktreeRecovery>();
|
||||
builder.Services.AddSignalR().AddJsonProtocol(options =>
|
||||
{
|
||||
options.PayloadSerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter());
|
||||
|
||||
Reference in New Issue
Block a user