Worker and App Program.cs: replace SqliteConnectionFactory+SchemaInitializer with AddDbContextFactory<ClaudeDoDbContext> + Database.Migrate(). Repos changed from AddSingleton to AddScoped. All singleton services (QueueService, StaleTaskRecovery, WorktreeManager, TaskRunner) and singleton ViewModels (MainWindowViewModel, TaskDetailViewModel, TaskListViewModel, TaskEditorViewModel) now take IDbContextFactory<ClaudeDoDbContext> and create short-lived contexts per operation. Test infrastructure: DbFixture now uses EF migrations instead of SchemaInitializer; all test classes create contexts via DbFixture.CreateContext(). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
198 lines
6.9 KiB
C#
198 lines
6.9 KiB
C#
using ClaudeDo.Data;
|
|
using ClaudeDo.Data.Git;
|
|
using ClaudeDo.Data.Models;
|
|
using ClaudeDo.Data.Repositories;
|
|
using ClaudeDo.Worker.Config;
|
|
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));
|
|
Assert.Equal($"claudedo/{task.Id.Replace("-", "")}", ctx.BranchName);
|
|
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);
|
|
}
|
|
|
|
[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_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);
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
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 { }
|
|
}
|
|
}
|
|
}
|