Skip to content

Commit 5f0682f

Browse files
authored
Improve conflict resolution (#6267)
#6265 It looks like some git weirdness is happening in the vstest flows. We're rewinding previous flows, and by doing so the work branch is "losing" some changes outside of the `src/repo` we're flowing to, as expected. But when we try to rebase the workbranch on top of the origin branch, these files that we're lost on the workbranch conflict with the ones in the target branch. Not sure what is causing this but it's easy enough to fix. I'm also failing to replicate in a test. I think it might have to do something with the unsafe flow? And git is not able to figure it out. In any case this fixes the issue
1 parent 4f6ac55 commit 5f0682f

4 files changed

Lines changed: 593 additions & 51 deletions

File tree

src/Microsoft.DotNet.Darc/DarcLib/LocalGitClient.cs

Lines changed: 66 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -613,11 +613,73 @@ public async Task<bool> IsAncestorCommit(string repoPath, string ancestor, strin
613613

614614
public async Task ResolveConflict(string repoPath, string file, bool ours)
615615
{
616-
var result = await _processManager.ExecuteGit(repoPath, "checkout", ours ? "--ours" : "--theirs", file);
617-
result.ThrowIfFailed($"Failed to resolve conflict in {file} in {repoPath}");
616+
// Use porcelain status to determine the conflict shape. The first two
617+
// characters (XY) tell us what each side did. For unmerged paths:
618+
//
619+
// pair meaning stage 2 (us) stage 3 (them)
620+
// ---- ------------------- ------------ --------------
621+
// DD both deleted - -
622+
// AU added by us yes -
623+
// UD deleted by them yes -
624+
// UA added by them - yes
625+
// DU deleted by us - yes
626+
// AA both added yes yes
627+
// UU both modified yes yes
628+
//
629+
// Note that 'U' is overloaded: in UD/DU/UU the side with U has a blob
630+
// (it modified the file), but in AU/UA the U side has no blob (the
631+
// other side simply added something it doesn't have).
632+
var status = await _processManager.ExecuteGit(repoPath, "status", "--porcelain=v1", "--", file);
633+
status.ThrowIfFailed($"Failed to inspect status of {file} in {repoPath}");
634+
635+
var line = status.GetOutputLines().FirstOrDefault()
636+
?? throw new InvalidOperationException($"No status entry found for {file} in {repoPath} - is it actually conflicted?");
637+
638+
if (line.Length < 2)
639+
{
640+
throw new InvalidOperationException($"Unexpected git status output for {file} in {repoPath}: '{line}'");
641+
}
642+
643+
var code = line[..2];
618644

619-
result = await _processManager.ExecuteGit(repoPath, "add", file);
620-
result.ThrowIfFailed($"Failed to stage resolved conflict in {file} in {repoPath}");
645+
// Map each unmerged status code to (ourBlob, theirBlob) - whether each
646+
// side contributed a blob to the index.
647+
var (ourBlob, theirBlob) = code switch
648+
{
649+
"DD" => (false, false),
650+
"DU" => (false, true),
651+
"UD" => (true, false),
652+
"AU" => (true, false),
653+
"UA" => (false, true),
654+
"UU" => (true, true),
655+
"AA" => (true, true),
656+
_ => throw new Exception($"Unexpected unmerged status '{code}' for {file} in {repoPath}"),
657+
};
658+
659+
var chosenHasBlob = ours ? ourBlob : theirBlob;
660+
var otherHasBlob = ours ? theirBlob : ourBlob;
661+
662+
ProcessExecutionResult result;
663+
if (!chosenHasBlob)
664+
{
665+
result = await _processManager.ExecuteGit(repoPath, "rm", "-f", "--", file);
666+
result.ThrowIfFailed($"Failed to remove {file} in {repoPath}");
667+
}
668+
else if (!otherHasBlob)
669+
{
670+
// Only the chosen side has content
671+
result = await _processManager.ExecuteGit(repoPath, "add", "--", file);
672+
result.ThrowIfFailed($"Failed to stage {file} in {repoPath}");
673+
}
674+
else
675+
{
676+
// Both sides contributed a blob - pick the chosen side and stage it.
677+
result = await _processManager.ExecuteGit(repoPath, "checkout", ours ? "--ours" : "--theirs", "--", file);
678+
result.ThrowIfFailed($"Failed to resolve conflict in {file} in {repoPath}");
679+
680+
result = await _processManager.ExecuteGit(repoPath, "add", "--", file);
681+
result.ThrowIfFailed($"Failed to stage resolved conflict in {file} in {repoPath}");
682+
}
621683
}
622684

623685
public async Task<string> GetMergeBaseAsync(

src/Microsoft.DotNet.Darc/DarcLib/VirtualMonoRepo/CodeFlowConflictResolver.cs

Lines changed: 116 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -219,54 +219,14 @@ protected async Task<bool> TryResolvingConflictWithCrossingFlow(
219219
{
220220
_logger.LogDebug("Trying to auto-resolve a conflict in {filePath} based on a crossing flow...", conflictedFile);
221221

222-
UnixPath vmrRepoPath = VmrInfo.GetRelativeRepoSourcesPath(codeflowOptions.Mapping.Name);
223-
if (codeflowOptions.CurrentFlow.IsForwardFlow && !conflictedFile.Path.StartsWith(vmrRepoPath + '/'))
222+
var crossingFlowPatch = await TryCreateCrossingFlowPatchAsync(codeflowOptions, vmr, repo, conflictedFile, crossingFlow, cancellationToken);
223+
if (crossingFlowPatch is null)
224224
{
225-
_logger.LogWarning("Conflict in {file} is not in the source repo, skipping auto-resolution", conflictedFile);
226225
return false;
227226
}
228227

229-
// Create patch for the file represent only the most current flow
230-
var (fromSha, toSha) = codeflowOptions.CurrentFlow.IsForwardFlow
231-
? (crossingFlow.RepoSha, codeflowOptions.CurrentFlow.RepoSha)
232-
: (crossingFlow.VmrSha, codeflowOptions.CurrentFlow.VmrSha);
233-
234-
var patchName = _vmrInfo.TmpPath / $"{codeflowOptions.Mapping.Name}-{Guid.NewGuid()}.patch";
235-
List<VmrIngestionPatch> patches = await _patchHandler.CreatePatches(
236-
patchName,
237-
fromSha,
238-
toSha,
239-
path: codeflowOptions.CurrentFlow.IsForwardFlow
240-
? new UnixPath(conflictedFile.Path.Substring(vmrRepoPath.Length + 1))
241-
: conflictedFile,
242-
filters: null,
243-
relativePaths: true,
244-
workingDir: codeflowOptions.CurrentFlow.IsForwardFlow
245-
? repo.Path
246-
: vmr.Path / vmrRepoPath,
247-
applicationPath: codeflowOptions.CurrentFlow.IsForwardFlow
248-
? vmrRepoPath
249-
: null,
250-
ignoreLineEndings: true,
251-
cancellationToken);
252-
253-
if (patches.Count > 1)
254-
{
255-
foreach (var patch in patches)
256-
{
257-
try
258-
{
259-
_fileSystem.DeleteFile(patch.Path);
260-
}
261-
catch
262-
{
263-
}
264-
}
265-
266-
throw new InvalidOperationException("Cannot auto-resolve conflicts for files over 1GB in size");
267-
}
228+
var (targetRepo, patch) = crossingFlowPatch.Value;
268229

269-
var targetRepo = codeflowOptions.CurrentFlow.IsForwardFlow ? vmr : repo;
270230
try
271231
{
272232
await targetRepo.ResolveConflict(conflictedFile, ours: true);
@@ -278,7 +238,7 @@ protected async Task<bool> TryResolvingConflictWithCrossingFlow(
278238
return false;
279239
}
280240

281-
if (_fileSystem.GetFileInfo(patches.First().Path).Length == 0)
241+
if (_fileSystem.GetFileInfo(patch.Path).Length == 0)
282242
{
283243
// This file did not change in the last flow
284244
return true;
@@ -287,7 +247,7 @@ protected async Task<bool> TryResolvingConflictWithCrossingFlow(
287247
try
288248
{
289249
await _patchHandler.ApplyPatch(
290-
patches[0],
250+
patch,
291251
targetRepo.Path,
292252
removePatchAfter: true,
293253
keepConflicts: false,
@@ -311,6 +271,116 @@ await _patchHandler.ApplyPatch(
311271
}
312272
}
313273

274+
/// <summary>
275+
/// Reapplies the changes from the crossing flow on top of a non-conflicted file.
276+
/// </summary>
277+
/// <returns>True when the patch was applied successfully (or was a no-op)</returns>
278+
private async Task<bool> TryReapplyingCrossingFlowChangesAsync(
279+
CodeflowOptions codeflowOptions,
280+
ILocalGitRepo vmr,
281+
ILocalGitRepo repo,
282+
UnixPath file,
283+
Codeflow crossingFlow,
284+
CancellationToken cancellationToken)
285+
{
286+
_logger.LogDebug("Trying to reapply crossing-flow changes to {filePath}...", file);
287+
288+
var crossingFlowPatch = await TryCreateCrossingFlowPatchAsync(codeflowOptions, vmr, repo, file, crossingFlow, cancellationToken);
289+
if (crossingFlowPatch is null)
290+
{
291+
return false;
292+
}
293+
294+
var (targetRepo, patch) = crossingFlowPatch.Value;
295+
296+
if (_fileSystem.GetFileInfo(patch.Path).Length == 0)
297+
{
298+
// This file did not change in the last flow
299+
return true;
300+
}
301+
302+
try
303+
{
304+
await _patchHandler.ApplyPatch(
305+
patch,
306+
targetRepo.Path,
307+
removePatchAfter: true,
308+
keepConflicts: false,
309+
cancellationToken: cancellationToken);
310+
_logger.LogDebug("Successfully reapplied crossing-flow changes to {filePath}", file);
311+
return true;
312+
}
313+
catch (PatchApplicationFailedException)
314+
{
315+
_logger.LogInformation("Failed to reapply crossing-flow changes to {filePath}", file);
316+
return false;
317+
}
318+
}
319+
320+
/// <summary>
321+
/// Builds a patch capturing the source-side changes between the crossing flow and the current flow
322+
/// for a single file. Returns null if the file is outside the source repo path (forward flow only),
323+
/// throws if the resulting patch would be too large to handle.
324+
/// </summary>
325+
private async Task<(ILocalGitRepo TargetRepo, VmrIngestionPatch Patch)?> TryCreateCrossingFlowPatchAsync(
326+
CodeflowOptions codeflowOptions,
327+
ILocalGitRepo vmr,
328+
ILocalGitRepo repo,
329+
UnixPath file,
330+
Codeflow crossingFlow,
331+
CancellationToken cancellationToken)
332+
{
333+
UnixPath vmrRepoPath = VmrInfo.GetRelativeRepoSourcesPath(codeflowOptions.Mapping.Name);
334+
if (codeflowOptions.CurrentFlow.IsForwardFlow && !file.Path.StartsWith(vmrRepoPath + '/'))
335+
{
336+
_logger.LogWarning("File {file} is not in the source repo, skipping crossing-flow auto-resolution", file);
337+
return null;
338+
}
339+
340+
// Create patch for the file representing only the most current flow
341+
var (fromSha, toSha) = codeflowOptions.CurrentFlow.IsForwardFlow
342+
? (crossingFlow.RepoSha, codeflowOptions.CurrentFlow.RepoSha)
343+
: (crossingFlow.VmrSha, codeflowOptions.CurrentFlow.VmrSha);
344+
345+
var patchName = _vmrInfo.TmpPath / $"{codeflowOptions.Mapping.Name}-{Guid.NewGuid()}.patch";
346+
List<VmrIngestionPatch> patches = await _patchHandler.CreatePatches(
347+
patchName,
348+
fromSha,
349+
toSha,
350+
path: codeflowOptions.CurrentFlow.IsForwardFlow
351+
? new UnixPath(file.Path.Substring(vmrRepoPath.Length + 1))
352+
: file,
353+
filters: null,
354+
relativePaths: true,
355+
workingDir: codeflowOptions.CurrentFlow.IsForwardFlow
356+
? repo.Path
357+
: vmr.Path / vmrRepoPath,
358+
applicationPath: codeflowOptions.CurrentFlow.IsForwardFlow
359+
? vmrRepoPath
360+
: null,
361+
ignoreLineEndings: true,
362+
cancellationToken);
363+
364+
if (patches.Count > 1)
365+
{
366+
foreach (var patch in patches)
367+
{
368+
try
369+
{
370+
_fileSystem.DeleteFile(patch.Path);
371+
}
372+
catch
373+
{
374+
}
375+
}
376+
377+
throw new InvalidOperationException("Cannot auto-resolve conflicts for files over 1GB in size");
378+
}
379+
380+
var targetRepo = codeflowOptions.CurrentFlow.IsForwardFlow ? vmr : repo;
381+
return (targetRepo, patches[0]);
382+
}
383+
314384
/// <summary>
315385
/// If a file was added and then removed again in the original repo, it won't exist in the PR branch,
316386
/// so in case of a conflict + recreation, we cannot remove it (if it does not exist).
@@ -552,7 +622,7 @@ private async Task FixRevertedFiles(
552622
return;
553623
}
554624

555-
if (!await TryResolvingConflictWithCrossingFlow(
625+
if (!await TryReapplyingCrossingFlowChangesAsync(
556626
codeflowOptions,
557627
vmr,
558628
productRepo,

src/Microsoft.DotNet.Darc/DarcLib/VirtualMonoRepo/ForwardFlowConflictResolver.cs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,10 +169,19 @@ protected override async Task<bool> TryResolvingConflict(
169169
return true;
170170
}
171171

172+
var relativeRepoSourcePath = VmrInfo.GetRelativeRepoSourcesPath(codeflowOptions.Mapping);
173+
// If there's a conflict outside of the repo folder we're flowing
174+
// we don't want the changes
175+
if (!conflictedFile.Path.StartsWith(relativeRepoSourcePath))
176+
{
177+
await vmr.ResolveConflict(conflictedFile.Path, ours: true);
178+
return true;
179+
}
180+
172181
// eng/common is always preferred from the source side
173182
// In rebase mode: ours=true means keep the incoming changes (source)
174183
// In merge mode: ours=false means prefer theirs (source being merged in)
175-
var engCommon = VmrInfo.GetRelativeRepoSourcesPath(codeflowOptions.Mapping) / Constants.CommonScriptFilesPath;
184+
var engCommon = relativeRepoSourcePath / Constants.CommonScriptFilesPath;
176185
if (conflictedFile.Path.StartsWith(engCommon, StringComparison.InvariantCultureIgnoreCase))
177186
{
178187
await vmr.ResolveConflict(conflictedFile, ours: true);

0 commit comments

Comments
 (0)