3-Way 를 "끝낼 수 있게" 만들기 (자동 병합 · 충돌만 이동 · 완료 판정)

지금까지는 안 겹치는 변경까지 사람이 하나씩 눌러야 해서, 정작 판단이 필요한
서너 줄이 그 속에 묻혔다. git merge 가 자동으로 해 주는 일을 여기서도 해 준다.

- 자동 병합: 저쪽만 고친 줄(ThreeWayState.RemoteChanged)을 한 번에 가져온다.
  충돌과 내가 고친 줄은 건드리지 않는다. 이미 가져온 줄은 더 이상 RemoteChanged 가
  아니므로 여러 번 눌러도 안전하다.
- 충돌만 이동: Alt+Shift+↑↓. "다음 차이" 와 달리 아직 안 정한 충돌만 딛는다.
- 완료 판정: ACTIONS 에 "남은 충돌 N개"(빨강) → "충돌 정리 끝"(초록).

판단이 하나 필요했다 - 충돌 여부는 BASE·MINE·REMOTE 를 견주면 저절로 나오지만,
"내 것으로 간다"고 정한 줄은 내용이 그대로라 여전히 충돌로 보인다. 그래서
MergeLine.NeedsDecision(충돌이면서 반영·수정 표시가 없는 줄)을 따로 두었다.
그 결정은 글을 바꾸지 않아 편집 뒤 재정렬하면 사라지므로, 얼어 있는 BASE 줄 번호
(없으면 MINE 줄 번호)로 붙들어 되살린다.

곁들여 고친 것: Take Mine / Take Theirs 버튼이 줄을 옮겨도 켜짐·꺼짐이 그대로였다.
CommunityToolkit 커맨드는 CommandManager 를 구독하지 않는데 이 둘만 갱신 목록에서
빠져 있었다 - 키보드(Alt+←/→)로는 되고 버튼만 안 되던 이유다.

실행 확인(mergehub-demo, 3-Way = feature/cooling): 자동 병합으로 Theirs only 1→0,
다음 충돌+Take Theirs 를 반복해 남은 충돌 3→2→1→"충돌 정리 끝".
테스트 290 → 293건 전부 통과(단축키 3건 추가).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
김승준 2026-09-08 10:49:28 +09:00
parent 9956567bf8
commit 85c5e9e085
6 changed files with 191 additions and 8 deletions

View File

@ -24,13 +24,24 @@
| **완료** | blame (줄별 작성자) | `Blame` 탭 신설. 변경 목록에서 파일을 고르고 `blame` 을 누르면 경로가 넘어간다 | 확인: 31줄 전부 `커밋·날짜·작성자` 가 붙어 나옴 |
| **완료** | 변경분 파일 추출 | `GitService.ExportChangedFiles` 신설. 저장소 안 경로 구조를 그대로 만들고, <b>바이트 그대로</b> 옮긴다(텍스트로 다시 쓰면 줄바꿈·인코딩이 바뀐다). 지워진 파일은 뽑을 것이 없어 건너뛴다 | 확인: `2개 파일 → ...Temp (건너뜀 0개)` |
### ③ 3-Way 병합 — "볼 수 있다"에서 "끝낼 수 있다"로
### ③ 3-Way 병합 — **완료** (2026-09-08)
| 상태 | 항목 | 내용 |
|---|---|---|
| 대기 | 자동 병합 | 한쪽만 고친 곳은 자동으로 결과에 넣는다. 사람은 충돌만 판단 |
| 대기 | 충돌만 이동 | `다음 차이` 말고 `다음 충돌`. 자동 병합과 짝이다 |
| 대기 | 병합 완료 판정 | 남은 충돌 0 이면 저장·커밋을 유도 |
"볼 수 있다"에서 "끝낼 수 있다"로.
| 상태 | 항목 | 내용 | 확인 |
|---|---|---|---|
| **완료** | 자동 병합 | 저쪽만 고친 줄(`ThreeWayState.RemoteChanged`)을 한 번에 가져온다. 충돌과 내가 고친 줄은 안 건드린다. `git merge` 가 자동으로 해 주는 일을 여기서도 해 주는 것 | `Theirs only 1 → 0`, 자동 병합 버튼이 스스로 꺼짐 |
| **완료** | 충돌만 이동 | `이전 충돌` / `다음 충돌` (`Alt+Shift+↑↓`). <b>아직 안 정한 충돌만</b> 딛는다 | 3개를 차례로 딛고 마지막에 버튼이 꺼짐 |
| **완료** | 병합 완료 판정 | ACTIONS 위쪽에 `남은 충돌 N개`(빨강) → `충돌 정리 끝`(초록) | 3개 → 2 → 1 → 끝 |
**여기서 판단이 하나 필요했다** — 충돌인지 아닌지는 BASE·MINE·REMOTE 를 견주면 저절로 나오지만,
"내 것으로 간다"고 정한 줄은 <b>내용이 그대로라 여전히 충돌로 보인다.</b> 그래서 `NeedsDecision`
(충돌이면서 아직 반영·수정 표시가 없는 줄)을 따로 두었다. 그 결정은 글을 바꾸지 않으므로 편집 뒤
다시 정렬하면 사라지는데, 얼어 있는 <b>BASE 줄 번호</b>(없으면 MINE 줄 번호)로 붙들어 되살린다.
**곁들여 고친 것**: `Take Mine` · `Take Theirs` 버튼이 <b>줄을 옮겨도 켜짐/꺼짐이 그대로였다.</b>
CommunityToolkit 커맨드는 CommandManager 를 구독하지 않는데 이 둘만 갱신 목록에서 빠져 있었다.
키보드(`Alt+←/→`)로는 되고 버튼만 안 되던 이유가 이것이다.
### ① 폴더 비교
@ -69,4 +80,5 @@
| 날짜 | 항목 | 커밋 |
|---|---|---|
| 2026-09-08 | 저장소 개설 (MergeHub · UbisamPlatform 각각) | `7719094` / `36ee902` |
| 2026-09-08 | **④ Git 조작 5가지** — amend · stash 목록 · 커밋 간 비교 · blame · 변경분 추출 | 아래 |
| 2026-09-08 | **④ Git 조작 5가지** — amend · stash 목록 · 커밋 간 비교 · blame · 변경분 추출 | `9956567` |
| 2026-09-08 | **③ 3-Way** — 자동 병합 · 충돌만 이동 · 병합 완료 판정 | 아래 |

View File

@ -130,6 +130,7 @@ namespace MergeHub.Models
[NotifyPropertyChangedFor(nameof(Highlight))]
[NotifyPropertyChangedFor(nameof(ThreeWay))]
[NotifyPropertyChangedFor(nameof(IsConflict))]
[NotifyPropertyChangedFor(nameof(NeedsDecision))]
[NotifyPropertyChangedFor(nameof(CanTakeRemote))]
[NotifyPropertyChangedFor(nameof(CanTakeLocal))]
private string _localText;
@ -141,11 +142,13 @@ namespace MergeHub.Models
/// <summary>BASE 내용을 가져와 반영한 줄인지. 되돌리면 풀린다.</summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(Highlight))]
[NotifyPropertyChangedFor(nameof(NeedsDecision))]
private bool _isApplied;
/// <summary>사용자가 직접 타이핑해 고친 줄인지. "머지 반영"과 구분해서 표시한다.</summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(Highlight))]
[NotifyPropertyChangedFor(nameof(NeedsDecision))]
private bool _isManuallyEdited;
/// <summary>반영하면서 줄 끝에 붙인 주석(툴팁으로 보여준다).</summary>
@ -281,6 +284,7 @@ namespace MergeHub.Models
[NotifyPropertyChangedFor(nameof(ThreeWay))]
[NotifyPropertyChangedFor(nameof(Highlight))]
[NotifyPropertyChangedFor(nameof(IsConflict))]
[NotifyPropertyChangedFor(nameof(NeedsDecision))]
[NotifyPropertyChangedFor(nameof(CanTakeRemote))]
[NotifyPropertyChangedFor(nameof(CanTakeLocal))]
private string _remoteText = string.Empty;
@ -363,6 +367,15 @@ namespace MergeHub.Models
public bool IsConflict => ThreeWay == ThreeWayState.Conflict;
/// <summary>
/// 아직 사람이 골라야 하는 충돌 줄인지.
///
/// 충돌인지 아닌지는 BASE·MINE·REMOTE 를 견주면 저절로 나오지만, <b>"내 것으로 간다"고
/// 정한 줄은 내용이 그대로라 여전히 충돌로 보인다.</b> 그래서 "정했다"는 표시
/// (반영했거나 직접 고쳤거나)를 함께 본다 - 이 수가 0 이 되면 이 파일의 병합은 끝난 것이다.
/// </summary>
public bool NeedsDecision => IsConflict && !IsApplied && !IsManuallyEdited && !IsMergeNote;
/// <summary>REMOTE 내용을 가져올 수 있는 줄인지. 이미 같으면 가져올 것이 없다.</summary>
public bool CanTakeRemote =>
IsThreeWay && !IsMergeNote && ThreeWay is ThreeWayState.RemoteChanged or ThreeWayState.Conflict;

View File

@ -21,6 +21,9 @@ namespace MergeHub.Services
GoToLine,
NextBookmark,
ToggleBookmark,
NextConflict,
PrevConflict,
AutoMerge,
OpenFolderCompare,
Find,
FindNext,
@ -109,6 +112,13 @@ namespace MergeHub.Services
new ShortcutEntry(ShortcutAction.CopyRightToLeft, Key.Left, ModifierKeys.Alt, "병합·편집",
"오른쪽 → 왼쪽 반영 (2-Way: 이 줄 되돌리기 / 3-Way: Take Theirs)"),
new ShortcutEntry(ShortcutAction.NextConflict, Key.Down, ModifierKeys.Alt | ModifierKeys.Shift, "병합·편집",
"다음 충돌 (3-Way)", "차이가 아니라 <아직 안 정한 충돌>만 딛는다. 자동 병합과 짝이다."),
new ShortcutEntry(ShortcutAction.PrevConflict, Key.Up, ModifierKeys.Alt | ModifierKeys.Shift, "병합·편집",
"이전 충돌 (3-Way)"),
new ShortcutEntry(ShortcutAction.AutoMerge, Key.M, ModifierKeys.Alt, "병합·편집",
"자동 병합 - 저쪽만 고친 줄을 한 번에 가져온다 (3-Way)"),
new ShortcutEntry(ShortcutAction.Undo, Key.Z, ModifierKeys.Control, "병합·편집", "실행 취소",
"머지 반영을 되돌린다. 되돌릴 머지 작업이 없으면 편집기의 글자 되돌리기가 그대로 동작한다."),
new ShortcutEntry(ShortcutAction.Redo, Key.Y, ModifierKeys.Control, "병합·편집", "다시 실행"),

View File

@ -453,6 +453,11 @@ namespace MergeHub.ViewModels
UndoCommand.NotifyCanExecuteChanged();
RedoCommand.NotifyCanExecuteChanged();
AcceptAllCommand.NotifyCanExecuteChanged();
// 3-Way 의 두 버튼. 빠져 있어서 줄을 옮겨도 켜짐/꺼짐이 그대로였다 -
// CommunityToolkit 의 커맨드는 CommandManager 를 구독하지 않으므로 직접 알려야 한다.
AcceptLocalCommand.NotifyCanExecuteChanged();
AcceptRemoteCommand.NotifyCanExecuteChanged();
FirstDiffCommand.NotifyCanExecuteChanged();
PrevDiffCommand.NotifyCanExecuteChanged();
NextDiffCommand.NotifyCanExecuteChanged();
@ -462,6 +467,10 @@ namespace MergeHub.ViewModels
ReloadCommand.NotifyCanExecuteChanged();
SaveAndCommitCommand.NotifyCanExecuteChanged();
SavePlainCommand.NotifyCanExecuteChanged();
AutoMergeCommand.NotifyCanExecuteChanged();
NextConflictCommand.NotifyCanExecuteChanged();
PrevConflictCommand.NotifyCanExecuteChanged();
}
// ================= 로드 =================
@ -621,6 +630,8 @@ namespace MergeHub.ViewModels
_baseTextSnapshot = baseText;
_remoteTextSnapshot = threeWay ? remoteText : string.Empty;
_mineTextSnapshot = threeWay ? localText : string.Empty;
_resolvedBaseNumbers.Clear(); // 파일이 바뀌면 앞 파일의 결정은 뜻이 없다
_resolvedMineNumbers.Clear();
_originalLocalText = BuildLocalText();
SyncEditorFromLines();
@ -994,7 +1005,10 @@ namespace MergeHub.ViewModels
if (beforeHasLocal == afterHasLocal && string.Equals(before, after, StringComparison.Ordinal))
{
// 이미 내 원본과 같다. 충돌 줄이면 "내 것으로 정했다"는 표시만 남긴다.
// 내용이 안 바뀌므로 다시 정렬하면 이 표시가 사라진다 - BASE 줄 번호로 따로 붙들어 둔다.
line.IsApplied = true;
if (line.BaseNumber is int resolvedBase) _resolvedBaseNumbers.Add(resolvedBase);
else if (line.OriginalLocalNumber is int resolvedMine) _resolvedMineNumbers.Add(resolvedMine);
AfterEdit();
return;
}
@ -1270,6 +1284,12 @@ namespace MergeHub.ViewModels
if (row.LocalNumber is int number && touched.Contains(number))
row.IsManuallyEdited = true;
// "내 것으로 간다"고 정했던 충돌은 내용이 그대로라 글만 봐서는 알 수 없다.
if (row.BaseNumber is int baseNumber
? _resolvedBaseNumbers.Contains(baseNumber)
: row.OriginalLocalNumber is int mineNumber && _resolvedMineNumbers.Contains(mineNumber))
row.IsApplied = true;
Lines.Add(row);
}
@ -1460,6 +1480,8 @@ namespace MergeHub.ViewModels
_baseTextSnapshot = baseText;
_remoteTextSnapshot = string.Empty; // 파일 두 개 비교는 늘 2-Way 다
_mineTextSnapshot = string.Empty;
_resolvedBaseNumbers.Clear();
_resolvedMineNumbers.Clear();
RefreshLanguage();
var rows = BuildLines(baseText, localText);
@ -2001,6 +2023,85 @@ namespace MergeHub.ViewModels
RefreshProblems();
}
// ================= 자동 병합 · 충돌만 보기 =================
/// <summary>
/// "내 것으로 간다"고 정한 충돌 줄의 BASE 줄 번호.
///
/// 그 결정은 <b>내용을 바꾸지 않으므로</b> 글만 봐서는 되살릴 수 없다. 편집 뒤 다시 정렬하면
/// 행이 새로 만들어지는데, 이걸 들고 있지 않으면 이미 정한 충돌이 되살아나
/// "남은 충돌"이 줄지 않는다. BASE 는 얼어 있으므로 그 줄 번호가 안전한 열쇠다.
/// </summary>
private readonly HashSet<int> _resolvedBaseNumbers = new HashSet<int>();
/// <summary>
/// BASE 에 없는 충돌(양쪽이 각자 새 줄을 넣은 자리)용. 이런 줄은 BASE 줄 번호가 없으므로
/// 역시 얼어 있는 MINE 줄 번호로 붙든다.
/// </summary>
private readonly HashSet<int> _resolvedMineNumbers = new HashSet<int>();
private bool CanAutoMerge() => IsThreeWay && Lines.Any(l => l.ThreeWay == ThreeWayState.RemoteChanged);
/// <summary>
/// 저쪽만 고친 줄을 한 번에 결과로 가져온다. 사람은 <b>충돌만</b> 판단하면 된다.
///
/// 안 겹치는 변경까지 하나씩 누르게 하면 100줄짜리 충돌 파일에서 정작 판단이 필요한
/// 서너 줄이 묻힌다 - `git merge` 가 자동으로 해 주는 일을 여기서도 해 준다.
/// 내가 고친 줄과 충돌 줄은 건드리지 않는다.
/// </summary>
[RelayCommand(CanExecute = nameof(CanAutoMerge))]
private void AutoMerge()
{
// 반영하면서 주석 줄이 끼어들어 Lines 가 바뀐다. 먼저 목록을 떠 놓고 돈다.
var targets = Lines.Where(l => l.ThreeWay == ThreeWayState.RemoteChanged && l.CanTakeRemote).ToList();
if (targets.Count == 0) return;
foreach (var line in targets) ApplyRemote(line, requery: false);
AfterEdit();
MessageUtil.ShowSuccessToast(UnresolvedConflictCount == 0
? $"{targets.Count}줄 자동 병합 · 남은 충돌 없음"
: $"{targets.Count}줄 자동 병합 · 남은 충돌 {UnresolvedConflictCount}개");
}
private bool CanGoConflict() => IsThreeWay && Lines.Any(l => l.NeedsDecision);
/// <summary>다음 충돌로. `다음 차이` 와 달리 <b>아직 안 정한 충돌만</b> 딛는다.</summary>
[RelayCommand(CanExecute = nameof(CanGoConflict))]
private void NextConflict()
{
for (var row = CaretRow + 1; row < Lines.Count; row++)
if (Lines[row].NeedsDecision) { GoToRow(row); return; }
// 끝까지 갔으면 처음으로 돈다 - 차이 이동과 같은 감각이다.
for (var row = 0; row < Lines.Count; row++)
if (Lines[row].NeedsDecision) { GoToRow(row); return; }
}
[RelayCommand(CanExecute = nameof(CanGoConflict))]
private void PrevConflict()
{
for (var row = CaretRow - 1; row >= 0; row--)
if (Lines[row].NeedsDecision) { GoToRow(row); return; }
for (var row = Lines.Count - 1; row >= 0; row--)
if (Lines[row].NeedsDecision) { GoToRow(row); return; }
}
/// <summary>아직 사람이 골라야 하는 충돌 줄 수. 0 이면 이 파일의 병합은 끝난 것이다.</summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(MergeStatusText))]
[NotifyPropertyChangedFor(nameof(IsMergeComplete))]
private int _unresolvedConflictCount;
/// <summary>3-Way 에서 지금 어디까지 왔는지 한 줄로.</summary>
public string MergeStatusText =>
!IsThreeWay ? string.Empty :
UnresolvedConflictCount == 0 ? "충돌 정리 끝" :
$"남은 충돌 {UnresolvedConflictCount}개";
public bool IsMergeComplete => IsThreeWay && UnresolvedConflictCount == 0;
// ================= 범례에 실시간으로 붙는 개수 =================
[ObservableProperty] private int _changedCount;
@ -2022,10 +2123,12 @@ namespace MergeHub.ViewModels
private void UpdateLegendCounts()
{
int changed = 0, applied = 0, edited = 0, added = 0, removed = 0, moved = 0, ignored = 0, same = 0;
int remote = 0, conflict = 0;
int remote = 0, conflict = 0, needsDecision = 0;
foreach (var line in Lines)
{
if (line.NeedsDecision) needsDecision++;
switch (line.Highlight)
{
case LineHighlight.Applied: applied++; break;
@ -2051,6 +2154,7 @@ namespace MergeHub.ViewModels
SameCount = same;
RemoteChangeCount = remote;
ConflictCount = conflict;
UnresolvedConflictCount = needsDecision;
}
private string BuildLocalText()

View File

@ -1223,6 +1223,46 @@
Background="{DynamicResource Ubisam.Brush.Accent.Fill}"
Foreground="{DynamicResource Ubisam.Brush.Accent.OnFill}"
ToolTip="선택한 줄을 REMOTE(Theirs) 내용으로"/>
<!--
3-Way 흐름. "안 겹치는 변경은 자동으로, 사람은 충돌만" 이 순서다.
남은 충돌이 0 이 되면 그 자리에서 다 됐다고 알려 준다.
-->
<Border Margin="2,10,2,0" Padding="6,4" CornerRadius="3"
Visibility="{Binding IsThreeWay, Converter={StaticResource BoolToVis}}"
Background="{DynamicResource Ubisam.Brush.Input.Background}"
BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}" BorderThickness="1">
<TextBlock Text="{Binding MergeStatusText}" FontWeight="Bold" FontSize="11.5"
HorizontalAlignment="Center"
ToolTip="아직 사람이 골라야 하는 줄 수입니다">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Foreground" Value="{DynamicResource Ubisam.Brush.Danger}"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsMergeComplete}" Value="True">
<Setter Property="Foreground" Value="{DynamicResource Ubisam.Brush.Success}"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</Border>
<Button Content="자동 병합" Command="{Binding AutoMergeCommand}"
MinHeight="30" Margin="2,4,2,0"
Visibility="{Binding IsThreeWay, Converter={StaticResource BoolToVis}}"
ToolTip="저쪽만 고친 줄을 한 번에 가져옵니다. 충돌과 내가 고친 줄은 건드리지 않습니다"/>
<UniformGrid Columns="2" Margin="0,4,0,0"
Visibility="{Binding IsThreeWay, Converter={StaticResource BoolToVis}}">
<Button Content="이전 충돌" Command="{Binding PrevConflictCommand}"
MinHeight="28" FontSize="11"
ToolTip="아직 안 정한 충돌만 딛습니다 (Alt+Shift+Up)"/>
<Button Content="다음 충돌" Command="{Binding NextConflictCommand}"
MinHeight="28" FontSize="11"
ToolTip="아직 안 정한 충돌만 딛습니다 (Alt+Shift+Down)"/>
</UniformGrid>
<Button Content="Revert" Command="{Binding RevertCommand}" MinHeight="30" Margin="2,4,2,0"
Style="{StaticResource FilledButton}"
Background="{DynamicResource Ubisam.Brush.Warning}"

View File

@ -709,6 +709,10 @@ namespace MergeHub.Views
GoToBox.SelectAll();
return true;
case ShortcutAction.NextConflict: return Run(vm.NextConflictCommand);
case ShortcutAction.PrevConflict: return Run(vm.PrevConflictCommand);
case ShortcutAction.AutoMerge: return Run(vm.AutoMergeCommand);
case ShortcutAction.NextBookmark: return Run(vm.NextBookmarkCommand);
case ShortcutAction.ToggleBookmark: