feat(merge): read conflict stages and write user resolutions
This commit is contained in:
@@ -23,6 +23,16 @@ public sealed record MergePreviewResult(
|
|||||||
IReadOnlyList<string> ConflictFiles,
|
IReadOnlyList<string> ConflictFiles,
|
||||||
int ChangedFileCount);
|
int ChangedFileCount);
|
||||||
|
|
||||||
|
public sealed record MergeConflicts(
|
||||||
|
string TaskId,
|
||||||
|
IReadOnlyList<ConflictFileContent> Files);
|
||||||
|
|
||||||
|
public sealed record ConflictFileContent(
|
||||||
|
string Path,
|
||||||
|
string Ours,
|
||||||
|
string Theirs,
|
||||||
|
string? Base);
|
||||||
|
|
||||||
public sealed class TaskMergeService
|
public sealed class TaskMergeService
|
||||||
{
|
{
|
||||||
public const string StatusMerged = "merged";
|
public const string StatusMerged = "merged";
|
||||||
@@ -217,6 +227,35 @@ public sealed class TaskMergeService
|
|||||||
return new MergeResult(StatusAborted, Array.Empty<string>(), null);
|
return new MergeResult(StatusAborted, Array.Empty<string>(), null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<MergeConflicts> GetConflictsAsync(string taskId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var (_, list, _) = await LoadMergeContextAsync(taskId, ct);
|
||||||
|
if (string.IsNullOrWhiteSpace(list.WorkingDir))
|
||||||
|
throw new InvalidOperationException("list has no working directory");
|
||||||
|
|
||||||
|
var files = await _git.ListConflictedFilesAsync(list.WorkingDir, ct);
|
||||||
|
var result = new List<ConflictFileContent>(files.Count);
|
||||||
|
foreach (var path in files)
|
||||||
|
{
|
||||||
|
var ours = await _git.ShowStageAsync(list.WorkingDir, 2, path, ct) ?? "";
|
||||||
|
var theirs = await _git.ShowStageAsync(list.WorkingDir, 3, path, ct) ?? "";
|
||||||
|
var @base = await _git.ShowStageAsync(list.WorkingDir, 1, path, ct);
|
||||||
|
result.Add(new ConflictFileContent(path, ours, theirs, @base));
|
||||||
|
}
|
||||||
|
return new MergeConflicts(taskId, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task WriteResolutionAsync(string taskId, string path, string content, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var (_, list, _) = await LoadMergeContextAsync(taskId, ct);
|
||||||
|
if (string.IsNullOrWhiteSpace(list.WorkingDir))
|
||||||
|
throw new InvalidOperationException("list has no working directory");
|
||||||
|
|
||||||
|
var full = Path.Combine(list.WorkingDir, path.Replace('/', Path.DirectorySeparatorChar));
|
||||||
|
await File.WriteAllTextAsync(full, content, ct);
|
||||||
|
await _git.AddPathAsync(list.WorkingDir, path, ct);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<MergeTargets> GetTargetsAsync(string taskId, CancellationToken ct)
|
public async Task<MergeTargets> GetTargetsAsync(string taskId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var (_, list, _) = await LoadMergeContextAsync(taskId, ct);
|
var (_, list, _) = await LoadMergeContextAsync(taskId, ct);
|
||||||
|
|||||||
@@ -621,6 +621,72 @@ public class TaskMergeServiceTests : IDisposable
|
|||||||
// Cleanup
|
// Cleanup
|
||||||
GitRepoFixture.RunGit(repo.RepoDir, "merge", "--abort");
|
GitRepoFixture.RunGit(repo.RepoDir, "merge", "--abort");
|
||||||
}
|
}
|
||||||
|
[Fact]
|
||||||
|
public async Task GetConflictsAsync_AfterConflictMerge_ReturnsOursAndTheirs()
|
||||||
|
{
|
||||||
|
if (!GitRepoFixture.IsGitAvailable()) return;
|
||||||
|
|
||||||
|
var db = NewDb();
|
||||||
|
var repo = NewRepo();
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
|
||||||
|
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# main change\n");
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-am", "main change");
|
||||||
|
|
||||||
|
var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
|
||||||
|
_wtCleanups.Add((repo.RepoDir, wtPath));
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", "claudedo/c1", wtPath, repo.BaseCommit);
|
||||||
|
File.WriteAllText(Path.Combine(wtPath, "README.md"), "# branch change\n");
|
||||||
|
GitRepoFixture.RunGit(wtPath, "commit", "-am", "branch change");
|
||||||
|
|
||||||
|
var (_, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.WaitingForReview);
|
||||||
|
await SeedWorktree(db, task.Id, wtPath, "claudedo/c1", repo.BaseCommit);
|
||||||
|
|
||||||
|
var (svc, _) = BuildService(db);
|
||||||
|
var start = await svc.MergeAsync(task.Id, "main", false, "msg", leaveConflictsInTree: true, CancellationToken.None);
|
||||||
|
Assert.Equal(TaskMergeService.StatusConflict, start.Status);
|
||||||
|
|
||||||
|
var conflicts = await svc.GetConflictsAsync(task.Id, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(task.Id, conflicts.TaskId);
|
||||||
|
var file = Assert.Single(conflicts.Files);
|
||||||
|
Assert.Equal("README.md", file.Path);
|
||||||
|
Assert.Contains("main change", file.Ours);
|
||||||
|
Assert.Contains("branch change", file.Theirs);
|
||||||
|
Assert.NotNull(file.Base);
|
||||||
|
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "merge", "--abort");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task WriteResolutionAsync_ThenContinue_CompletesMerge()
|
||||||
|
{
|
||||||
|
if (!GitRepoFixture.IsGitAvailable()) return;
|
||||||
|
|
||||||
|
var db = NewDb();
|
||||||
|
var repo = NewRepo();
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
|
||||||
|
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# main change\n");
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-am", "main change");
|
||||||
|
|
||||||
|
var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
|
||||||
|
_wtCleanups.Add((repo.RepoDir, wtPath));
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", "claudedo/c2", wtPath, repo.BaseCommit);
|
||||||
|
File.WriteAllText(Path.Combine(wtPath, "README.md"), "# branch change\n");
|
||||||
|
GitRepoFixture.RunGit(wtPath, "commit", "-am", "branch change");
|
||||||
|
|
||||||
|
var (_, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.WaitingForReview);
|
||||||
|
await SeedWorktree(db, task.Id, wtPath, "claudedo/c2", repo.BaseCommit);
|
||||||
|
|
||||||
|
var (svc, _) = BuildService(db);
|
||||||
|
await svc.MergeAsync(task.Id, "main", false, "msg", leaveConflictsInTree: true, CancellationToken.None);
|
||||||
|
|
||||||
|
await svc.WriteResolutionAsync(task.Id, "README.md", "# resolved by user\n", CancellationToken.None);
|
||||||
|
var result = await svc.ContinueMergeAsync(task.Id, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(TaskMergeService.StatusMerged, result.Status);
|
||||||
|
Assert.Equal("# resolved by user\n", File.ReadAllText(Path.Combine(repo.RepoDir, "README.md")));
|
||||||
|
Assert.False(await new GitService().IsMidMergeAsync(repo.RepoDir));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#region Test doubles
|
#region Test doubles
|
||||||
|
|||||||
Reference in New Issue
Block a user