preview_merge could only see file-overlap cleanliness via git merge-tree, never whether the result compiles -- the two costliest findings of the 2026-08-06 batch run were both merge-tree-clean but build-broken. When a list has a verify command configured, a clean preview is now additionally materialized (via a commit-tree + detached scratch worktree, outside the real repo, always cleaned up) and built/tested there, without ever touching the real working tree. preview_merge always attempts a verify run when a command is configured; preview_merge_set only does when its new runVerify parameter is set (default off), so a set preview never starts N builds unasked. The post-merge verify gate is unchanged.
104 lines
4.7 KiB
C#
104 lines
4.7 KiB
C#
using ClaudeDo.Data.Git;
|
|
using ClaudeDo.Worker.Tests.Infrastructure;
|
|
|
|
namespace ClaudeDo.Worker.Tests.Runner;
|
|
|
|
public class GitServicePreviewMergeTests : IDisposable
|
|
{
|
|
private readonly List<GitRepoFixture> _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));
|
|
}
|
|
}
|