using ClaudeDo.Data.Git; using ClaudeDo.Worker.Tests.Infrastructure; namespace ClaudeDo.Worker.Tests.Runner; public class GitServicePreviewMergeTests : IDisposable { private readonly List _repos = new(); private GitRepoFixture NewRepo() { var r = new GitRepoFixture(); _repos.Add(r); return r; } public void Dispose() { foreach (var r in _repos) try { r.Dispose(); } catch { } } [Fact] public async Task PreviewMergeAsync_NonConflicting_ReportsCleanWithChangedCount() { if (!GitRepoFixture.IsGitAvailable()) return; var repo = NewRepo(); var git = new GitService(); var baseBranch = await git.GetCurrentBranchAsync(repo.RepoDir); GitRepoFixture.RunGit(repo.RepoDir, "checkout", "-b", "feature"); File.WriteAllText(Path.Combine(repo.RepoDir, "newfile.txt"), "x\n"); GitRepoFixture.RunGit(repo.RepoDir, "add", "-A"); GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "feat"); GitRepoFixture.RunGit(repo.RepoDir, "checkout", baseBranch); var preview = await git.PreviewMergeAsync(repo.RepoDir, baseBranch, "feature", CancellationToken.None); Assert.True(preview.Supported); Assert.True(preview.Clean); Assert.Empty(preview.ConflictFiles); var count = await git.CountChangedFilesAsync(repo.RepoDir, baseBranch, "feature", CancellationToken.None); Assert.Equal(1, count); } [Fact] public async Task PreviewMergeAsync_Conflicting_ReportsFilesAndDoesNotMutateTree() { if (!GitRepoFixture.IsGitAvailable()) return; var repo = NewRepo(); var git = new GitService(); var baseBranch = await git.GetCurrentBranchAsync(repo.RepoDir); GitRepoFixture.RunGit(repo.RepoDir, "checkout", "-b", "feature"); File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# from feature\n"); GitRepoFixture.RunGit(repo.RepoDir, "add", "-A"); GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "feat readme"); GitRepoFixture.RunGit(repo.RepoDir, "checkout", baseBranch); File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# from base\n"); GitRepoFixture.RunGit(repo.RepoDir, "add", "-A"); GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "base readme"); var headBefore = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim(); var preview = await git.PreviewMergeAsync(repo.RepoDir, baseBranch, "feature", CancellationToken.None); Assert.True(preview.Supported); Assert.False(preview.Clean); Assert.Contains("README.md", preview.ConflictFiles); Assert.Equal(headBefore, GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim()); Assert.False(await git.IsMidMergeAsync(repo.RepoDir)); } [Fact] public async Task PreviewMergeAsync_Clean_ReturnsTreeOidMaterializableIntoDetachedWorktree() { if (!GitRepoFixture.IsGitAvailable()) return; var repo = NewRepo(); var git = new GitService(); var baseBranch = await git.GetCurrentBranchAsync(repo.RepoDir); GitRepoFixture.RunGit(repo.RepoDir, "checkout", "-b", "feature"); File.WriteAllText(Path.Combine(repo.RepoDir, "newfile.txt"), "x\n"); GitRepoFixture.RunGit(repo.RepoDir, "add", "-A"); GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "feat"); GitRepoFixture.RunGit(repo.RepoDir, "checkout", baseBranch); var preview = await git.PreviewMergeAsync(repo.RepoDir, baseBranch, "feature", CancellationToken.None); Assert.True(preview.Clean); Assert.False(string.IsNullOrWhiteSpace(preview.TreeOid)); // The tree a clean preview would produce can be wrapped in a commit and checked out into // a detached, branchless scratch worktree -- the technique the verify-in-preview feature // relies on -- without ever touching the real working tree. var parentSha = await git.RevParseAsync(repo.RepoDir, baseBranch, CancellationToken.None); var commitSha = await git.CommitTreeAsync(repo.RepoDir, preview.TreeOid!, parentSha, "scratch", CancellationToken.None); var scratchPath = Path.Combine(Path.GetTempPath(), $"scratch_{Guid.NewGuid():N}"); await git.WorktreeAddDetachedAsync(repo.RepoDir, scratchPath, commitSha, CancellationToken.None); try { Assert.True(File.Exists(Path.Combine(scratchPath, "newfile.txt"))); } finally { await git.WorktreeRemoveAsync(repo.RepoDir, scratchPath, force: true, CancellationToken.None); } Assert.False(Directory.Exists(scratchPath)); Assert.False(await git.HasChangesAsync(repo.RepoDir, CancellationToken.None)); } }