feat(git): add non-destructive merge-tree conflict probe
This commit is contained in:
@@ -3,6 +3,8 @@ using System.Text;
|
||||
|
||||
namespace ClaudeDo.Data.Git;
|
||||
|
||||
public sealed record MergePreview(bool Supported, bool Clean, IReadOnlyList<string> ConflictFiles);
|
||||
|
||||
public sealed class GitService
|
||||
{
|
||||
public async Task<bool> IsGitRepoAsync(string dir, CancellationToken ct = default)
|
||||
@@ -236,6 +238,49 @@ public sealed class GitService
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Non-destructive mergeability probe via `git merge-tree --write-tree`. Writes only
|
||||
/// loose objects — the working tree, index, and refs are left untouched.
|
||||
/// </summary>
|
||||
public async Task<MergePreview> PreviewMergeAsync(
|
||||
string repoDir, string targetBranch, string sourceBranch, CancellationToken ct = default)
|
||||
{
|
||||
var (exitCode, stdout, _) = await RunGitAsync(repoDir,
|
||||
["merge-tree", "--write-tree", "--name-only", targetBranch, sourceBranch], ct);
|
||||
|
||||
if (exitCode == 0)
|
||||
return new MergePreview(true, true, Array.Empty<string>());
|
||||
|
||||
if (exitCode == 1)
|
||||
{
|
||||
// stdout: <tree-oid>\n<file>\n...\n\n<informational messages>
|
||||
var lines = stdout.Split('\n');
|
||||
var files = new List<string>();
|
||||
for (int i = 1; i < lines.Length; i++)
|
||||
{
|
||||
var line = lines[i].TrimEnd('\r');
|
||||
if (string.IsNullOrWhiteSpace(line)) break;
|
||||
files.Add(line.Trim());
|
||||
}
|
||||
return new MergePreview(true, false, files);
|
||||
}
|
||||
|
||||
// Any other exit (e.g. git too old: "unknown option --write-tree").
|
||||
return new MergePreview(false, false, Array.Empty<string>());
|
||||
}
|
||||
|
||||
/// <summary>Count of files that differ on <paramref name="sourceBranch"/> since its merge base with the target.</summary>
|
||||
public async Task<int> CountChangedFilesAsync(
|
||||
string repoDir, string targetBranch, string sourceBranch, CancellationToken ct = default)
|
||||
{
|
||||
var (exitCode, stdout, _) = await RunGitAsync(repoDir,
|
||||
["diff", "--name-only", $"{targetBranch}...{sourceBranch}"], ct);
|
||||
if (exitCode != 0) return 0;
|
||||
return stdout
|
||||
.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Count(s => s.Length > 0);
|
||||
}
|
||||
|
||||
public async Task MergeFfOnlyAsync(string repoDir, string branchName, CancellationToken ct = default)
|
||||
{
|
||||
var (exitCode, _, stderr) = await RunGitAsync(repoDir, ["merge", "--ff-only", branchName], ct);
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user