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
@@ -1,10 +1,12 @@
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Git;
using ClaudeDo.Worker.Planning;
using ClaudeDo.Worker.State;
using ClaudeDo.Worker.Tests.Infrastructure;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Worker.Tests.State;
@@ -22,6 +24,9 @@ public sealed class TaskStateServiceTests : IDisposable
private readonly TaskStateServiceBuilder.Built _built;
private readonly ITaskStateService _sut;
private readonly string _listId;
private readonly List<GitRepoFixture> _repos = new();
private static bool GitAvailable => GitRepoFixture.IsGitAvailable();
public TaskStateServiceTests()
{
@@ -41,7 +46,19 @@ public sealed class TaskStateServiceTests : IDisposable
ctx.SaveChanges();
}
public void Dispose() => _db.Dispose();
public void Dispose()
{
foreach (var r in _repos) r.Dispose();
_db.Dispose();
}
private async Task SetListWorkingDirAsync(string workingDir)
{
await using var ctx = _factory.CreateDbContext();
var list = await ctx.Lists.FirstAsync(l => l.Id == _listId);
list.WorkingDir = workingDir;
await ctx.SaveChangesAsync();
}
private async Task<string> SeedTaskAsync(
TaskStatus status,
@@ -98,6 +115,74 @@ public sealed class TaskStateServiceTests : IDisposable
Assert.Contains(_built.Hub.Proxy.Calls, c => c.Method == "TaskUpdated");
}
[Fact]
public async Task EnqueueAsync_NoWorkingDir_QueuesNormally_NoBaseDirtyWarning()
{
// The list created in the constructor has no WorkingDir -- covers the
// "no valid repo" acceptance case: queuing must not throw or block.
var id = await SeedTaskAsync(TaskStatus.Idle);
var result = await _sut.EnqueueAsync(id, default);
Assert.True(result.Ok);
Assert.Null(result.BaseDirty);
Assert.Equal(TaskStatus.Queued, await GetStatusAsync(id));
}
[Fact]
public async Task EnqueueAsync_CleanBaseRepo_NoBaseDirtyWarning()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var repo = new GitRepoFixture();
_repos.Add(repo);
await SetListWorkingDirAsync(repo.RepoDir);
var id = await SeedTaskAsync(TaskStatus.Idle);
var result = await _sut.EnqueueAsync(id, default);
Assert.True(result.Ok);
Assert.Null(result.BaseDirty);
}
[Fact]
public async Task EnqueueAsync_UntrackedFileInBaseRepo_ReturnsBaseDirtyWarning()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var repo = new GitRepoFixture();
_repos.Add(repo);
File.WriteAllText(Path.Combine(repo.RepoDir, "new-file.txt"), "not committed");
await SetListWorkingDirAsync(repo.RepoDir);
var id = await SeedTaskAsync(TaskStatus.Idle);
var result = await _sut.EnqueueAsync(id, default);
Assert.True(result.Ok);
Assert.NotNull(result.BaseDirty);
Assert.Equal(0, result.BaseDirty!.ModifiedCount);
Assert.Equal(1, result.BaseDirty!.UntrackedCount);
}
[Fact]
public async Task EnqueueAsync_ModifiedFileInBaseRepo_ReturnsBaseDirtyWarning()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var repo = new GitRepoFixture();
_repos.Add(repo);
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "changed on disk");
await SetListWorkingDirAsync(repo.RepoDir);
var id = await SeedTaskAsync(TaskStatus.Idle);
var result = await _sut.EnqueueAsync(id, default);
Assert.True(result.Ok);
Assert.NotNull(result.BaseDirty);
Assert.Equal(1, result.BaseDirty!.ModifiedCount);
Assert.Equal(0, result.BaseDirty!.UntrackedCount);
}
[Fact]
public async Task EnqueueAsync_ManualTask_Rejected_AndStaysIdle()
{