feat(queue): warn when the base branch has uncommitted changes

Queuing (update_task_status, batch_update_task_status) and run_task_now
now surface a non-blocking baseDirty warning (separate modified/untracked
counts) when the list's working dir has uncommitted changes at enqueue
time, since a new worktree forks from the commit tip and silently misses
them. BaseDirtyChecker caches per working dir for a few seconds so a
batch queue over many tasks in one list only shells out to git once. The
UI surfaces the same warning via the footer error strip on queue actions.
This commit is contained in:
mika kuns
2026-08-10 14:30:33 +02:00
parent 6a2a19cc9e
commit d3155e6868
23 changed files with 481 additions and 32 deletions
@@ -2,6 +2,7 @@ using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.External;
using ClaudeDo.Worker.Git;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Lifecycle;
using ClaudeDo.Worker.Planning;
@@ -86,7 +87,8 @@ public sealed class AddSubtaskToolTests : IDisposable
return new ExternalMcpService(
_tasks, _lists, queue, broadcaster,
state,
git, dbFactory, maintenance, merge, planningMerge);
git, dbFactory, maintenance, merge, planningMerge,
new BaseDirtyChecker(git, NullLogger<BaseDirtyChecker>.Instance));
}
[Fact]
+35 -3
View File
@@ -4,6 +4,7 @@ using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Config;
using ClaudeDo.Worker.External;
using ClaudeDo.Worker.Git;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Lifecycle;
using ClaudeDo.Worker.Planning;
@@ -24,6 +25,9 @@ public sealed class BatchMcpToolsTests : IDisposable
private readonly TaskRepository _tasks;
private readonly ListRepository _lists;
private readonly HubBroadcaster _broadcaster;
private readonly List<GitRepoFixture> _repos = new();
private static bool GitAvailable => GitRepoFixture.IsGitAvailable();
public BatchMcpToolsTests()
{
@@ -35,14 +39,15 @@ public sealed class BatchMcpToolsTests : IDisposable
public void Dispose()
{
foreach (var r in _repos) r.Dispose();
_ctx.Dispose();
_db.Dispose();
}
private async Task<string> SeedListAsync()
private async Task<string> SeedListAsync(string? workingDir = null)
{
var id = Guid.NewGuid().ToString();
await _lists.AddAsync(new ListEntity { Id = id, Name = "L", CreatedAt = DateTime.UtcNow });
await _lists.AddAsync(new ListEntity { Id = id, Name = "L", CreatedAt = DateTime.UtcNow, WorkingDir = workingDir });
return id;
}
@@ -74,7 +79,8 @@ public sealed class BatchMcpToolsTests : IDisposable
var svc = new ExternalMcpService(
_tasks, _lists, CreateQueue(), _broadcaster,
state,
git, factory, maintenance, merge, planningMerge);
git, factory, maintenance, merge, planningMerge,
new BaseDirtyChecker(git, NullLogger<BaseDirtyChecker>.Instance));
return new BatchMcpTools(svc);
}
@@ -247,6 +253,32 @@ public sealed class BatchMcpToolsTests : IDisposable
Assert.Equal(TaskStatus.Queued, (await _tasks.GetByIdAsync(t2.Id))!.Status);
}
[Fact]
public async Task BatchUpdateTaskStatus_DirtyBaseRepo_ReportsWarningPerItem_OneListOneRepo()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var repo = new GitRepoFixture();
_repos.Add(repo);
File.WriteAllText(Path.Combine(repo.RepoDir, "scratch.txt"), "new");
var listId = await SeedListAsync(repo.RepoDir);
var t1 = await SeedTaskAsync(listId, "a", TaskStatus.Idle);
var t2 = await SeedTaskAsync(listId, "b", TaskStatus.Idle);
var sut = BuildSut();
// Two tasks queued from the same list -- BaseDirtyChecker's TTL cache means only one
// `git status` actually runs underneath, but both items still see the warning.
var results = await sut.BatchUpdateTaskStatus(new[] { t1.Id, t2.Id }, "Queued", CancellationToken.None);
Assert.All(results, r => Assert.True(r.Ok));
Assert.All(results, r =>
{
Assert.NotNull(r.BaseDirty);
Assert.Equal(1, r.BaseDirty!.UntrackedCount);
});
}
[Fact]
public async Task BatchUpdateTaskStatus_Done_MixedWorktreeState_ReportsPerItemAndDoesNotAbort()
{
@@ -4,6 +4,7 @@ using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Config;
using ClaudeDo.Worker.External;
using ClaudeDo.Worker.Git;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Lifecycle;
using ClaudeDo.Worker.Planning;
@@ -136,7 +137,8 @@ public sealed class ExternalMcpServiceTests : IDisposable
return new ExternalMcpService(
_tasks, _lists, queue, _broadcaster,
state,
git, factory, maintenance, merge, planningMerge);
git, factory, maintenance, merge, planningMerge,
new BaseDirtyChecker(git, NullLogger<BaseDirtyChecker>.Instance));
}
private QueueService CreateQueue()