fix(worktrees): detect external merges, clean up empty dirs, prune in owning repo

- 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
This commit is contained in:
mika kuns
2026-10-02 09:07:43 +02:00
parent 9c015f34cc
commit 0c0bb7f24c
12 changed files with 333 additions and 22 deletions
@@ -1,3 +1,4 @@
using System.Collections.Concurrent;
using ClaudeDo.Data;
using ClaudeDo.Data.Git;
using ClaudeDo.Data.Models;
@@ -44,12 +45,28 @@ public sealed class WorktreeMaintenanceService
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
select new { Row = new WorktreeRow(w.TaskId, w.Path, w.BranchName, l.WorkingDir), ListId = t.ListId };
|| (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 rows = await query.AsNoTracking().Select(x => x.Row).ToListAsync(ct);
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>();
@@ -114,10 +131,100 @@ public sealed class WorktreeMaintenanceService
var rows = await query.AsNoTracking().ToListAsync(ct);
return rows.Select(x => 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))).ToList();
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)
@@ -145,7 +252,10 @@ public sealed class WorktreeMaintenanceService
private async Task<(bool Removed, bool BranchDeleted)> TryRemoveAsync(WorktreeRow row, bool force, CancellationToken ct)
{
var repoDirExists = !string.IsNullOrWhiteSpace(row.WorkingDir) && Directory.Exists(row.WorkingDir);
// 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)
@@ -153,7 +263,7 @@ public sealed class WorktreeMaintenanceService
dirRemoved = true;
try
{
await _git.WorktreeRemoveAsync(row.WorkingDir!, row.Path, force, ct);
await _git.WorktreeRemoveAsync(repoDir!, row.Path, force, ct);
}
catch (Exception ex)
{
@@ -195,21 +305,24 @@ public sealed class WorktreeMaintenanceService
bool branchDeleted = false;
if (repoDirExists)
{
try { await _git.WorktreePruneAsync(row.WorkingDir!, ct); }
catch (Exception ex) { _logger.LogWarning(ex, "git worktree prune failed for {Repo}", row.WorkingDir); }
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(row.WorkingDir!, row.BranchName, force: true, ct);
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);