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
@@ -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);
}
}
@@ -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<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);
}
}