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,88 @@
using System.Collections.Concurrent;
using ClaudeDo.Data.Git;
namespace ClaudeDo.Worker.Git;
public sealed record DirtyBaseWarning(int ModifiedCount, int UntrackedCount);
/// <summary>
/// Detects uncommitted changes in a list's working directory before a task forks a worktree
/// from HEAD. WorktreeManager.ResolveBaseCommitAsync forks from the commit tip, never the
/// working tree, so local edits sitting there are silently invisible to every task queued
/// against that list -- each one starts without them and can collide re-creating the same
/// files. Non-blocking by design: callers surface the counts as a heads-up, never refuse the
/// transition.
/// </summary>
public interface IBaseDirtyChecker
{
Task<DirtyBaseWarning?> CheckAsync(string? workingDir, CancellationToken ct);
}
public sealed class BaseDirtyChecker : IBaseDirtyChecker
{
// Long enough to collapse every task in one batch_update_task_status call over the same
// list into a single `git status` invocation; short enough that a later, independent queue
// action re-checks instead of trusting a stale answer.
private static readonly TimeSpan CacheTtl = TimeSpan.FromSeconds(10);
private readonly GitService _git;
private readonly ILogger<BaseDirtyChecker> _logger;
private readonly ConcurrentDictionary<string, (DateTime ExpiresAtUtc, DirtyBaseWarning? Warning)> _cache = new();
public BaseDirtyChecker(GitService git, ILogger<BaseDirtyChecker> logger)
{
_git = git;
_logger = logger;
}
public async Task<DirtyBaseWarning?> CheckAsync(string? workingDir, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(workingDir))
return null;
string key;
try { key = Path.GetFullPath(workingDir); }
catch { return null; }
if (_cache.TryGetValue(key, out var cached) && cached.ExpiresAtUtc > DateTime.UtcNow)
return cached.Warning;
var warning = await ComputeAsync(key, ct);
_cache[key] = (DateTime.UtcNow.Add(CacheTtl), warning);
return warning;
}
private async Task<DirtyBaseWarning?> ComputeAsync(string workingDir, CancellationToken ct)
{
try
{
if (!await _git.IsGitRepoAsync(workingDir, ct))
return null;
var porcelain = await _git.GetStatusPorcelainAsync(workingDir, ct);
return Parse(porcelain);
}
catch (OperationCanceledException) { throw; }
catch (Exception ex)
{
// Never blocks a queue/run transition on a check that only exists to warn.
_logger.LogWarning(ex, "Base-dirty check failed for {WorkingDir}", workingDir);
return null;
}
}
private static DirtyBaseWarning? Parse(string porcelain)
{
if (string.IsNullOrWhiteSpace(porcelain)) return null;
int modified = 0, untracked = 0;
foreach (var raw in porcelain.Split('\n', StringSplitOptions.RemoveEmptyEntries))
{
var line = raw.TrimEnd('\r');
if (line.Length == 0) continue;
if (line.StartsWith("??", StringComparison.Ordinal)) untracked++;
else modified++;
}
return modified == 0 && untracked == 0 ? null : new DirtyBaseWarning(modified, untracked);
}
}