Merge branch 'claudedo/9f963a215bb34ca4a28050d4f39178d3'

This commit is contained in:
mika kuns
2026-08-10 15:19:57 +02:00
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);
}
}