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;
|
namespace ClaudeDo.Data.Git;
|
||||||
|
|
||||||
|
public sealed record MergePreview(bool Supported, bool Clean, IReadOnlyList<string> ConflictFiles);
|
||||||
|
|
||||||
public sealed class GitService
|
public sealed class GitService
|
||||||
{
|
{
|
||||||
public async Task<bool> IsGitRepoAsync(string dir, CancellationToken ct = default)
|
public async Task<bool> IsGitRepoAsync(string dir, CancellationToken ct = default)
|
||||||
@@ -236,6 +238,49 @@ public sealed class GitService
|
|||||||
.ToList();
|
.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)
|
public async Task MergeFfOnlyAsync(string repoDir, string branchName, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var (exitCode, _, stderr) = await RunGitAsync(repoDir, ["merge", "--ff-only", branchName], ct);
|
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