chore(claude-do): merge fix(worktrees): Merge-Erkennung, Cleanup leerer Verzeichniss

ClaudeDo-Task: fad77fdc-d166-41a5-971c-fac16d84ab2c
This commit is contained in:
mika kuns
2026-10-02 09:14:23 +02:00
12 changed files with 333 additions and 22 deletions
+7
View File
@@ -489,6 +489,13 @@ public sealed class GitService
return head.StartsWith("origin/", StringComparison.Ordinal) ? head["origin/".Length..] : head; return head.StartsWith("origin/", StringComparison.Ordinal) ? head["origin/".Length..] : head;
} }
/// <summary>Absolute path of the <c>.git</c> dir shared by all worktrees of the repo owning <paramref name="dir"/>, or null.</summary>
public async Task<string?> GetCommonDirAsync(string dir, CancellationToken ct = default)
{
var (exitCode, stdout, _) = await RunGitAsync(dir, ["rev-parse", "--path-format=absolute", "--git-common-dir"], ct);
return exitCode == 0 && !string.IsNullOrWhiteSpace(stdout) ? stdout.Trim() : null;
}
/// <summary><c>git merge --ff-only</c>: moves the checked-out branch to <paramref name="sourceRef"/> or fails without touching it.</summary> /// <summary><c>git merge --ff-only</c>: moves the checked-out branch to <paramref name="sourceRef"/> or fails without touching it.</summary>
public async Task<(int ExitCode, string Stderr)> MergeFastForwardOnlyAsync( public async Task<(int ExitCode, string Stderr)> MergeFastForwardOnlyAsync(
string worktreePath, string sourceRef, CancellationToken ct = default) string worktreePath, string sourceRef, CancellationToken ct = default)
+2 -1
View File
@@ -88,7 +88,8 @@ public record WorktreeOverviewDto(
string? DiffStat, string? DiffStat,
DateTime CreatedAt, DateTime CreatedAt,
bool PathExistsOnDisk, bool PathExistsOnDisk,
int TaskNumber = 0); int TaskNumber = 0,
bool ContentInMain = false);
public record ForceRemoveResultDto(bool Removed, string? Reason); public record ForceRemoveResultDto(bool Removed, string? Reason);
@@ -555,6 +555,8 @@
"resolve": "Lösen", "resolve": "Lösen",
"phantom": "Phantom", "phantom": "Phantom",
"phantomTooltip": "Verzeichnis fehlt auf der Festplatte", "phantomTooltip": "Verzeichnis fehlt auf der Festplatte",
"mergedExternal": "gemergt (extern)",
"mergedExternalTooltip": "Die Commits sind schon im Default-Branch (lokaler origin-Stand, ohne Fetch), z.B. per Squash-PR. \"Abgeschlossene aufräumen\" entfernt ihn.",
"ctxShowDiff": "Diff anzeigen", "ctxShowDiff": "Diff anzeigen",
"ctxOpenInExplorer": "Im Explorer öffnen", "ctxOpenInExplorer": "Im Explorer öffnen",
"ctxJumpToTask": "Zur Aufgabe springen", "ctxJumpToTask": "Zur Aufgabe springen",
@@ -555,6 +555,8 @@
"resolve": "Resolve", "resolve": "Resolve",
"phantom": "phantom", "phantom": "phantom",
"phantomTooltip": "Directory missing on disk", "phantomTooltip": "Directory missing on disk",
"mergedExternal": "merged (external)",
"mergedExternalTooltip": "Its commits are already in the default branch (local origin ref, not fetched) — e.g. a squash PR. \"Cleanup finished\" removes it.",
"ctxShowDiff": "Show diff", "ctxShowDiff": "Show diff",
"ctxOpenInExplorer": "Open in Explorer", "ctxOpenInExplorer": "Open in Explorer",
"ctxJumpToTask": "Jump to task", "ctxJumpToTask": "Jump to task",
@@ -33,6 +33,8 @@ public sealed partial class WorktreeOverviewRowViewModel : ViewModelBase
[ObservableProperty] private string? _diffStat; [ObservableProperty] private string? _diffStat;
[ObservableProperty][NotifyPropertyChangedFor(nameof(AgeText))] private DateTime _createdAt; [ObservableProperty][NotifyPropertyChangedFor(nameof(AgeText))] private DateTime _createdAt;
[ObservableProperty] private bool _pathExistsOnDisk; [ObservableProperty] private bool _pathExistsOnDisk;
/// <summary>Not Merged in ClaudeDo, but the branch's commits are already in the default branch (e.g. a squash PR).</summary>
[ObservableProperty] private bool _contentInMain;
[ObservableProperty] private bool _isSelected; [ObservableProperty] private bool _isSelected;
[ObservableProperty] private bool _isChecked; [ObservableProperty] private bool _isChecked;
[ObservableProperty] [ObservableProperty]
@@ -374,6 +376,7 @@ public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase
ListId = d.ListId, ListName = d.ListName, ListId = d.ListId, ListName = d.ListName,
Path = d.Path, BranchName = d.BranchName, BaseCommit = d.BaseCommit, State = d.State, Path = d.Path, BranchName = d.BranchName, BaseCommit = d.BaseCommit, State = d.State,
DiffStat = d.DiffStat, CreatedAt = d.CreatedAt, PathExistsOnDisk = d.PathExistsOnDisk, DiffStat = d.DiffStat, CreatedAt = d.CreatedAt, PathExistsOnDisk = d.PathExistsOnDisk,
ContentInMain = d.ContentInMain,
}; };
public IEnumerable<WorktreeOverviewRowViewModel> AllRows => public IEnumerable<WorktreeOverviewRowViewModel> AllRows =>
@@ -77,6 +77,11 @@
<TextBlock Classes="meta" Text="{loc:Tr modals.worktreesOverview.phantom}" Foreground="{DynamicResource StatusErrorBrush}" <TextBlock Classes="meta" Text="{loc:Tr modals.worktreesOverview.phantom}" Foreground="{DynamicResource StatusErrorBrush}"
IsVisible="{Binding !PathExistsOnDisk}" IsVisible="{Binding !PathExistsOnDisk}"
ToolTip.Tip="{loc:Tr modals.worktreesOverview.phantomTooltip}"/> ToolTip.Tip="{loc:Tr modals.worktreesOverview.phantomTooltip}"/>
<TextBlock Classes="meta" Text="•"
IsVisible="{Binding ContentInMain}"/>
<TextBlock Classes="meta" Text="{loc:Tr modals.worktreesOverview.mergedExternal}" Foreground="{DynamicResource StatusDoneBrush}"
IsVisible="{Binding ContentInMain}"
ToolTip.Tip="{loc:Tr modals.worktreesOverview.mergedExternalTooltip}"/>
</StackPanel> </StackPanel>
</StackPanel> </StackPanel>
<TextBlock Grid.Column="2" Classes="meta" VerticalAlignment="Center" <TextBlock Grid.Column="2" Classes="meta" VerticalAlignment="Center"
+13 -8
View File
@@ -23,7 +23,7 @@ public sealed record TaskDiffDto(
public sealed record WorktreeListItemDto( public sealed record WorktreeListItemDto(
string? TaskId, int? Number, string Path, string Branch, string? TaskId, int? Number, string Path, string Branch,
string HeadCommit, bool IsDirty, bool MergedIntoMain); string HeadCommit, bool IsDirty, bool MergedIntoMain, bool ContentInMain = false);
public sealed record CleanupWorktreeResult( public sealed record CleanupWorktreeResult(
bool Removed, string WorktreePath, bool BranchDeleted, int? Number = null); bool Removed, string WorktreePath, bool BranchDeleted, int? Number = null);
@@ -152,27 +152,31 @@ public sealed class WorktreeMcpTools
[McpServerTool, Description( [McpServerTool, Description(
"Survey every worktree ClaudeDo tracks — use it to find leftovers to clean up. Only worktrees recorded in " + "Survey every worktree ClaudeDo tracks — use it to find leftovers to clean up. Only worktrees recorded in " +
"the ClaudeDo database appear here, and headCommit is empty when the path is missing from disk." + "the ClaudeDo database appear here, and headCommit is empty when the path is missing from disk or is not a " +
"git worktree. contentInMain: the branch's own commits are already in the default branch (merged, " +
"fast-forwarded or squash-merged outside ClaudeDo), checked against the LOCAL origin/<default> (else " +
"main/master) without fetching -- fetch first for a current answer. mergedIntoMain = merged via ClaudeDo " +
"or contentInMain." +
McpToolDocs.ProgressHint)] McpToolDocs.ProgressHint)]
public async Task<IReadOnlyList<WorktreeListItemDto>> ListWorktrees( public async Task<IReadOnlyList<WorktreeListItemDto>> ListWorktrees(
CancellationToken cancellationToken = default, CancellationToken cancellationToken = default,
IProgress<ProgressNotificationValue>? progress = null) IProgress<ProgressNotificationValue>? progress = null)
{ {
var rows = await _maintenance.GetOverviewAsync(null, cancellationToken); var rows = await _maintenance.GetOverviewAsync(null, cancellationToken);
// One git status + one rev-parse per row, run concurrently -- with many tracked worktrees // One git status + one rev-parse per row (on top of GetOverviewAsync's content-in-main check), run concurrently -- with many tracked worktrees
// (this tool's whole point is surfacing leftovers nobody cleaned up) that can still take a // (this tool's whole point is surfacing leftovers nobody cleaned up) that can still take a
// while, so this reports on the same elapsed-time cadence as a single long git call rather // while, so this reports on the same elapsed-time cadence as a single long git call rather
// than per-row (rows finish out of order under Task.WhenAll, so there's no natural i/n). // than per-row (rows finish out of order under Task.WhenAll, so there's no natural i/n).
var results = await ProgressReporter.RunAsync( var results = await ProgressReporter.RunAsync(
Task.WhenAll(rows.Select(async row => Task.WhenAll(rows.Select(async row =>
{ {
var isDirty = row.PathExistsOnDisk && await TryGetIsDirtyAsync(row.Path, cancellationToken); var isDirty = row.IsGitWorktree && await TryGetIsDirtyAsync(row.Path, cancellationToken);
var headCommit = row.PathExistsOnDisk var headCommit = row.IsGitWorktree
? (await TryRunGitAsync(row.Path, ["rev-parse", "HEAD"], cancellationToken) ?? "") ? (await TryRunGitAsync(row.Path, ["rev-parse", "HEAD"], cancellationToken) ?? "")
: ""; : "";
return new WorktreeListItemDto( return new WorktreeListItemDto(
row.TaskId, row.TaskNumber, row.Path, row.BranchName, headCommit, row.TaskId, row.TaskNumber, row.Path, row.BranchName, headCommit,
isDirty, row.State == WorktreeState.Merged); isDirty, row.State == WorktreeState.Merged || row.ContentInMain, row.ContentInMain);
})), })),
ProgressReportInterval, progress, "surveying worktrees"); ProgressReportInterval, progress, "surveying worktrees");
return results; return results;
@@ -180,7 +184,7 @@ public sealed class WorktreeMcpTools
[McpServerTool, Description( [McpServerTool, Description(
"Remove a task's worktree directory and delete its git branch. branchDeleted reports whether the branch " + "Remove a task's worktree directory and delete its git branch. branchDeleted reports whether the branch " +
"went too." + McpToolDocs.NotWhileRunning)] "is gone afterwards (also true when it was already deleted)." + McpToolDocs.NotWhileRunning)]
public async Task<CleanupWorktreeResult> CleanupTaskWorktree( public async Task<CleanupWorktreeResult> CleanupTaskWorktree(
string taskId, string taskId,
[Description("false (default): refuse a worktree with uncommitted changes. true: remove it anyway, losing " + [Description("false (default): refuse a worktree with uncommitted changes. true: remove it anyway, losing " +
@@ -200,7 +204,8 @@ public sealed class WorktreeMcpTools
if (task.Status == TaskStatus.Running) if (task.Status == TaskStatus.Running)
throw new InvalidOperationException("Cannot remove worktree of a running task."); throw new InvalidOperationException("Cannot remove worktree of a running task.");
if (!force && Directory.Exists(wt.Path)) // Only a real checkout can be dirty; git status in an empty leftover dir fails (or walks up into an enclosing repo).
if (!force && WorktreeMaintenanceService.IsGitWorktree(wt.Path))
{ {
var isDirty = await ProgressReporter.RunAsync( var isDirty = await ProgressReporter.RunAsync(
_git.HasChangesAsync(wt.Path, cancellationToken), ProgressReportInterval, progress, "checking worktree for changes"); _git.HasChangesAsync(wt.Path, cancellationToken), ProgressReportInterval, progress, "checking worktree for changes");
+1 -1
View File
@@ -467,7 +467,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
return rows.Select(r => new WorktreeOverviewDto( return rows.Select(r => new WorktreeOverviewDto(
r.TaskId, r.TaskTitle, r.TaskStatus, r.ListId, r.ListName, r.TaskId, r.TaskTitle, r.TaskStatus, r.ListId, r.ListName,
r.Path, r.BranchName, r.BaseCommit, r.State, r.DiffStat, r.CreatedAt, r.PathExistsOnDisk, r.Path, r.BranchName, r.BaseCommit, r.State, r.DiffStat, r.CreatedAt, r.PathExistsOnDisk,
r.TaskNumber)).ToList(); r.TaskNumber, r.ContentInMain)).ToList();
} }
public async Task<bool> SetWorktreeState(string taskId, WorktreeState newState) public async Task<bool> SetWorktreeState(string taskId, WorktreeState newState)
@@ -1,3 +1,4 @@
using System.Collections.Concurrent;
using ClaudeDo.Data; using ClaudeDo.Data;
using ClaudeDo.Data.Git; using ClaudeDo.Data.Git;
using ClaudeDo.Data.Models; using ClaudeDo.Data.Models;
@@ -44,12 +45,28 @@ public sealed class WorktreeMaintenanceService
join t in context.Tasks on w.TaskId equals t.Id join t in context.Tasks on w.TaskId equals t.Id
join l in context.Lists on t.ListId equals l.Id join l in context.Lists on t.ListId equals l.Id
where w.State == WorktreeState.Merged || w.State == WorktreeState.Discarded 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)) if (!string.IsNullOrEmpty(listId))
query = query.Where(x => x.ListId == 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; int removed = 0;
var removedTaskIds = new List<string>(); var removedTaskIds = new List<string>();
@@ -114,10 +131,100 @@ public sealed class WorktreeMaintenanceService
var rows = await query.AsNoTracking().ToListAsync(ct); var rows = await query.AsNoTracking().ToListAsync(ct);
return rows.Select(x => new WorktreeOverviewRow( return await Task.WhenAll(rows.Select(async x =>
x.TaskId, x.Number, x.Title, x.Status, x.ListId, x.ListName, {
x.Path, x.BranchName, x.BaseCommit ?? "", x.State, x.DiffStat, x.CreatedAt, var isGitWorktree = IsGitWorktree(x.Path);
PathExistsOnDisk: !string.IsNullOrWhiteSpace(x.Path) && Directory.Exists(x.Path))).ToList(); 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) 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) 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; bool dirRemoved;
if (repoDirExists) if (repoDirExists)
@@ -153,7 +263,7 @@ public sealed class WorktreeMaintenanceService
dirRemoved = true; dirRemoved = true;
try try
{ {
await _git.WorktreeRemoveAsync(row.WorkingDir!, row.Path, force, ct); await _git.WorktreeRemoveAsync(repoDir!, row.Path, force, ct);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -195,21 +305,24 @@ public sealed class WorktreeMaintenanceService
bool branchDeleted = false; bool branchDeleted = false;
if (repoDirExists) if (repoDirExists)
{ {
try { await _git.WorktreePruneAsync(row.WorkingDir!, ct); } try { await _git.WorktreePruneAsync(repoDir!, ct); }
catch (Exception ex) { _logger.LogWarning(ex, "git worktree prune failed for {Repo}", row.WorkingDir); } catch (Exception ex) { _logger.LogWarning(ex, "git worktree prune failed for {Repo}", repoDir); }
if (!string.IsNullOrWhiteSpace(row.BranchName)) if (!string.IsNullOrWhiteSpace(row.BranchName))
{ {
try try
{ {
await _git.BranchDeleteAsync(row.WorkingDir!, row.BranchName, force: true, ct); await _git.BranchDeleteAsync(repoDir!, row.BranchName, force: true, ct);
branchDeleted = true; branchDeleted = true;
} }
catch (Exception ex) catch (Exception ex)
{ {
// Already deleted -- the normal case once the branch has been merged. // Already deleted -- the normal case once the branch has been merged.
if (IsBranchAlreadyGone(ex)) if (IsBranchAlreadyGone(ex))
{
_logger.LogDebug(ex, "branch {Branch} was already deleted", row.BranchName); _logger.LogDebug(ex, "branch {Branch} was already deleted", row.BranchName);
branchDeleted = true;
}
else else
_logger.LogWarning(ex, "Failed to delete branch {Branch} for worktree {Path}", _logger.LogWarning(ex, "Failed to delete branch {Branch} for worktree {Path}",
row.BranchName, row.Path); row.BranchName, row.Path);
@@ -3,6 +3,9 @@ using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Worker.Worktrees; namespace ClaudeDo.Worker.Worktrees;
/// <summary>One tracked worktree with its task/list context and on-disk facts.</summary>
/// <param name="IsGitWorktree">The path holds a git checkout (<c>.git</c> present), not just a directory.</param>
/// <param name="ContentInMain">Own commits are already in the local default branch (merged, fast-forwarded or squashed) — independent of <paramref name="State"/>.</param>
public sealed record WorktreeOverviewRow( public sealed record WorktreeOverviewRow(
string TaskId, string TaskId,
int TaskNumber, int TaskNumber,
@@ -16,4 +19,6 @@ public sealed record WorktreeOverviewRow(
WorktreeState State, WorktreeState State,
string? DiffStat, string? DiffStat,
DateTime CreatedAt, DateTime CreatedAt,
bool PathExistsOnDisk); bool PathExistsOnDisk,
bool IsGitWorktree = false,
bool ContentInMain = false);
@@ -489,4 +489,45 @@ public sealed class WorktreeMcpToolsTests : IDisposable
WorktreeMcpTools.ProgressReportInterval = original; WorktreeMcpTools.ProgressReportInterval = original;
} }
} }
[Fact]
public async Task CleanupTaskWorktree_EmptyDirectory_RemovesDbRow()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var (task, list, wt) = await SeedWorktreeAsync(TaskStatus.Done);
// Leave an empty, non-git directory behind where the worktree was.
GitRepoFixture.RunGit(list.WorkingDir!, "worktree", "remove", "--force", wt.WorktreePath);
Directory.CreateDirectory(wt.WorktreePath);
var sut = BuildSut(CreateQueue());
var rows = await sut.ListWorktrees(CancellationToken.None);
Assert.Equal("", rows.Single(r => r.TaskId == task.Id).HeadCommit);
var result = await sut.CleanupTaskWorktree(task.Id, false, CancellationToken.None);
Assert.True(result.Removed);
Assert.False(Directory.Exists(wt.WorktreePath));
using var ctx = _db.CreateContext();
Assert.Null(await new WorktreeRepository(ctx).GetByTaskIdAsync(task.Id));
}
[Fact]
public async Task ListWorktrees_SquashMergedBranch_ReportsMergedIntoMain()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var (task, list, wt) = await SeedWorktreeAsync(TaskStatus.Done);
File.WriteAllText(Path.Combine(wt.WorktreePath, "feature.txt"), "x");
GitRepoFixture.RunGit(wt.WorktreePath, "add", "-A");
GitRepoFixture.RunGit(wt.WorktreePath, "commit", "-m", "feature");
GitRepoFixture.RunGit(list.WorkingDir!, "merge", "--squash", wt.BranchName);
GitRepoFixture.RunGit(list.WorkingDir!, "commit", "-m", "squash");
var sut = BuildSut(CreateQueue());
var row = (await sut.ListWorktrees(CancellationToken.None)).Single(r => r.TaskId == task.Id);
Assert.True(row.ContentInMain);
Assert.True(row.MergedIntoMain);
}
} }
@@ -531,4 +531,131 @@ public class WorktreeMaintenanceServiceTests : IDisposable
Assert.Equal(1, progressCalls[0].Args[2]); Assert.Equal(1, progressCalls[0].Args[2]);
Assert.Equal(2, progressCalls[1].Args[2]); Assert.Equal(2, progressCalls[1].Args[2]);
} }
private static async Task SeedRowAsync(DbFixture db, ListEntity? list, TaskEntity task, string path, string branch,
string baseCommit, WorktreeState state = WorktreeState.Active)
{
using var ctx = db.CreateContext();
if (list is not null) await new ListRepository(ctx).AddAsync(list);
await new TaskRepository(ctx).AddAsync(task);
await new WorktreeRepository(ctx).AddAsync(new WorktreeEntity
{
TaskId = task.Id, Path = path, BranchName = branch,
BaseCommit = baseCommit, State = state, CreatedAt = DateTime.UtcNow,
});
}
private static void CommitFile(string dir, string file)
{
File.WriteAllText(Path.Combine(dir, file), file);
GitRepoFixture.RunGit(dir, "add", "-A");
GitRepoFixture.RunGit(dir, "commit", "-m", $"add {file}");
}
private static void SquashMerge(string repoDir, string branch)
{
GitRepoFixture.RunGit(repoDir, "merge", "--squash", branch);
GitRepoFixture.RunGit(repoDir, "commit", "-m", $"squash {branch}");
}
[Fact]
public async Task GetOverview_ContentInMain_True_For_SquashMerged_False_For_Unmerged_Commit()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var repo = NewRepo();
var git = new GitService();
var db = NewDb();
var (list, squashed) = MakeEntities(repo.RepoDir);
var unmerged = MakeTaskForList(list.Id, ClaudeDo.Data.Models.TaskStatus.Done);
var squashedWt = await CreateWorktreeAsync(git, repo.RepoDir, squashed.Id);
var unmergedWt = await CreateWorktreeAsync(git, repo.RepoDir, unmerged.Id);
CommitFile(squashedWt, "squashed.txt");
CommitFile(unmergedWt, "unmerged.txt");
SquashMerge(repo.RepoDir, $"test/{squashed.Id}");
await SeedRowAsync(db, list, squashed, squashedWt, $"test/{squashed.Id}", repo.BaseCommit);
await SeedRowAsync(db, null, unmerged, unmergedWt, $"test/{unmerged.Id}", repo.BaseCommit);
var svc = new WorktreeMaintenanceService(db.CreateFactory(), git, NullLogger<WorktreeMaintenanceService>.Instance);
var rows = await svc.GetOverviewAsync(null);
Assert.True(rows.Single(r => r.TaskId == squashed.Id).ContentInMain);
Assert.False(rows.Single(r => r.TaskId == unmerged.Id).ContentInMain);
try { await git.WorktreeRemoveAsync(repo.RepoDir, squashedWt, force: true); } catch { }
try { await git.WorktreeRemoveAsync(repo.RepoDir, unmergedWt, force: true); } catch { }
}
[Fact]
public async Task CleanupFinished_Removes_Active_Worktree_Squash_Merged_Outside_ClaudeDo()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var repo = NewRepo();
var git = new GitService();
var db = NewDb();
var (list, squashed) = MakeEntities(repo.RepoDir);
var unmerged = MakeTaskForList(list.Id, ClaudeDo.Data.Models.TaskStatus.Done);
var squashedWt = await CreateWorktreeAsync(git, repo.RepoDir, squashed.Id);
var unmergedWt = await CreateWorktreeAsync(git, repo.RepoDir, unmerged.Id);
CommitFile(squashedWt, "squashed.txt");
CommitFile(unmergedWt, "unmerged.txt");
SquashMerge(repo.RepoDir, $"test/{squashed.Id}");
await SeedRowAsync(db, list, squashed, squashedWt, $"test/{squashed.Id}", repo.BaseCommit);
await SeedRowAsync(db, null, unmerged, unmergedWt, $"test/{unmerged.Id}", repo.BaseCommit);
var svc = new WorktreeMaintenanceService(db.CreateFactory(), git, NullLogger<WorktreeMaintenanceService>.Instance);
var result = await svc.CleanupFinishedAsync();
Assert.Equal(new[] { squashed.Id }, result.RemovedTaskIds);
Assert.False(Directory.Exists(squashedWt));
Assert.True(Directory.Exists(unmergedWt));
try { await git.WorktreeRemoveAsync(repo.RepoDir, unmergedWt, force: true); } catch { }
}
[Fact]
public async Task ForceRemove_Prunes_And_Deletes_Branch_In_Owning_Repo_Not_List_WorkingDir()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var owner = NewRepo();
var other = NewRepo();
var git = new GitService();
var db = NewDb();
// The list points at a different repo than the one the worktree was created in.
var (list, task) = MakeEntities(other.RepoDir);
var wt = await CreateWorktreeAsync(git, owner.RepoDir, task.Id);
await SeedRowAsync(db, list, task, wt, $"test/{task.Id}", owner.BaseCommit);
var svc = new WorktreeMaintenanceService(db.CreateFactory(), git, NullLogger<WorktreeMaintenanceService>.Instance);
var result = await svc.ForceRemoveAsync(task.Id);
Assert.True(result.Removed);
Assert.True(result.BranchDeleted);
Assert.DoesNotContain($"test/{task.Id}", GitRepoFixture.RunGit(owner.RepoDir, "branch", "--list"));
Assert.DoesNotContain(Path.GetFileName(wt), GitRepoFixture.RunGit(owner.RepoDir, "worktree", "list"));
}
[Fact]
public async Task ForceRemove_Reports_BranchDeleted_When_Branch_Was_Already_Gone()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var repo = NewRepo();
var db = NewDb();
var (list, task) = MakeEntities(repo.RepoDir);
var phantomPath = Path.Combine(Path.GetTempPath(), $"wt_phantom_{Guid.NewGuid():N}");
await SeedRowAsync(db, list, task, phantomPath, $"test/{task.Id}-never-existed", repo.BaseCommit);
var svc = new WorktreeMaintenanceService(db.CreateFactory(), new GitService(), NullLogger<WorktreeMaintenanceService>.Instance);
var result = await svc.ForceRemoveAsync(task.Id);
Assert.True(result.Removed);
Assert.True(result.BranchDeleted);
}
} }