Files
ClaudeDo/tests/ClaudeDo.Worker.Tests/Runner/WorktreeManagerTests.cs
T

496 lines
20 KiB
C#

using ClaudeDo.Data;
using ClaudeDo.Data.Git;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Config;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Runner;
using ClaudeDo.Worker.Tests.Infrastructure;
using Microsoft.Extensions.Logging.Abstractions;
namespace ClaudeDo.Worker.Tests.Runner;
public class WorktreeManagerTests : IDisposable
{
private readonly List<GitRepoFixture> _fixtures = new();
private readonly List<DbFixture> _dbFixtures = new();
private readonly List<string> _tempDirs = new();
private readonly List<(string repoDir, string wtPath)> _worktreeCleanups = new();
private static bool GitAvailable => GitRepoFixture.IsGitAvailable();
private GitRepoFixture CreateRepo()
{
var f = new GitRepoFixture();
_fixtures.Add(f);
return f;
}
private async Task<(WorktreeManager mgr, DbFixture db)> CreateManagerAsync(
TaskEntity task, ListEntity list, string strategy = "sibling", string? centralRoot = null)
{
var db = new DbFixture();
_dbFixtures.Add(db);
// Seed the DB with list and task so FK constraints pass.
using var seedCtx = db.CreateContext();
var listRepo = new ListRepository(seedCtx);
var taskRepo = new TaskRepository(seedCtx);
await listRepo.AddAsync(list);
await taskRepo.AddAsync(task);
var cfg = new WorkerConfig
{
WorktreeRootStrategy = strategy,
};
if (centralRoot is not null)
cfg.CentralWorktreeRoot = centralRoot;
var mgr = new WorktreeManager(
new GitService(), db.CreateFactory(), cfg, NullLogger<WorktreeManager>.Instance);
return (mgr, db);
}
[Fact]
public async Task CreateAsync_Succeeds_InGitRepo()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var repo = CreateRepo();
var (task, list) = MakeEntities(repo.RepoDir);
var (mgr, db) = await CreateManagerAsync(task, list);
var ctx = await mgr.CreateAsync(task, list, CancellationToken.None);
_worktreeCleanups.Add((repo.RepoDir, ctx.WorktreePath));
Assert.NotNull(ctx);
Assert.True(Directory.Exists(ctx.WorktreePath));
// StartsWith: a folder leaked into the shared %TEMP% root by another test forces the id suffix.
Assert.StartsWith($"chore/{task.Number}-test-task", ctx.BranchName);
Assert.StartsWith($"{task.Number}-test-task", Path.GetFileName(ctx.WorktreePath));
Assert.Equal(repo.BaseCommit, ctx.BaseCommit);
using var readCtx = db.CreateContext();
var wtRepo = new WorktreeRepository(readCtx);
var row = await wtRepo.GetByTaskIdAsync(task.Id);
Assert.NotNull(row);
Assert.Equal(WorktreeState.Active, row!.State);
Assert.Equal(ctx.BaseCommit, row.BaseCommit);
Assert.Null(row.HeadCommit);
}
[Theory]
[InlineData("Fix: Löschen des Tasks!", null, false, "7-fix-loeschen-des-tasks")]
[InlineData("äöü", null, false, "7-aeoeue")]
[InlineData("a very long title that goes well beyond the forty char cap", null, false, "7-a-very-long-title-that-goes-well-beyond")]
[InlineData("test task", 412, false, "412-test-task")]
[InlineData("test task", 412, true, "412-test-task")]
[InlineData("test task", null, true, "test-task")]
[InlineData("!!!", null, true, "7")]
public void NameFor_NumberRules(string title, int? ticketId, bool isCompany, string expected)
{
var task = new TaskEntity { Id = "x", ListId = "y", Title = title, Number = 7, CreatedAt = DateTime.UtcNow };
Assert.Equal(expected, WorktreeManager.NameFor(task, ticketId, isCompany));
}
[Theory]
[InlineData("feat", "feature")]
[InlineData("fix", "fix")]
[InlineData("refactor", "refactor")]
[InlineData("docs", "docs")]
[InlineData("test", "test")]
[InlineData("chore", "chore")]
[InlineData("ci", "chore")]
[InlineData("perf", "chore")]
[InlineData(" FEAT ", "feature")]
[InlineData("", "chore")]
public void BranchTypeFor_maps_commit_types(string commitType, string expected)
=> Assert.Equal(expected, WorktreeManager.BranchTypeFor(commitType));
[Fact]
public async Task CreateAsync_BroadcastsCreatingWorktreePhase()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var repo = CreateRepo();
var (task, list) = MakeEntities(repo.RepoDir);
var db = new DbFixture();
_dbFixtures.Add(db);
using (var seedCtx = db.CreateContext())
{
await new ListRepository(seedCtx).AddAsync(list);
await new TaskRepository(seedCtx).AddAsync(task);
}
var fakeHub = new CapturingHubContext();
var broadcaster = new HubBroadcaster(fakeHub);
var cfg = new WorkerConfig { WorktreeRootStrategy = "sibling" };
var mgr = new WorktreeManager(
new GitService(), db.CreateFactory(), cfg, NullLogger<WorktreeManager>.Instance, broadcaster);
var ctx = await mgr.CreateAsync(task, list, CancellationToken.None);
_worktreeCleanups.Add((repo.RepoDir, ctx.WorktreePath));
var progressCalls = fakeHub.Proxy.Calls.Where(c => c.Method == "OperationProgress").ToList();
Assert.Single(progressCalls);
Assert.Equal(task.Id, (string?)progressCalls[0].Args[0]);
Assert.Equal(WorktreeManager.PhaseCreatingWorktree, (string?)progressCalls[0].Args[1]);
}
[Fact]
public async Task CreateAsync_NameTakenByForeignBranch_FallsBackToIdSuffixAndLeavesBranchIntact()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var repo = CreateRepo();
var (task, list) = MakeEntities(repo.RepoDir);
var (mgr, _) = await CreateManagerAsync(task, list);
// A leftover from an earlier DB whose task happened to get the same number and title.
var foreign = $"chore/{WorktreeManager.NameFor(task, null, false)}";
GitRepoFixture.RunGit(repo.RepoDir, "branch", foreign, repo.BaseCommit);
var ctx = await mgr.CreateAsync(task, list, CancellationToken.None);
_worktreeCleanups.Add((repo.RepoDir, ctx.WorktreePath));
var suffixed = $"{WorktreeManager.NameFor(task, null, false)}-{task.Id.Replace("-", "")[..8]}";
Assert.Equal($"chore/{suffixed}", ctx.BranchName);
Assert.Equal(suffixed, Path.GetFileName(ctx.WorktreePath));
Assert.False(string.IsNullOrWhiteSpace(GitRepoFixture.RunGit(repo.RepoDir, "branch", "--list", foreign)));
}
[Fact]
public async Task CreateAsync_SelfHealPath_BroadcastsCreatingWorktreePhaseForEachSection()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var repo = CreateRepo();
var (task, list) = MakeEntities(repo.RepoDir);
var db = new DbFixture();
_dbFixtures.Add(db);
using (var seedCtx = db.CreateContext())
{
await new ListRepository(seedCtx).AddAsync(list);
await new TaskRepository(seedCtx).AddAsync(task);
}
// Occupy the plain name (-> id-suffixed fallback) and the fallback too, so
// `git worktree add -b` hits the "already exists" self-heal path.
var branchName = $"chore/{WorktreeManager.NameFor(task, null, false)}";
GitRepoFixture.RunGit(repo.RepoDir, "branch", branchName, repo.BaseCommit);
GitRepoFixture.RunGit(repo.RepoDir, "branch", $"{branchName}-{task.Id.Replace("-", "")[..8]}", repo.BaseCommit);
var fakeHub = new CapturingHubContext();
var broadcaster = new HubBroadcaster(fakeHub);
var cfg = new WorkerConfig { WorktreeRootStrategy = "sibling" };
var mgr = new WorktreeManager(
new GitService(), db.CreateFactory(), cfg, NullLogger<WorktreeManager>.Instance, broadcaster);
var ctx = await mgr.CreateAsync(task, list, CancellationToken.None);
_worktreeCleanups.Add((repo.RepoDir, ctx.WorktreePath));
// One message for the initial attempt, one for the self-heal retry section -- not a tick stream.
var progressCalls = fakeHub.Proxy.Calls
.Where(c => c.Method == "OperationProgress"
&& (string?)c.Args[0] == task.Id
&& (string?)c.Args[1] == WorktreeManager.PhaseCreatingWorktree)
.ToList();
Assert.Equal(2, progressCalls.Count);
}
[Fact]
public async Task CommitIfChangedAsync_NoChanges_HeadCommitStaysNull()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var repo = CreateRepo();
var (task, list) = MakeEntities(repo.RepoDir);
var (mgr, db) = await CreateManagerAsync(task, list);
var ctx = await mgr.CreateAsync(task, list, CancellationToken.None);
_worktreeCleanups.Add((repo.RepoDir, ctx.WorktreePath));
var committed = await mgr.CommitIfChangedAsync(ctx, task, list, CancellationToken.None);
Assert.False(committed);
using var readCtx = db.CreateContext();
var wtRepo = new WorktreeRepository(readCtx);
var row = await wtRepo.GetByTaskIdAsync(task.Id);
Assert.Null(row!.HeadCommit);
}
[Fact]
public async Task CommitIfChangedAsync_WithNewFile_HeadCommitSet()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var repo = CreateRepo();
var (task, list) = MakeEntities(repo.RepoDir);
var (mgr, db) = await CreateManagerAsync(task, list);
var ctx = await mgr.CreateAsync(task, list, CancellationToken.None);
_worktreeCleanups.Add((repo.RepoDir, ctx.WorktreePath));
File.WriteAllText(Path.Combine(ctx.WorktreePath, "hello.txt"), "hello world");
var committed = await mgr.CommitIfChangedAsync(ctx, task, list, CancellationToken.None);
Assert.True(committed);
using var readCtx = db.CreateContext();
var wtRepo = new WorktreeRepository(readCtx);
var row = await wtRepo.GetByTaskIdAsync(task.Id);
Assert.NotNull(row!.HeadCommit);
Assert.NotEqual(ctx.BaseCommit, row.HeadCommit);
Assert.NotNull(row.DiffStat);
Assert.Contains("hello.txt", row.DiffStat);
}
[Fact]
public async Task CreateAsync_EmptyRepo_BootstrapsInitialCommit()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
// Fresh `git init`, zero commits, no identity configured — the cross-PC failure
// that surfaced as "git rev-parse HEAD failed: ambiguous argument 'HEAD'".
var repoDir = Path.Combine(Path.GetTempPath(), $"claudedo_emptyrepo_{Guid.NewGuid():N}");
Directory.CreateDirectory(repoDir);
GitRepoFixture.RunGit(repoDir, "init", "-b", "main");
_tempDirs.Add(repoDir);
var (task, list) = MakeEntities(repoDir);
var (mgr, db) = await CreateManagerAsync(task, list);
var ctx = await mgr.CreateAsync(task, list, CancellationToken.None);
_worktreeCleanups.Add((repoDir, ctx.WorktreePath));
Assert.True(Directory.Exists(ctx.WorktreePath));
// The base commit is the auto-created bootstrap commit.
var head = GitRepoFixture.RunGit(repoDir, "rev-parse", "HEAD").Trim();
Assert.Equal(head, ctx.BaseCommit);
using var readCtx = db.CreateContext();
var row = await new WorktreeRepository(readCtx).GetByTaskIdAsync(task.Id);
Assert.Equal(WorktreeState.Active, row!.State);
}
[Fact]
public async Task CreateAsync_NonGitDir_Throws_NoRow()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var tmpDir = Path.Combine(Path.GetTempPath(), $"claudedo_nogit_{Guid.NewGuid():N}");
Directory.CreateDirectory(tmpDir);
_tempDirs.Add(tmpDir);
var (task, list) = MakeEntities(tmpDir);
var db = new DbFixture();
_dbFixtures.Add(db);
using (var seedCtx = db.CreateContext())
{
var listRepo = new ListRepository(seedCtx);
var taskRepo = new TaskRepository(seedCtx);
await listRepo.AddAsync(list);
await taskRepo.AddAsync(task);
}
var cfg = new WorkerConfig { WorktreeRootStrategy = "sibling" };
var mgr = new WorktreeManager(
new GitService(), db.CreateFactory(), cfg, NullLogger<WorktreeManager>.Instance);
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => mgr.CreateAsync(task, list, CancellationToken.None));
Assert.Contains("not a git repository", ex.Message);
using var readCtx = db.CreateContext();
var wtRepo = new WorktreeRepository(readCtx);
var row = await wtRepo.GetByTaskIdAsync(task.Id);
Assert.Null(row);
}
[Fact]
public async Task CreateAsync_DbInsertFails_RemovesOrphanedWorktreeAndBranch()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var repo = CreateRepo();
var (task, list) = MakeEntities(repo.RepoDir);
// Seed the list but NOT the task: the worktrees-row insert references
// tasks(task_id) and fails the FK after `git worktree add` has succeeded.
var db = new DbFixture();
_dbFixtures.Add(db);
using (var seedCtx = db.CreateContext())
await new ListRepository(seedCtx).AddAsync(list);
var cfg = new WorkerConfig { WorktreeRootStrategy = "sibling" };
var mgr = new WorktreeManager(
new GitService(), db.CreateFactory(), cfg, NullLogger<WorktreeManager>.Instance);
await Assert.ThrowsAnyAsync<Exception>(
() => mgr.CreateAsync(task, list, CancellationToken.None));
var branchName = $"chore/{WorktreeManager.NameFor(task, null, false)}";
var branchList = GitRepoFixture.RunGit(repo.RepoDir, "branch", "--list", branchName);
Assert.True(string.IsNullOrWhiteSpace(branchList),
$"orphaned branch {branchName} should be cleaned up, got: {branchList}");
var worktreeList = GitRepoFixture.RunGit(repo.RepoDir, "worktree", "list");
Assert.DoesNotContain(WorktreeManager.NameFor(task, null, false), worktreeList);
}
[Fact]
public async Task CreateAsync_TrailingSeparatorOnWorkingDir_PlacesWorktreeOutsideRepo()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var repo = CreateRepo();
var workingDirWithSeparator = repo.RepoDir + Path.DirectorySeparatorChar;
var (task, list) = MakeEntities(workingDirWithSeparator);
var (mgr, db) = await CreateManagerAsync(task, list);
var ctx = await mgr.CreateAsync(task, list, CancellationToken.None);
_worktreeCleanups.Add((repo.RepoDir, ctx.WorktreePath));
var repoDirFull = Path.GetFullPath(repo.RepoDir);
Assert.False(
ctx.WorktreePath.StartsWith(repoDirFull + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase),
$"worktree path {ctx.WorktreePath} should not be nested inside the repo dir {repoDirFull}");
Assert.True(Directory.Exists(ctx.WorktreePath));
}
[Fact]
public async Task CreateAsync_CompanyRepoWithTicket_UsesTicketIdAndFeaturePrefix()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var repo = CreateRepo();
GitRepoFixture.RunGit(repo.RepoDir, "remote", "add", "origin", "https://srv.local/Coll/Proj/_git/Repo");
var (task, list) = MakeEntities(repo.RepoDir);
task.CommitType = "feat";
task.TicketRef = "bandel:412";
var (mgr, _) = await CreateManagerAsync(task, list);
var ctx = await mgr.CreateAsync(task, list, CancellationToken.None);
_worktreeCleanups.Add((repo.RepoDir, ctx.WorktreePath));
Assert.StartsWith("feature/412-test-task", ctx.BranchName);
}
[Fact]
public async Task CreateAsync_CompanyRepoWithoutTicket_OmitsNumber()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var repo = CreateRepo();
GitRepoFixture.RunGit(repo.RepoDir, "remote", "add", "origin", "https://srv.local/Coll/Proj/_git/Repo");
var (task, list) = MakeEntities(repo.RepoDir);
var (mgr, _) = await CreateManagerAsync(task, list);
var ctx = await mgr.CreateAsync(task, list, CancellationToken.None);
_worktreeCleanups.Add((repo.RepoDir, ctx.WorktreePath));
Assert.StartsWith("chore/test-task", ctx.BranchName);
Assert.DoesNotContain($"/{task.Number}-", ctx.BranchName);
}
[Fact]
public async Task CreateAsync_ChildWithoutTicket_UsesParentTicket()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var repo = CreateRepo();
var (child, list) = MakeEntities(repo.RepoDir);
var parent = new TaskEntity
{
Id = Guid.NewGuid().ToString(), ListId = list.Id, Title = "parent", CommitType = "feat",
TicketRef = "bandel:77", CreatedAt = DateTime.UtcNow,
};
child.ParentTaskId = parent.Id;
var db = new DbFixture();
_dbFixtures.Add(db);
using (var seedCtx = db.CreateContext())
{
await new ListRepository(seedCtx).AddAsync(list);
await new TaskRepository(seedCtx).AddAsync(parent);
await new TaskRepository(seedCtx).AddAsync(child);
}
var mgr = new WorktreeManager(new GitService(), db.CreateFactory(),
new WorkerConfig { WorktreeRootStrategy = "sibling" }, NullLogger<WorktreeManager>.Instance);
var ctx = await mgr.CreateAsync(child, list, CancellationToken.None);
_worktreeCleanups.Add((repo.RepoDir, ctx.WorktreePath));
Assert.StartsWith("chore/77-test-task", ctx.BranchName);
}
private static (TaskEntity task, ListEntity list) MakeEntities(string workingDir)
{
var listId = Guid.NewGuid().ToString();
var taskId = Guid.NewGuid().ToString();
var task = new TaskEntity
{
Id = taskId,
ListId = listId,
Title = "test task",
Description = "a description",
CommitType = "chore",
CreatedAt = DateTime.UtcNow,
};
var list = new ListEntity
{
Id = listId,
Name = "Test List",
WorkingDir = workingDir,
CreatedAt = DateTime.UtcNow,
};
return (task, list);
}
[Fact]
public async Task DiscardAsync_RemovesWorktreeAndBranch_AndSetsStateDiscarded()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var repo = CreateRepo();
var (task, list) = MakeEntities(repo.RepoDir);
var (mgr, db) = await CreateManagerAsync(task, list);
var ctx = await mgr.CreateAsync(task, list, CancellationToken.None);
_worktreeCleanups.Add((repo.RepoDir, ctx.WorktreePath));
var worktreePath = ctx.WorktreePath;
WorktreeEntity wt;
using (var readCtx = db.CreateContext())
wt = (await new WorktreeRepository(readCtx).GetByTaskIdAsync(task.Id))!;
await mgr.DiscardAsync(wt, list.WorkingDir!, CancellationToken.None);
Assert.False(Directory.Exists(worktreePath), "worktree directory should be gone");
var branchList = GitRepoFixture.RunGit(repo.RepoDir, "branch", "--list", ctx.BranchName);
Assert.True(string.IsNullOrWhiteSpace(branchList),
$"branch {ctx.BranchName} should be deleted, got: {branchList}");
using var readCtx2 = db.CreateContext();
var row = await new WorktreeRepository(readCtx2).GetByTaskIdAsync(task.Id);
Assert.NotNull(row);
Assert.Equal(WorktreeState.Discarded, row!.State);
}
public void Dispose()
{
foreach (var (repoDir, wtPath) in _worktreeCleanups)
{
try { GitRepoFixture.RunGit(repoDir, "worktree", "remove", "--force", wtPath); } catch { }
}
foreach (var f in _fixtures) f.Dispose();
foreach (var db in _dbFixtures) db.Dispose();
foreach (var d in _tempDirs)
{
try { Directory.Delete(d, true); } catch { }
}
}
}