feat(merge): real conflict-hunk parsing pipeline (chunk 2 backend)
Replace the whole-file conflict model with line-level hunks, the foundation for the full in-app merge editor. - ConflictMarkerParser: parses git conflict markers (incl. diff3 base) into ordered stable/conflict MergeSegments; exact round-trip + Compose - GitService.MergeNoFfAsync passes -c merge.conflictStyle=diff3 so the working tree carries the merge base in conflict markers - TaskMergeService.GetConflictDocumentsAsync: reads each conflicted file, parses into segments, flags binary files - hub GetMergeConflictDocuments + DTOs (MergeConflictDocumentsDto/ ConflictDocumentDto/MergeSegmentDto), IWorkerClient + both fakes - tests: 8 parser unit tests + a real-git integration test asserting line-level hunks with a diff3 base
This commit is contained in:
@@ -61,6 +61,9 @@ public record MergeTargetsDto(string DefaultBranch, IReadOnlyList<string> LocalB
|
||||
public record MergeConflictsDto(string TaskId, IReadOnlyList<ConflictFileDto> Files);
|
||||
public record ConflictFileDto(string Path, IReadOnlyList<ConflictHunkDto> Hunks);
|
||||
public record ConflictHunkDto(string Ours, string Theirs, string? Base);
|
||||
public record MergeConflictDocumentsDto(string TaskId, IReadOnlyList<ConflictDocumentDto> Files);
|
||||
public record ConflictDocumentDto(string Path, bool IsBinary, IReadOnlyList<MergeSegmentDto> Segments);
|
||||
public record MergeSegmentDto(bool IsConflict, string Text, string Ours, string? Base, string Theirs);
|
||||
public record UpdateListDto(string Id, string Name, string? WorkingDir, string DefaultCommitType);
|
||||
public record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null);
|
||||
public record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null);
|
||||
@@ -383,6 +386,18 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
new[] { new ConflictHunkDto(f.Ours, f.Theirs, f.Base) })).ToList());
|
||||
});
|
||||
|
||||
public Task<MergeConflictDocumentsDto> GetMergeConflictDocuments(string taskId)
|
||||
=> HubGuard(async () =>
|
||||
{
|
||||
var c = await _mergeService.GetConflictDocumentsAsync(taskId, CancellationToken.None);
|
||||
return new MergeConflictDocumentsDto(
|
||||
c.TaskId,
|
||||
c.Files.Select(f => new ConflictDocumentDto(
|
||||
f.Path, f.IsBinary,
|
||||
f.Segments.Select(s => new MergeSegmentDto(
|
||||
s.IsConflict, s.Text, s.Ours, s.Base, s.Theirs)).ToList())).ToList());
|
||||
});
|
||||
|
||||
public Task WriteConflictResolution(string taskId, string path, string resolvedContent)
|
||||
=> HubGuard(() => _mergeService.WriteResolutionAsync(
|
||||
taskId, path, resolvedContent ?? "", CancellationToken.None));
|
||||
|
||||
@@ -33,6 +33,15 @@ public sealed record ConflictFileContent(
|
||||
string Theirs,
|
||||
string? Base);
|
||||
|
||||
public sealed record ConflictDocuments(
|
||||
string TaskId,
|
||||
IReadOnlyList<ConflictDocumentContent> Files);
|
||||
|
||||
public sealed record ConflictDocumentContent(
|
||||
string Path,
|
||||
bool IsBinary,
|
||||
IReadOnlyList<MergeSegment> Segments);
|
||||
|
||||
public sealed class TaskMergeService
|
||||
{
|
||||
public const string StatusMerged = "merged";
|
||||
@@ -256,6 +265,45 @@ public sealed class TaskMergeService
|
||||
return new MergeConflicts(taskId, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads each conflicted working-tree file and parses its conflict markers into line-level
|
||||
/// segments (with the diff3 merge base when present). Binary files are flagged and skipped.
|
||||
/// </summary>
|
||||
public async Task<ConflictDocuments> GetConflictDocumentsAsync(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<ConflictDocumentContent>(files.Count);
|
||||
foreach (var path in files)
|
||||
{
|
||||
var full = Path.Combine(list.WorkingDir, path.Replace('/', Path.DirectorySeparatorChar));
|
||||
string text;
|
||||
try { text = await File.ReadAllTextAsync(full, ct); }
|
||||
catch { text = ""; }
|
||||
|
||||
if (LooksBinary(text))
|
||||
{
|
||||
result.Add(new ConflictDocumentContent(path, true, Array.Empty<MergeSegment>()));
|
||||
continue;
|
||||
}
|
||||
|
||||
result.Add(new ConflictDocumentContent(path, false, ConflictMarkerParser.Parse(text)));
|
||||
}
|
||||
return new ConflictDocuments(taskId, result);
|
||||
}
|
||||
|
||||
// A NUL byte in the head of the file is the conventional binary sniff.
|
||||
private static bool LooksBinary(string text)
|
||||
{
|
||||
var n = Math.Min(text.Length, 8000);
|
||||
for (var i = 0; i < n; i++)
|
||||
if (text[i] == '\0') return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
public async Task WriteResolutionAsync(string taskId, string path, string content, CancellationToken ct)
|
||||
{
|
||||
var (_, list, _) = await LoadMergeContextAsync(taskId, ct);
|
||||
|
||||
Reference in New Issue
Block a user