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:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user