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 _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.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); } }