feat(worker): add WorktreeMaintenanceService for idle-worktree cleanup
This commit is contained in:
@@ -73,6 +73,21 @@ public sealed class GitService
|
||||
return stdout;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Full diff between <paramref name="baseRef"/> and the current working tree
|
||||
/// (committed-on-branch changes + uncommitted work). Used for viewing a Claude
|
||||
/// task's total impact relative to where the branch started.
|
||||
/// </summary>
|
||||
public async Task<string> GetBranchDiffAsync(string worktreePath, string baseRef, CancellationToken ct = default)
|
||||
{
|
||||
var (exitCode, stdout, _) = await RunGitAsync(worktreePath,
|
||||
["diff", baseRef], ct);
|
||||
if (exitCode == 0 && !string.IsNullOrWhiteSpace(stdout))
|
||||
return stdout;
|
||||
// Fallback: whatever the worktree has vs HEAD (uncommitted only).
|
||||
return await GetDiffAsync(worktreePath, ct);
|
||||
}
|
||||
|
||||
public async Task<string> DiffStatAsync(string worktreePath, string baseCommit, string headCommit, CancellationToken ct = default)
|
||||
{
|
||||
var (exitCode, stdout, stderr) = await RunGitAsync(worktreePath,
|
||||
@@ -93,6 +108,41 @@ public sealed class GitService
|
||||
throw new InvalidOperationException($"git worktree remove failed (exit {exitCode}): {stderr}");
|
||||
}
|
||||
|
||||
public async Task<List<string>> ListWorktreePathsForBranchAsync(string repoDir, string branchName, CancellationToken ct = default)
|
||||
{
|
||||
var (exitCode, stdout, _) = await RunGitAsync(repoDir, ["worktree", "list", "--porcelain"], ct);
|
||||
if (exitCode != 0) return new();
|
||||
|
||||
var target = $"refs/heads/{branchName}";
|
||||
var paths = new List<string>();
|
||||
string? currentPath = null;
|
||||
foreach (var raw in stdout.Split('\n'))
|
||||
{
|
||||
var line = raw.TrimEnd('\r');
|
||||
if (line.StartsWith("worktree ", StringComparison.Ordinal))
|
||||
{
|
||||
currentPath = line["worktree ".Length..].Trim();
|
||||
}
|
||||
else if (line.StartsWith("branch ", StringComparison.Ordinal))
|
||||
{
|
||||
var b = line["branch ".Length..].Trim();
|
||||
if (b == target && currentPath is not null) paths.Add(currentPath);
|
||||
}
|
||||
else if (string.IsNullOrWhiteSpace(line))
|
||||
{
|
||||
currentPath = null;
|
||||
}
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
public async Task WorktreePruneAsync(string repoDir, CancellationToken ct = default)
|
||||
{
|
||||
var (exitCode, _, stderr) = await RunGitAsync(repoDir, ["worktree", "prune"], ct);
|
||||
if (exitCode != 0)
|
||||
throw new InvalidOperationException($"git worktree prune failed (exit {exitCode}): {stderr}");
|
||||
}
|
||||
|
||||
public async Task BranchDeleteAsync(string repoDir, string branchName, bool force = false, CancellationToken ct = default)
|
||||
{
|
||||
var flag = force ? "-D" : "-d";
|
||||
|
||||
@@ -40,4 +40,17 @@ public sealed class WorktreeRepository
|
||||
{
|
||||
await _context.Worktrees.Where(w => w.TaskId == taskId).ExecuteDeleteAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<List<WorktreeEntity>> GetAllAsync(CancellationToken ct = default)
|
||||
{
|
||||
return await _context.Worktrees.AsNoTracking().ToListAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<List<WorktreeEntity>> GetByStatesAsync(
|
||||
IReadOnlyCollection<WorktreeState> states, CancellationToken ct = default)
|
||||
{
|
||||
return await _context.Worktrees.AsNoTracking()
|
||||
.Where(w => states.Contains(w.State))
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ builder.Services.AddSingleton<GitService>();
|
||||
builder.Services.AddSingleton<WorktreeManager>();
|
||||
builder.Services.AddSingleton<ClaudeArgsBuilder>();
|
||||
builder.Services.AddSingleton<TaskRunner>();
|
||||
builder.Services.AddSingleton<WorktreeMaintenanceService>();
|
||||
|
||||
// Agent file management.
|
||||
var agentsDir = Path.Combine(ClaudeDo.Data.Paths.AppDataRoot(), "agents");
|
||||
|
||||
@@ -39,8 +39,19 @@ public sealed class WorktreeManager
|
||||
var branchName = $"claudedo/{idForBranch}";
|
||||
var slug = CommitMessageBuilder.ToSlug(list.Name);
|
||||
|
||||
var worktreePath = _cfg.WorktreeRootStrategy.Equals("central", StringComparison.OrdinalIgnoreCase)
|
||||
? Path.Combine(_cfg.CentralWorktreeRoot, slug, task.Id)
|
||||
string strategy;
|
||||
string? centralRoot;
|
||||
using (var settingsCtx = _dbFactory.CreateDbContext())
|
||||
{
|
||||
var settings = await new AppSettingsRepository(settingsCtx).GetAsync(ct);
|
||||
strategy = settings.WorktreeStrategy;
|
||||
centralRoot = !string.IsNullOrWhiteSpace(settings.CentralWorktreeRoot)
|
||||
? settings.CentralWorktreeRoot
|
||||
: _cfg.CentralWorktreeRoot;
|
||||
}
|
||||
|
||||
var worktreePath = strategy.Equals("central", StringComparison.OrdinalIgnoreCase)
|
||||
? Path.Combine(centralRoot ?? _cfg.CentralWorktreeRoot, slug, task.Id)
|
||||
: Path.Combine(Path.GetDirectoryName(workingDir)!, ".claudedo-worktrees", slug, task.Id);
|
||||
|
||||
worktreePath = Path.GetFullPath(worktreePath);
|
||||
@@ -48,12 +59,43 @@ public sealed class WorktreeManager
|
||||
// Ensure parent directory exists.
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(worktreePath)!);
|
||||
|
||||
// Create the worktree (this also creates the directory).
|
||||
await _git.WorktreeAddAsync(workingDir, branchName, worktreePath, baseCommit, ct);
|
||||
// Create the worktree. If a stale branch from a previous run remains
|
||||
// (e.g. after force-remove), delete it and retry once.
|
||||
try
|
||||
{
|
||||
await _git.WorktreeAddAsync(workingDir, branchName, worktreePath, baseCommit, ct);
|
||||
}
|
||||
catch (InvalidOperationException ex) when (ex.Message.Contains("already exists", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.LogWarning("Branch {Branch} already exists; cleaning phantom worktrees and retrying", branchName);
|
||||
|
||||
// Find and forcefully remove any existing worktree registered against this branch.
|
||||
List<string> stalePaths;
|
||||
try { stalePaths = await _git.ListWorktreePathsForBranchAsync(workingDir, branchName, ct); }
|
||||
catch (Exception listEx)
|
||||
{
|
||||
_logger.LogWarning(listEx, "git worktree list failed during self-heal");
|
||||
stalePaths = new();
|
||||
}
|
||||
foreach (var stalePath in stalePaths)
|
||||
{
|
||||
try { await _git.WorktreeRemoveAsync(workingDir, stalePath, force: true, ct); }
|
||||
catch (Exception wrEx) { _logger.LogWarning(wrEx, "Failed to remove stale worktree at {Path}", stalePath); }
|
||||
}
|
||||
|
||||
try { await _git.WorktreePruneAsync(workingDir, ct); }
|
||||
catch (Exception pruneEx) { _logger.LogWarning(pruneEx, "git worktree prune failed during self-heal"); }
|
||||
try { await _git.BranchDeleteAsync(workingDir, branchName, force: true, ct); }
|
||||
catch (Exception delEx) { _logger.LogWarning(delEx, "git branch -D failed during self-heal"); }
|
||||
|
||||
await _git.WorktreeAddAsync(workingDir, branchName, worktreePath, baseCommit, ct);
|
||||
}
|
||||
|
||||
// Insert worktrees row AFTER git succeeds — if git throws, no row is created.
|
||||
using var context = _dbFactory.CreateDbContext();
|
||||
var wtRepo = new WorktreeRepository(context);
|
||||
// Drop any stale row from a prior run (force-remove may have left the DB side behind).
|
||||
await wtRepo.DeleteAsync(task.Id, ct);
|
||||
await wtRepo.AddAsync(new WorktreeEntity
|
||||
{
|
||||
TaskId = task.Id,
|
||||
|
||||
128
src/ClaudeDo.Worker/Services/WorktreeMaintenanceService.cs
Normal file
128
src/ClaudeDo.Worker/Services/WorktreeMaintenanceService.cs
Normal file
@@ -0,0 +1,128 @@
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Git;
|
||||
using ClaudeDo.Data.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ClaudeDo.Worker.Services;
|
||||
|
||||
public sealed class WorktreeMaintenanceService
|
||||
{
|
||||
public sealed record CleanupResult(int Removed);
|
||||
public sealed record ResetResult(int Removed, int TasksAffected, bool Blocked, int RunningTasks);
|
||||
|
||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||
private readonly GitService _git;
|
||||
private readonly ILogger<WorktreeMaintenanceService> _logger;
|
||||
|
||||
public WorktreeMaintenanceService(
|
||||
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
||||
GitService git,
|
||||
ILogger<WorktreeMaintenanceService> logger)
|
||||
{
|
||||
_dbFactory = dbFactory;
|
||||
_git = git;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<CleanupResult> CleanupFinishedAsync(CancellationToken ct = default)
|
||||
{
|
||||
using var context = _dbFactory.CreateDbContext();
|
||||
var rows = await (from w in context.Worktrees
|
||||
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 WorktreeRow(w.TaskId, w.Path, w.BranchName, l.WorkingDir))
|
||||
.AsNoTracking()
|
||||
.ToListAsync(ct);
|
||||
|
||||
int removed = 0;
|
||||
foreach (var row in rows)
|
||||
{
|
||||
if (await TryRemoveAsync(row, force: false, ct))
|
||||
removed++;
|
||||
}
|
||||
return new CleanupResult(removed);
|
||||
}
|
||||
|
||||
public async Task<ResetResult> ResetAllAsync(CancellationToken ct = default)
|
||||
{
|
||||
using var context = _dbFactory.CreateDbContext();
|
||||
var running = await context.Tasks.AsNoTracking()
|
||||
.CountAsync(t => t.Status == ClaudeDo.Data.Models.TaskStatus.Running, ct);
|
||||
if (running > 0)
|
||||
return new ResetResult(0, 0, Blocked: true, RunningTasks: running);
|
||||
|
||||
var rows = await (from w in context.Worktrees
|
||||
join t in context.Tasks on w.TaskId equals t.Id
|
||||
join l in context.Lists on t.ListId equals l.Id
|
||||
select new WorktreeRow(w.TaskId, w.Path, w.BranchName, l.WorkingDir))
|
||||
.AsNoTracking()
|
||||
.ToListAsync(ct);
|
||||
|
||||
int removed = 0;
|
||||
foreach (var row in rows)
|
||||
{
|
||||
if (await TryRemoveAsync(row, force: true, ct))
|
||||
removed++;
|
||||
}
|
||||
return new ResetResult(removed, rows.Count, Blocked: false, RunningTasks: 0);
|
||||
}
|
||||
|
||||
private async Task<bool> TryRemoveAsync(WorktreeRow row, bool force, CancellationToken ct)
|
||||
{
|
||||
var repoDirExists = !string.IsNullOrWhiteSpace(row.WorkingDir) && Directory.Exists(row.WorkingDir);
|
||||
|
||||
if (repoDirExists)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _git.WorktreeRemoveAsync(row.WorkingDir!, row.Path, force, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"git worktree remove failed for {Path}; falling back to directory delete", row.Path);
|
||||
try { if (Directory.Exists(row.Path)) Directory.Delete(row.Path, recursive: true); }
|
||||
catch (Exception delEx)
|
||||
{
|
||||
_logger.LogError(delEx, "Directory.Delete fallback also failed for {Path}", row.Path);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
try { if (Directory.Exists(row.Path)) Directory.Delete(row.Path, recursive: true); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Directory.Delete failed for {Path}", row.Path);
|
||||
}
|
||||
}
|
||||
|
||||
// Branch cleanup: otherwise rerunning the task hits "branch already exists".
|
||||
// Prune first so git no longer thinks the branch is checked out by a phantom worktree.
|
||||
if (repoDirExists)
|
||||
{
|
||||
try { await _git.WorktreePruneAsync(row.WorkingDir!, ct); }
|
||||
catch (Exception ex) { _logger.LogWarning(ex, "git worktree prune failed for {Repo}", row.WorkingDir); }
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(row.BranchName))
|
||||
{
|
||||
try
|
||||
{
|
||||
await _git.BranchDeleteAsync(row.WorkingDir!, row.BranchName, force: true, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to delete branch {Branch} for worktree {Path}",
|
||||
row.BranchName, row.Path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
using var context = _dbFactory.CreateDbContext();
|
||||
await context.Worktrees.Where(w => w.TaskId == row.TaskId).ExecuteDeleteAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
private sealed record WorktreeRow(string TaskId, string Path, string BranchName, string? WorkingDir);
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
using ClaudeDo.Data.Git;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Worker.Services;
|
||||
using ClaudeDo.Worker.Tests.Infrastructure;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace ClaudeDo.Worker.Tests.Services;
|
||||
|
||||
public class WorktreeMaintenanceServiceTests : IDisposable
|
||||
{
|
||||
private readonly List<DbFixture> _dbs = new();
|
||||
private readonly List<GitRepoFixture> _repos = new();
|
||||
|
||||
private static bool GitAvailable => GitRepoFixture.IsGitAvailable();
|
||||
|
||||
private DbFixture NewDb() { var d = new DbFixture(); _dbs.Add(d); return d; }
|
||||
private GitRepoFixture NewRepo() { var r = new GitRepoFixture(); _repos.Add(r); return r; }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var d in _dbs) try { d.Dispose(); } catch { }
|
||||
foreach (var r in _repos) try { r.Dispose(); } catch { }
|
||||
}
|
||||
|
||||
private static (ListEntity list, TaskEntity task) MakeEntities(string workingDir, ClaudeDo.Data.Models.TaskStatus status = ClaudeDo.Data.Models.TaskStatus.Done)
|
||||
{
|
||||
var list = new ListEntity
|
||||
{
|
||||
Id = Guid.NewGuid().ToString(),
|
||||
Name = "test",
|
||||
WorkingDir = workingDir,
|
||||
DefaultCommitType = "feat",
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
var task = MakeTaskForList(list.Id, status);
|
||||
return (list, task);
|
||||
}
|
||||
|
||||
private static TaskEntity MakeTaskForList(string listId, ClaudeDo.Data.Models.TaskStatus status)
|
||||
=> new TaskEntity
|
||||
{
|
||||
Id = Guid.NewGuid().ToString(),
|
||||
ListId = listId,
|
||||
Title = "t",
|
||||
Description = null,
|
||||
Status = status,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
private static async Task<string> CreateWorktreeAsync(GitService git, string repoDir, string taskId)
|
||||
{
|
||||
var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
|
||||
var baseCommit = await git.RevParseHeadAsync(repoDir);
|
||||
await git.WorktreeAddAsync(repoDir, $"test/{taskId}", wtPath, baseCommit);
|
||||
return wtPath;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CleanupFinished_Removes_Merged_And_Discarded_But_Skips_Active()
|
||||
{
|
||||
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||
|
||||
var repo = NewRepo();
|
||||
var git = new GitService();
|
||||
var db = NewDb();
|
||||
|
||||
var (list, activeTask) = MakeEntities(repo.RepoDir);
|
||||
var mergedTask = MakeTaskForList(list.Id, ClaudeDo.Data.Models.TaskStatus.Done);
|
||||
var discardedTask = MakeTaskForList(list.Id, ClaudeDo.Data.Models.TaskStatus.Done);
|
||||
|
||||
var activeWt = await CreateWorktreeAsync(git, repo.RepoDir, activeTask.Id);
|
||||
var mergedWt = await CreateWorktreeAsync(git, repo.RepoDir, mergedTask.Id);
|
||||
var discardedWt = await CreateWorktreeAsync(git, repo.RepoDir, discardedTask.Id);
|
||||
|
||||
using (var ctx = db.CreateContext())
|
||||
{
|
||||
await new ListRepository(ctx).AddAsync(list);
|
||||
var taskRepo = new TaskRepository(ctx);
|
||||
await taskRepo.AddAsync(activeTask);
|
||||
await taskRepo.AddAsync(mergedTask);
|
||||
await taskRepo.AddAsync(discardedTask);
|
||||
var wtRepo = new WorktreeRepository(ctx);
|
||||
await wtRepo.AddAsync(new WorktreeEntity
|
||||
{
|
||||
TaskId = activeTask.Id, Path = activeWt, BranchName = $"test/{activeTask.Id}",
|
||||
BaseCommit = repo.BaseCommit, State = WorktreeState.Active, CreatedAt = DateTime.UtcNow,
|
||||
});
|
||||
await wtRepo.AddAsync(new WorktreeEntity
|
||||
{
|
||||
TaskId = mergedTask.Id, Path = mergedWt, BranchName = $"test/{mergedTask.Id}",
|
||||
BaseCommit = repo.BaseCommit, State = WorktreeState.Merged, CreatedAt = DateTime.UtcNow,
|
||||
});
|
||||
await wtRepo.AddAsync(new WorktreeEntity
|
||||
{
|
||||
TaskId = discardedTask.Id, Path = discardedWt, BranchName = $"test/{discardedTask.Id}",
|
||||
BaseCommit = repo.BaseCommit, State = WorktreeState.Discarded, CreatedAt = DateTime.UtcNow,
|
||||
});
|
||||
}
|
||||
|
||||
var svc = new WorktreeMaintenanceService(
|
||||
db.CreateFactory(), git, NullLogger<WorktreeMaintenanceService>.Instance);
|
||||
|
||||
var result = await svc.CleanupFinishedAsync();
|
||||
|
||||
Assert.Equal(2, result.Removed);
|
||||
Assert.True(Directory.Exists(activeWt));
|
||||
Assert.False(Directory.Exists(mergedWt));
|
||||
Assert.False(Directory.Exists(discardedWt));
|
||||
|
||||
using var checkCtx = db.CreateContext();
|
||||
var remaining = await new WorktreeRepository(checkCtx).GetAllAsync();
|
||||
Assert.Single(remaining);
|
||||
Assert.Equal(activeTask.Id, remaining[0].TaskId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResetAll_Blocked_When_Running_Task_Exists()
|
||||
{
|
||||
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||
|
||||
var repo = NewRepo();
|
||||
var git = new GitService();
|
||||
var db = NewDb();
|
||||
|
||||
var (list, task) = MakeEntities(repo.RepoDir, status: ClaudeDo.Data.Models.TaskStatus.Running);
|
||||
var wt = await CreateWorktreeAsync(git, repo.RepoDir, task.Id);
|
||||
|
||||
using (var ctx = db.CreateContext())
|
||||
{
|
||||
await new ListRepository(ctx).AddAsync(list);
|
||||
await new TaskRepository(ctx).AddAsync(task);
|
||||
await new WorktreeRepository(ctx).AddAsync(new WorktreeEntity
|
||||
{
|
||||
TaskId = task.Id, Path = wt, BranchName = $"test/{task.Id}",
|
||||
BaseCommit = repo.BaseCommit, State = WorktreeState.Active, CreatedAt = DateTime.UtcNow,
|
||||
});
|
||||
}
|
||||
|
||||
var svc = new WorktreeMaintenanceService(
|
||||
db.CreateFactory(), git, NullLogger<WorktreeMaintenanceService>.Instance);
|
||||
|
||||
var result = await svc.ResetAllAsync();
|
||||
|
||||
Assert.True(result.Blocked);
|
||||
Assert.Equal(1, result.RunningTasks);
|
||||
Assert.Equal(0, result.Removed);
|
||||
Assert.True(Directory.Exists(wt));
|
||||
|
||||
// Cleanup
|
||||
try { await git.WorktreeRemoveAsync(repo.RepoDir, wt, force: true); } catch { }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResetAll_Removes_All_When_No_Running_Tasks()
|
||||
{
|
||||
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||
|
||||
var repo = NewRepo();
|
||||
var git = new GitService();
|
||||
var db = NewDb();
|
||||
|
||||
var (list, t1) = MakeEntities(repo.RepoDir, status: ClaudeDo.Data.Models.TaskStatus.Done);
|
||||
var t2 = MakeTaskForList(list.Id, ClaudeDo.Data.Models.TaskStatus.Manual);
|
||||
|
||||
var wt1 = await CreateWorktreeAsync(git, repo.RepoDir, t1.Id);
|
||||
var wt2 = await CreateWorktreeAsync(git, repo.RepoDir, t2.Id);
|
||||
|
||||
using (var ctx = db.CreateContext())
|
||||
{
|
||||
await new ListRepository(ctx).AddAsync(list);
|
||||
var taskRepo = new TaskRepository(ctx);
|
||||
await taskRepo.AddAsync(t1);
|
||||
await taskRepo.AddAsync(t2);
|
||||
var wtRepo = new WorktreeRepository(ctx);
|
||||
await wtRepo.AddAsync(new WorktreeEntity
|
||||
{
|
||||
TaskId = t1.Id, Path = wt1, BranchName = $"test/{t1.Id}",
|
||||
BaseCommit = repo.BaseCommit, State = WorktreeState.Active, CreatedAt = DateTime.UtcNow,
|
||||
});
|
||||
await wtRepo.AddAsync(new WorktreeEntity
|
||||
{
|
||||
TaskId = t2.Id, Path = wt2, BranchName = $"test/{t2.Id}",
|
||||
BaseCommit = repo.BaseCommit, State = WorktreeState.Kept, CreatedAt = DateTime.UtcNow,
|
||||
});
|
||||
}
|
||||
|
||||
var svc = new WorktreeMaintenanceService(
|
||||
db.CreateFactory(), git, NullLogger<WorktreeMaintenanceService>.Instance);
|
||||
|
||||
var result = await svc.ResetAllAsync();
|
||||
|
||||
Assert.False(result.Blocked);
|
||||
Assert.Equal(2, result.Removed);
|
||||
Assert.Equal(2, result.TasksAffected);
|
||||
Assert.False(Directory.Exists(wt1));
|
||||
Assert.False(Directory.Exists(wt2));
|
||||
|
||||
using var checkCtx = db.CreateContext();
|
||||
var remaining = await new WorktreeRepository(checkCtx).GetAllAsync();
|
||||
Assert.Empty(remaining);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user