feat(merge): resolve conflicts straight from the merge modal
Changelog / changelog (push) Successful in 2s
Release / release (push) Successful in 49s

The merge modal previews the selected target and offers 'Resolve conflicts' next to the stash retry when it predicts a conflict. A dirty target is autostashed. The resolver now aborts when closed natively (Alt+F4), so the merge and its autostash can never be left pending.
This commit is contained in:
mika kuns
2026-10-02 18:53:34 +02:00
parent 8cf3a3a9d0
commit 214e53b714
6 changed files with 60 additions and 8 deletions
+2 -1
View File
@@ -539,7 +539,8 @@
"removeWorktree": "Worktree nach dem Mergen entfernen",
"commitMessage": "Commit-Nachricht",
"conflictedFiles": "Konfliktdateien:",
"stashAndMerge": "Änderungen stashen und mergen"
"stashAndMerge": "Änderungen stashen und mergen",
"resolveConflicts": "Konflikte lösen"
},
"diff": {
"title": "DIFF",
+2 -1
View File
@@ -539,7 +539,8 @@
"removeWorktree": "Remove worktree after merge",
"commitMessage": "Commit message",
"conflictedFiles": "Conflicted files:",
"stashAndMerge": "Stash changes and merge"
"stashAndMerge": "Stash changes and merge",
"resolveConflicts": "Resolve conflicts"
},
"diff": {
"title": "DIFF",
@@ -122,6 +122,9 @@ public sealed partial class ConflictResolverViewModel : ObservableObject
public string TaskId => _taskId;
public Action? CloseRequested { get; set; }
/// <summary>True once the merge was continued or aborted; a window closed before that must abort, or the repo stays mid-merge with an autostash pending.</summary>
public bool IsSettled { get; private set; }
/// <summary>Raised when the current conflict changes so the view can reload its editors.</summary>
public event Action? CurrentChanged;
@@ -277,13 +280,17 @@ public sealed partial class ConflictResolverViewModel : ObservableObject
// Hand back to the orchestrator: it commits this subtask and drains the rest.
// A later subtask conflict re-opens this editor via the PlanningMergeConflict broadcast.
await _worker.ContinuePlanningMergeAsync(_planningParentId);
IsSettled = true;
CloseRequested?.Invoke();
return;
}
var result = await _worker.ContinueConflictMergeAsync(_taskId);
if (string.Equals(result.Status, "merged", StringComparison.Ordinal))
{
IsSettled = true;
CloseRequested?.Invoke();
}
else
Error = result.ErrorMessage ?? "Conflicts not fully resolved — review and retry.";
}
@@ -297,6 +304,7 @@ public sealed partial class ConflictResolverViewModel : ObservableObject
[RelayCommand]
private async Task AbortAsync()
{
IsSettled = true;
IsBusy = true;
try
{
@@ -16,11 +16,11 @@ public sealed partial class MergeModalViewModel : ViewModelBase
public ObservableCollection<string> Branches { get; } = new();
[ObservableProperty][NotifyCanExecuteChangedFor(nameof(SubmitCommand))] private string? _selectedBranch;
[ObservableProperty][NotifyCanExecuteChangedFor(nameof(SubmitCommand), nameof(ResolveConflictsCommand))] private string? _selectedBranch;
[ObservableProperty] private bool _removeWorktree = true;
[ObservableProperty] private string _commitMessage = "";
[ObservableProperty][NotifyCanExecuteChangedFor(nameof(SubmitCommand))] private bool _isBusy;
[ObservableProperty][NotifyCanExecuteChangedFor(nameof(SubmitCommand), nameof(ResolveConflictsCommand))] private bool _isBusy;
/// What the pending merge is doing right now, fed by the worker's MergeProgress broadcast.
/// A merge whose list has a verify command can occupy this call for minutes — without this the
@@ -37,6 +37,11 @@ public sealed partial class MergeModalViewModel : ViewModelBase
[ObservableProperty] private bool _showStashRetry;
[ObservableProperty] private IReadOnlyList<string> _conflictFiles = Array.Empty<string>();
/// <summary>The merge preview for the selected branch predicts a conflict — offers the resolver up front.</summary>
[ObservableProperty] private bool _previewHasConflict;
private bool _targetDirty;
public Action? CloseAction { get; set; }
/// True once a merge has succeeded — lets the caller (e.g. the diff window)
@@ -79,6 +84,7 @@ public sealed partial class MergeModalViewModel : ViewModelBase
// Known dirty before anyone clicks Merge — offer the stash retry right away instead
// of making the user hit the "dirty_target" refusal first.
_targetDirty = targets.TargetDirty;
if (targets.TargetDirty)
{
WarningMessage = Loc.T("vm.merge.targetDirty");
@@ -92,6 +98,29 @@ public sealed partial class MergeModalViewModel : ViewModelBase
finally { IsBusy = false; }
}
partial void OnSelectedBranchChanged(string? value) => _ = RefreshPreviewAsync(value);
private async Task RefreshPreviewAsync(string? branch)
{
PreviewHasConflict = false;
if (string.IsNullOrWhiteSpace(branch) || string.IsNullOrEmpty(TaskId)) return;
try
{
var dto = await _worker.PreviewMergeAsync(TaskId, branch);
if (SelectedBranch == branch) PreviewHasConflict = dto?.ConflictFiles.Count > 0;
}
catch { /* preview is a hint only; Merge still reports the real conflict */ }
}
/// <summary>Hands the predicted conflict straight to the resolver; stashes a dirty target, as the warning above announces.</summary>
[RelayCommand(CanExecute = nameof(CanSubmit))]
private async Task ResolveConflictsAsync()
{
RoutedToResolver = true;
CloseAction?.Invoke();
await _merge.ResolveConflictAsync(TaskId, SelectedBranch!, _targetDirty);
}
private bool CanSubmit() =>
!IsBusy && !HasConflict && !string.IsNullOrWhiteSpace(SelectedBranch);
@@ -42,6 +42,13 @@ public partial class ConflictResolverView : Window
InitializeComponent();
}
/// <summary>Alt+F4 bypasses the Abort-bound close button; abort here so the merge and its autostash never stay pending.</summary>
protected override void OnClosed(EventArgs e)
{
base.OnClosed(e);
if (_vm is { IsSettled: false }) _vm.AbortCommand.Execute(null);
}
protected override void OnDataContextChanged(EventArgs e)
{
base.OnDataContextChanged(e);
@@ -74,10 +74,16 @@
TextWrapping="Wrap"
IsVisible="{Binding WarningMessage, Converter={x:Static ObjectConverters.IsNotNull}}" />
<Button Classes="btn" HorizontalAlignment="Left"
Content="{loc:Tr modals.merge.stashAndMerge}"
Command="{Binding StashAndMergeCommand}"
IsVisible="{Binding ShowStashRetry}" />
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Classes="btn"
Content="{loc:Tr modals.merge.stashAndMerge}"
Command="{Binding StashAndMergeCommand}"
IsVisible="{Binding ShowStashRetry}" />
<Button Classes="btn"
Content="{loc:Tr modals.merge.resolveConflicts}"
Command="{Binding ResolveConflictsCommand}"
IsVisible="{Binding PreviewHasConflict}" />
</StackPanel>
<Border Classes="danger-box"
IsVisible="{Binding HasConflict}">