Merge branch 'claudedo/9f963a215bb34ca4a28050d4f39178d3'
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