diff --git a/src/ClaudeDo.Data/Git/GitService.cs b/src/ClaudeDo.Data/Git/GitService.cs index 8e950085..29e16e94 100644 --- a/src/ClaudeDo.Data/Git/GitService.cs +++ b/src/ClaudeDo.Data/Git/GitService.cs @@ -489,6 +489,13 @@ public sealed class GitService return head.StartsWith("origin/", StringComparison.Ordinal) ? head["origin/".Length..] : head; } + /// Absolute path of the .git dir shared by all worktrees of the repo owning , or null. + public async Task 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; + } + /// git merge --ff-only: moves the checked-out branch to or fails without touching it. public async Task<(int ExitCode, string Stderr)> MergeFastForwardOnlyAsync( string worktreePath, string sourceRef, CancellationToken ct = default) diff --git a/src/ClaudeDo.Data/Wire.cs b/src/ClaudeDo.Data/Wire.cs index 1d580d4e..5a85710e 100644 --- a/src/ClaudeDo.Data/Wire.cs +++ b/src/ClaudeDo.Data/Wire.cs @@ -88,7 +88,8 @@ public record WorktreeOverviewDto( string? DiffStat, DateTime CreatedAt, bool PathExistsOnDisk, - int TaskNumber = 0); + int TaskNumber = 0, + bool ContentInMain = false); public record ForceRemoveResultDto(bool Removed, string? Reason); diff --git a/src/ClaudeDo.Localization/locales/de.json b/src/ClaudeDo.Localization/locales/de.json index 7f20badb..488333d4 100644 --- a/src/ClaudeDo.Localization/locales/de.json +++ b/src/ClaudeDo.Localization/locales/de.json @@ -555,6 +555,8 @@ "resolve": "Lösen", "phantom": "Phantom", "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", "ctxOpenInExplorer": "Im Explorer öffnen", "ctxJumpToTask": "Zur Aufgabe springen", diff --git a/src/ClaudeDo.Localization/locales/en.json b/src/ClaudeDo.Localization/locales/en.json index 989331d7..09b30069 100644 --- a/src/ClaudeDo.Localization/locales/en.json +++ b/src/ClaudeDo.Localization/locales/en.json @@ -555,6 +555,8 @@ "resolve": "Resolve", "phantom": "phantom", "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", "ctxOpenInExplorer": "Open in Explorer", "ctxJumpToTask": "Jump to task", diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs index bd22f38a..fa298548 100644 --- a/src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs @@ -33,6 +33,8 @@ public sealed partial class WorktreeOverviewRowViewModel : ViewModelBase [ObservableProperty] private string? _diffStat; [ObservableProperty][NotifyPropertyChangedFor(nameof(AgeText))] private DateTime _createdAt; [ObservableProperty] private bool _pathExistsOnDisk; + /// Not Merged in ClaudeDo, but the branch's commits are already in the default branch (e.g. a squash PR). + [ObservableProperty] private bool _contentInMain; [ObservableProperty] private bool _isSelected; [ObservableProperty] private bool _isChecked; [ObservableProperty] @@ -374,6 +376,7 @@ public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase ListId = d.ListId, ListName = d.ListName, Path = d.Path, BranchName = d.BranchName, BaseCommit = d.BaseCommit, State = d.State, DiffStat = d.DiffStat, CreatedAt = d.CreatedAt, PathExistsOnDisk = d.PathExistsOnDisk, + ContentInMain = d.ContentInMain, }; public IEnumerable AllRows => diff --git a/src/ClaudeDo.Ui/Views/Modals/WorktreesOverviewModalView.axaml b/src/ClaudeDo.Ui/Views/Modals/WorktreesOverviewModalView.axaml index 98f708e7..7832f174 100644 --- a/src/ClaudeDo.Ui/Views/Modals/WorktreesOverviewModalView.axaml +++ b/src/ClaudeDo.Ui/Views/Modals/WorktreesOverviewModalView.axaml @@ -77,6 +77,11 @@ + + (else " + + "main/master) without fetching -- fetch first for a current answer. mergedIntoMain = merged via ClaudeDo " + + "or contentInMain." + McpToolDocs.ProgressHint)] public async Task> ListWorktrees( CancellationToken cancellationToken = default, IProgress? progress = null) { 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 // 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). var results = await ProgressReporter.RunAsync( Task.WhenAll(rows.Select(async row => { - var isDirty = row.PathExistsOnDisk && await TryGetIsDirtyAsync(row.Path, cancellationToken); - var headCommit = row.PathExistsOnDisk + var isDirty = row.IsGitWorktree && await TryGetIsDirtyAsync(row.Path, cancellationToken); + var headCommit = row.IsGitWorktree ? (await TryRunGitAsync(row.Path, ["rev-parse", "HEAD"], cancellationToken) ?? "") : ""; return new WorktreeListItemDto( 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"); return results; @@ -180,7 +184,7 @@ public sealed class WorktreeMcpTools [McpServerTool, Description( "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 CleanupTaskWorktree( string taskId, [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) 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( _git.HasChangesAsync(wt.Path, cancellationToken), ProgressReportInterval, progress, "checking worktree for changes"); diff --git a/src/ClaudeDo.Worker/Hub/WorkerHub.cs b/src/ClaudeDo.Worker/Hub/WorkerHub.cs index e3ceae6f..1c704b0a 100644 --- a/src/ClaudeDo.Worker/Hub/WorkerHub.cs +++ b/src/ClaudeDo.Worker/Hub/WorkerHub.cs @@ -467,7 +467,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub return rows.Select(r => new WorktreeOverviewDto( 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.TaskNumber)).ToList(); + r.TaskNumber, r.ContentInMain)).ToList(); } public async Task SetWorktreeState(string taskId, WorktreeState newState) diff --git a/src/ClaudeDo.Worker/Worktrees/WorktreeMaintenanceService.cs b/src/ClaudeDo.Worker/Worktrees/WorktreeMaintenanceService.cs index 4e89bd21..63128061 100644 --- a/src/ClaudeDo.Worker/Worktrees/WorktreeMaintenanceService.cs +++ b/src/ClaudeDo.Worker/Worktrees/WorktreeMaintenanceService.cs @@ -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(); + 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(); @@ -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); + })); + } + + /// True when holds a git checkout (.git file or dir), not just a directory. + // 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 _contentInMainCache = new(); + + /// + /// 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. + /// + private async Task 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; + } + + /// origin/HEAD's branch, else the first existing of origin/main, origin/master, main, master. + private async Task 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 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; } + } + + /// The repo owning the worktree (parent of its git-common-dir); the list's working dir when the path is no git checkout anymore. + private async Task 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: ...//.git -> . 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 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); diff --git a/src/ClaudeDo.Worker/Worktrees/WorktreeOverviewRow.cs b/src/ClaudeDo.Worker/Worktrees/WorktreeOverviewRow.cs index 698792ab..1ba3d247 100644 --- a/src/ClaudeDo.Worker/Worktrees/WorktreeOverviewRow.cs +++ b/src/ClaudeDo.Worker/Worktrees/WorktreeOverviewRow.cs @@ -3,6 +3,9 @@ using TaskStatus = ClaudeDo.Data.Models.TaskStatus; namespace ClaudeDo.Worker.Worktrees; +/// One tracked worktree with its task/list context and on-disk facts. +/// The path holds a git checkout (.git present), not just a directory. +/// Own commits are already in the local default branch (merged, fast-forwarded or squashed) — independent of . public sealed record WorktreeOverviewRow( string TaskId, int TaskNumber, @@ -16,4 +19,6 @@ public sealed record WorktreeOverviewRow( WorktreeState State, string? DiffStat, DateTime CreatedAt, - bool PathExistsOnDisk); + bool PathExistsOnDisk, + bool IsGitWorktree = false, + bool ContentInMain = false); diff --git a/tests/ClaudeDo.Worker.Tests/External/WorktreeMcpToolsTests.cs b/tests/ClaudeDo.Worker.Tests/External/WorktreeMcpToolsTests.cs index ebc26d51..abcbba29 100644 --- a/tests/ClaudeDo.Worker.Tests/External/WorktreeMcpToolsTests.cs +++ b/tests/ClaudeDo.Worker.Tests/External/WorktreeMcpToolsTests.cs @@ -489,4 +489,45 @@ public sealed class WorktreeMcpToolsTests : IDisposable 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); + } } diff --git a/tests/ClaudeDo.Worker.Tests/Services/WorktreeMaintenanceServiceTests.cs b/tests/ClaudeDo.Worker.Tests/Services/WorktreeMaintenanceServiceTests.cs index 2cb1fb7e..cf04f869 100644 --- a/tests/ClaudeDo.Worker.Tests/Services/WorktreeMaintenanceServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Services/WorktreeMaintenanceServiceTests.cs @@ -531,4 +531,131 @@ public class WorktreeMaintenanceServiceTests : IDisposable Assert.Equal(1, progressCalls[0].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.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.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.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.Instance); + var result = await svc.ForceRemoveAsync(task.Id); + + Assert.True(result.Removed); + Assert.True(result.BranchDeleted); + } }