- ContentInMain: HEAD ancestor of the default branch or squash-merged
(merge-tree == target tree), against local origin/<default> without fetch;
list_worktrees mergedIntoMain and the overview modal ("merged (external)")
use it, "Cleanup finished" takes clean Active rows that are in main
- cleanup_task_worktree skips the dirty check for a non-git directory
- prune / worktree remove / branch -D run in the repo owning the worktree
(git-common-dir), not the list's working dir; branch already gone reports
branchDeleted=true
347 lines
16 KiB
C#
347 lines
16 KiB
C#
using System.Collections.Concurrent;
|
|
using ClaudeDo.Data;
|
|
using ClaudeDo.Data.Git;
|
|
using ClaudeDo.Data.Models;
|
|
using ClaudeDo.Worker.Hub;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace ClaudeDo.Worker.Worktrees;
|
|
|
|
public sealed class WorktreeMaintenanceService
|
|
{
|
|
public sealed record CleanupResult(int Removed, IReadOnlyList<string> RemovedTaskIds);
|
|
public sealed record ResetResult(int Removed, int TasksAffected, bool Blocked, int RunningTasks, IReadOnlyList<string> RemovedTaskIds);
|
|
public sealed record ForceRemoveResult(bool Removed, string? Reason, bool BranchDeleted);
|
|
|
|
// opKey/phase for the OperationProgress broadcast. Not task-bound, so opKey is a stable
|
|
// string rather than a task id — mirrors "startup-recovery"/"worktree-cleanup" in HubBroadcaster.
|
|
public const string OpKey = "worktree-maintenance";
|
|
public const string PhaseMaintaining = "maintaining-worktrees";
|
|
|
|
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
|
private readonly GitService _git;
|
|
private readonly ILogger<WorktreeMaintenanceService> _logger;
|
|
private readonly HubBroadcaster? _broadcaster;
|
|
|
|
public WorktreeMaintenanceService(
|
|
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
|
GitService git,
|
|
ILogger<WorktreeMaintenanceService> logger,
|
|
HubBroadcaster? broadcaster = null)
|
|
{
|
|
_dbFactory = dbFactory;
|
|
_git = git;
|
|
_logger = logger;
|
|
_broadcaster = broadcaster;
|
|
}
|
|
|
|
private Task ReportProgress(int current, int total) =>
|
|
_broadcaster?.OperationProgress(OpKey, PhaseMaintaining, current, total) ?? Task.CompletedTask;
|
|
|
|
public async Task<CleanupResult> CleanupFinishedAsync(string? listId = null, CancellationToken ct = default)
|
|
{
|
|
using var context = _dbFactory.CreateDbContext();
|
|
var query = from w in context.Worktrees
|
|
join t in context.Tasks on w.TaskId equals t.Id
|
|
join l in context.Lists on t.ListId equals l.Id
|
|
where w.State == WorktreeState.Merged || w.State == WorktreeState.Discarded
|
|
|| (w.State == WorktreeState.Active && t.Status != ClaudeDo.Data.Models.TaskStatus.Running)
|
|
select new
|
|
{
|
|
Row = new WorktreeRow(w.TaskId, w.Path, w.BranchName, l.WorkingDir),
|
|
ListId = t.ListId, w.State, w.BaseCommit,
|
|
};
|
|
|
|
if (!string.IsNullOrEmpty(listId))
|
|
query = query.Where(x => x.ListId == listId);
|
|
|
|
var candidates = await query.AsNoTracking().ToListAsync(ct);
|
|
var rows = new List<WorktreeRow>();
|
|
foreach (var c in candidates)
|
|
{
|
|
// An Active row only counts as finished when it was merged outside ClaudeDo (e.g. a
|
|
// squash PR) and holds nothing uncommitted -- TryRemoveAsync would delete that too.
|
|
if (c.State != WorktreeState.Active
|
|
|| (IsGitWorktree(c.Row.Path)
|
|
&& await IsContentInMainAsync(c.Row.Path, c.BaseCommit, ct)
|
|
&& !await TryHasChangesAsync(c.Row.Path, ct)))
|
|
rows.Add(c.Row);
|
|
}
|
|
|
|
int removed = 0;
|
|
var removedTaskIds = new List<string>();
|
|
for (var i = 0; i < rows.Count; i++)
|
|
{
|
|
await ReportProgress(i + 1, rows.Count);
|
|
var (rowRemoved, _) = await TryRemoveAsync(rows[i], force: false, ct);
|
|
if (rowRemoved)
|
|
{
|
|
removed++;
|
|
removedTaskIds.Add(rows[i].TaskId);
|
|
}
|
|
}
|
|
return new CleanupResult(removed, removedTaskIds);
|
|
}
|
|
|
|
public async Task<ResetResult> ResetAllAsync(CancellationToken ct = default)
|
|
{
|
|
using var context = _dbFactory.CreateDbContext();
|
|
var running = await context.Tasks.AsNoTracking()
|
|
.CountAsync(t => t.Status == ClaudeDo.Data.Models.TaskStatus.Running, ct);
|
|
if (running > 0)
|
|
return new ResetResult(0, 0, Blocked: true, RunningTasks: running, Array.Empty<string>());
|
|
|
|
var rows = await (from w in context.Worktrees
|
|
join t in context.Tasks on w.TaskId equals t.Id
|
|
join l in context.Lists on t.ListId equals l.Id
|
|
select new WorktreeRow(w.TaskId, w.Path, w.BranchName, l.WorkingDir))
|
|
.AsNoTracking()
|
|
.ToListAsync(ct);
|
|
|
|
int removed = 0;
|
|
var removedTaskIds = new List<string>();
|
|
for (var i = 0; i < rows.Count; i++)
|
|
{
|
|
await ReportProgress(i + 1, rows.Count);
|
|
var (rowRemoved, _) = await TryRemoveAsync(rows[i], force: true, ct);
|
|
if (rowRemoved)
|
|
{
|
|
removed++;
|
|
removedTaskIds.Add(rows[i].TaskId);
|
|
}
|
|
}
|
|
return new ResetResult(removed, rows.Count, Blocked: false, RunningTasks: 0, removedTaskIds);
|
|
}
|
|
|
|
public async Task<IReadOnlyList<WorktreeOverviewRow>> GetOverviewAsync(
|
|
string? listId, CancellationToken ct = default)
|
|
{
|
|
using var context = _dbFactory.CreateDbContext();
|
|
var query = from w in context.Worktrees
|
|
join t in context.Tasks on w.TaskId equals t.Id
|
|
join l in context.Lists on t.ListId equals l.Id
|
|
select new
|
|
{
|
|
w.TaskId, t.Number, t.Title, t.Status, ListId = l.Id, ListName = l.Name,
|
|
w.Path, w.BranchName, w.BaseCommit, w.State, w.DiffStat, w.CreatedAt,
|
|
};
|
|
|
|
if (!string.IsNullOrEmpty(listId))
|
|
query = query.Where(x => x.ListId == listId);
|
|
|
|
var rows = await query.AsNoTracking().ToListAsync(ct);
|
|
|
|
return await Task.WhenAll(rows.Select(async x =>
|
|
{
|
|
var isGitWorktree = IsGitWorktree(x.Path);
|
|
var contentInMain = isGitWorktree
|
|
&& (x.State is WorktreeState.Active or WorktreeState.Kept)
|
|
&& await IsContentInMainAsync(x.Path, x.BaseCommit, ct);
|
|
return new WorktreeOverviewRow(
|
|
x.TaskId, x.Number, x.Title, x.Status, x.ListId, x.ListName,
|
|
x.Path, x.BranchName, x.BaseCommit ?? "", x.State, x.DiffStat, x.CreatedAt,
|
|
PathExistsOnDisk: !string.IsNullOrWhiteSpace(x.Path) && Directory.Exists(x.Path),
|
|
isGitWorktree, contentInMain);
|
|
}));
|
|
}
|
|
|
|
/// <summary>True when <paramref name="path"/> holds a git checkout (<c>.git</c> file or dir), not just a directory.</summary>
|
|
// A bare directory check isn't enough: git walks up from an empty dir into whatever repo encloses it.
|
|
internal static bool IsGitWorktree(string? path) =>
|
|
!string.IsNullOrWhiteSpace(path)
|
|
&& (File.Exists(Path.Combine(path, ".git")) || Directory.Exists(Path.Combine(path, ".git")));
|
|
|
|
// ponytail: 30s TTL per path -- the overview modal reloads every 4s and this costs ~5 git
|
|
// processes per row; key on (path, HEAD, target sha) if a stale answer ever matters.
|
|
private static readonly TimeSpan ContentInMainTtl = TimeSpan.FromSeconds(30);
|
|
private readonly ConcurrentDictionary<string, (DateTime At, bool Result)> _contentInMainCache = new();
|
|
|
|
/// <summary>
|
|
/// The worktree's own commits are already in the default branch -- HEAD is an ancestor of it, or
|
|
/// merging HEAD would not change its tree (squash PR). Checks local refs only, never fetches.
|
|
/// </summary>
|
|
private async Task<bool> IsContentInMainAsync(string path, string? baseCommit, CancellationToken ct)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(baseCommit)) return false;
|
|
if (_contentInMainCache.TryGetValue(path, out var hit) && DateTime.UtcNow - hit.At < ContentInMainTtl)
|
|
return hit.Result;
|
|
|
|
bool result;
|
|
try
|
|
{
|
|
// No commits beyond the base: trivially "in main", but there is nothing merged.
|
|
if (await _git.IsAncestorAsync(path, "HEAD", baseCommit, ct) != false) result = false;
|
|
else if (await ResolveDefaultRefAsync(path, ct) is not { } target) result = false;
|
|
else if (await _git.IsAncestorAsync(path, "HEAD", target, ct) == true) result = true;
|
|
else
|
|
{
|
|
var preview = await _git.PreviewMergeAsync(path, target, "HEAD", ct);
|
|
result = preview.Clean
|
|
&& preview.TreeOid == await _git.RevParseAsync(path, $"{target}^{{tree}}", ct);
|
|
}
|
|
}
|
|
catch (OperationCanceledException) { throw; }
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogDebug(ex, "content-in-main check failed for {Path}", path);
|
|
result = false;
|
|
}
|
|
|
|
_contentInMainCache[path] = (DateTime.UtcNow, result);
|
|
return result;
|
|
}
|
|
|
|
/// <summary>origin/HEAD's branch, else the first existing of origin/main, origin/master, main, master.</summary>
|
|
private async Task<string?> ResolveDefaultRefAsync(string dir, CancellationToken ct)
|
|
{
|
|
var remoteHead = await _git.GetRemoteHeadBranchAsync(dir, ct);
|
|
string[] candidates = remoteHead is null
|
|
? ["origin/main", "origin/master", "main", "master"]
|
|
: [$"origin/{remoteHead}"];
|
|
foreach (var c in candidates)
|
|
{
|
|
try { await _git.RevParseAsync(dir, $"{c}^{{commit}}", ct); return c; }
|
|
catch (InvalidOperationException) { }
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private async Task<bool> TryHasChangesAsync(string path, CancellationToken ct)
|
|
{
|
|
// Can't tell -> treat as dirty, so a bulk cleanup never deletes it.
|
|
try { return await _git.HasChangesAsync(path, ct); }
|
|
catch (OperationCanceledException) { throw; }
|
|
catch { return true; }
|
|
}
|
|
|
|
/// <summary>The repo owning the worktree (parent of its git-common-dir); the list's working dir when the path is no git checkout anymore.</summary>
|
|
private async Task<string?> ResolveOwnerRepoAsync(WorktreeRow row, CancellationToken ct)
|
|
{
|
|
if (!IsGitWorktree(row.Path)) return row.WorkingDir;
|
|
var commonDir = await _git.GetCommonDirAsync(row.Path, ct);
|
|
if (commonDir is null) return row.WorkingDir;
|
|
var full = Path.TrimEndingDirectorySeparator(Path.GetFullPath(commonDir));
|
|
// Non-bare repo: .../<repo>/.git -> <repo>. A bare repo's common dir is the repo itself.
|
|
return Path.GetFileName(full).Equals(".git", StringComparison.OrdinalIgnoreCase)
|
|
? Path.GetDirectoryName(full)
|
|
: full;
|
|
}
|
|
|
|
public async Task<ForceRemoveResult> ForceRemoveAsync(string taskId, CancellationToken ct = default)
|
|
{
|
|
using var context = _dbFactory.CreateDbContext();
|
|
|
|
var row = await (from w in context.Worktrees
|
|
join t in context.Tasks on w.TaskId equals t.Id
|
|
join l in context.Lists on t.ListId equals l.Id
|
|
where w.TaskId == taskId
|
|
select new { Row = new WorktreeRow(w.TaskId, w.Path, w.BranchName, l.WorkingDir),
|
|
Status = t.Status })
|
|
.AsNoTracking()
|
|
.FirstOrDefaultAsync(ct);
|
|
|
|
if (row is null)
|
|
return new ForceRemoveResult(false, "worktree not found", false);
|
|
|
|
if (row.Status == ClaudeDo.Data.Models.TaskStatus.Running)
|
|
return new ForceRemoveResult(false, "task is currently running", false);
|
|
|
|
var (ok, branchDeleted) = await TryRemoveAsync(row.Row, force: true, ct);
|
|
return new ForceRemoveResult(ok, ok ? null : "remove failed", branchDeleted);
|
|
}
|
|
|
|
private async Task<(bool Removed, bool BranchDeleted)> TryRemoveAsync(WorktreeRow row, bool force, CancellationToken ct)
|
|
{
|
|
// The list's working dir isn't necessarily the repo that owns the worktree (a list can point
|
|
// at another repo than the task ran in) -- prune/branch -D there would silently miss.
|
|
var repoDir = await ResolveOwnerRepoAsync(row, ct);
|
|
var repoDirExists = !string.IsNullOrWhiteSpace(repoDir) && Directory.Exists(repoDir);
|
|
bool dirRemoved;
|
|
|
|
if (repoDirExists)
|
|
{
|
|
dirRemoved = true;
|
|
try
|
|
{
|
|
await _git.WorktreeRemoveAsync(repoDir!, row.Path, force, ct);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// "is not a working tree" means git already forgot this worktree (pruned, or
|
|
// removed by hand). Deleting the directory is then the whole job, not a fallback
|
|
// from a real failure -- warning about it floods the footer log strip when a
|
|
// batch of stale rows is cleaned up.
|
|
if (IsAlreadyUnregistered(ex))
|
|
_logger.LogDebug(ex, "git no longer tracks {Path}; deleting the directory", row.Path);
|
|
else
|
|
_logger.LogWarning(ex,
|
|
"git worktree remove failed for {Path}; falling back to directory delete", row.Path);
|
|
try { if (Directory.Exists(row.Path)) Directory.Delete(row.Path, recursive: true); }
|
|
catch (Exception delEx)
|
|
{
|
|
_logger.LogError(delEx, "Directory.Delete fallback also failed for {Path}", row.Path);
|
|
dirRemoved = false;
|
|
}
|
|
}
|
|
if (Directory.Exists(row.Path)) dirRemoved = false;
|
|
}
|
|
else
|
|
{
|
|
dirRemoved = true;
|
|
try { if (Directory.Exists(row.Path)) Directory.Delete(row.Path, recursive: true); }
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Directory.Delete failed for {Path}", row.Path);
|
|
dirRemoved = false;
|
|
}
|
|
}
|
|
|
|
// Drop the DB row only when the on-disk worktree is gone; otherwise we'd silently
|
|
// strand a directory while reporting success.
|
|
if (!dirRemoved) return (false, false);
|
|
|
|
// Branch cleanup: otherwise rerunning the task hits "branch already exists".
|
|
// Prune first so git no longer thinks the branch is checked out by a phantom worktree.
|
|
bool branchDeleted = false;
|
|
if (repoDirExists)
|
|
{
|
|
try { await _git.WorktreePruneAsync(repoDir!, ct); }
|
|
catch (Exception ex) { _logger.LogWarning(ex, "git worktree prune failed for {Repo}", repoDir); }
|
|
|
|
if (!string.IsNullOrWhiteSpace(row.BranchName))
|
|
{
|
|
try
|
|
{
|
|
await _git.BranchDeleteAsync(repoDir!, row.BranchName, force: true, ct);
|
|
branchDeleted = true;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// Already deleted -- the normal case once the branch has been merged.
|
|
if (IsBranchAlreadyGone(ex))
|
|
{
|
|
_logger.LogDebug(ex, "branch {Branch} was already deleted", row.BranchName);
|
|
branchDeleted = true;
|
|
}
|
|
else
|
|
_logger.LogWarning(ex, "Failed to delete branch {Branch} for worktree {Path}",
|
|
row.BranchName, row.Path);
|
|
}
|
|
}
|
|
}
|
|
|
|
using var context = _dbFactory.CreateDbContext();
|
|
await context.Worktrees.Where(w => w.TaskId == row.TaskId).ExecuteDeleteAsync(ct);
|
|
return (true, branchDeleted);
|
|
}
|
|
|
|
// Both are "already done" outcomes, not failures — see the call sites.
|
|
private static bool IsAlreadyUnregistered(Exception ex) =>
|
|
ex.Message.Contains("is not a working tree", StringComparison.OrdinalIgnoreCase);
|
|
|
|
private static bool IsBranchAlreadyGone(Exception ex) =>
|
|
ex.Message.Contains("' not found", StringComparison.Ordinal);
|
|
|
|
private sealed record WorktreeRow(string TaskId, string Path, string BranchName, string? WorkingDir);
|
|
}
|