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
@@ -0,0 +1,135 @@
using ClaudeDo.Data.Git;
using ClaudeDo.Worker.Git;
using ClaudeDo.Worker.Tests.Infrastructure;
using Microsoft.Extensions.Logging.Abstractions;
namespace ClaudeDo.Worker.Tests.Git;
public sealed class BaseDirtyCheckerTests : IDisposable
{
private readonly List<GitRepoFixture> _repos = new();
private static bool GitAvailable => GitRepoFixture.IsGitAvailable();
public void Dispose()
{
foreach (var r in _repos) r.Dispose();
}
private GitRepoFixture CreateRepo()
{
var repo = new GitRepoFixture();
_repos.Add(repo);
return repo;
}
private static BaseDirtyChecker CreateSut() =>
new(new GitService(), NullLogger<BaseDirtyChecker>.Instance);
[Fact]
public async Task CheckAsync_NullWorkingDir_ReturnsNull()
{
var sut = CreateSut();
Assert.Null(await sut.CheckAsync(null, default));
Assert.Null(await sut.CheckAsync("", default));
Assert.Null(await sut.CheckAsync(" ", default));
}
[Fact]
public async Task CheckAsync_NotAGitRepo_ReturnsNull()
{
var dir = Path.Combine(Path.GetTempPath(), $"claudedo_notgit_{Guid.NewGuid():N}");
Directory.CreateDirectory(dir);
try
{
var sut = CreateSut();
Assert.Null(await sut.CheckAsync(dir, default));
}
finally
{
Directory.Delete(dir, true);
}
}
[Fact]
public async Task CheckAsync_CleanRepo_ReturnsNull()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var repo = CreateRepo();
var sut = CreateSut();
Assert.Null(await sut.CheckAsync(repo.RepoDir, default));
}
[Fact]
public async Task CheckAsync_UntrackedFile_ReturnsUntrackedCountOnly()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var repo = CreateRepo();
File.WriteAllText(Path.Combine(repo.RepoDir, "scratch.txt"), "new");
var sut = CreateSut();
var warning = await sut.CheckAsync(repo.RepoDir, default);
Assert.NotNull(warning);
Assert.Equal(0, warning!.ModifiedCount);
Assert.Equal(1, warning.UntrackedCount);
}
[Fact]
public async Task CheckAsync_ModifiedTrackedFile_ReturnsModifiedCountOnly()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var repo = CreateRepo();
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "edited");
var sut = CreateSut();
var warning = await sut.CheckAsync(repo.RepoDir, default);
Assert.NotNull(warning);
Assert.Equal(1, warning!.ModifiedCount);
Assert.Equal(0, warning.UntrackedCount);
}
[Fact]
public async Task CheckAsync_ModifiedAndUntracked_ReportsBothSeparately()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var repo = CreateRepo();
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "edited");
File.WriteAllText(Path.Combine(repo.RepoDir, "scratch.txt"), "new");
var sut = CreateSut();
var warning = await sut.CheckAsync(repo.RepoDir, default);
Assert.NotNull(warning);
Assert.Equal(1, warning!.ModifiedCount);
Assert.Equal(1, warning.UntrackedCount);
}
// Proves the TTL cache collapses repeated checks against the same working dir into a
// single underlying `git status` call -- the acceptance requirement that a batch queue
// op over many tasks in one list must not shell out once per task. A second check
// immediately after a repo mutation still returns the first (stale) answer instead of
// re-running git.
[Fact]
public async Task CheckAsync_RepeatedCallsWithinTtl_ReuseCachedResult()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var repo = CreateRepo();
var sut = CreateSut();
var first = await sut.CheckAsync(repo.RepoDir, default);
Assert.Null(first);
File.WriteAllText(Path.Combine(repo.RepoDir, "scratch.txt"), "new");
var second = await sut.CheckAsync(repo.RepoDir, default);
Assert.Null(second);
}
}