diff --git a/doc/진행상황.md b/doc/진행상황.md
index 5013ac5..7380d1f 100644
--- a/doc/진행상황.md
+++ b/doc/진행상황.md
@@ -24,13 +24,24 @@
| **완료** | blame (줄별 작성자) | `Blame` 탭 신설. 변경 목록에서 파일을 고르고 `blame` 을 누르면 경로가 넘어간다 | 확인: 31줄 전부 `커밋·날짜·작성자` 가 붙어 나옴 |
| **완료** | 변경분 파일 추출 | `GitService.ExportChangedFiles` 신설. 저장소 안 경로 구조를 그대로 만들고, 바이트 그대로 옮긴다(텍스트로 다시 쓰면 줄바꿈·인코딩이 바뀐다). 지워진 파일은 뽑을 것이 없어 건너뛴다 | 확인: `2개 파일 → ...Temp (건너뜀 0개)` |
-### ③ 3-Way 병합 — "볼 수 있다"에서 "끝낼 수 있다"로
+### ③ 3-Way 병합 — **완료** (2026-09-08)
-| 상태 | 항목 | 내용 |
-|---|---|---|
-| 대기 | 자동 병합 | 한쪽만 고친 곳은 자동으로 결과에 넣는다. 사람은 충돌만 판단 |
-| 대기 | 충돌만 이동 | `다음 차이` 말고 `다음 충돌`. 자동 병합과 짝이다 |
-| 대기 | 병합 완료 판정 | 남은 충돌 0 이면 저장·커밋을 유도 |
+"볼 수 있다"에서 "끝낼 수 있다"로.
+
+| 상태 | 항목 | 내용 | 확인 |
+|---|---|---|---|
+| **완료** | 자동 병합 | 저쪽만 고친 줄(`ThreeWayState.RemoteChanged`)을 한 번에 가져온다. 충돌과 내가 고친 줄은 안 건드린다. `git merge` 가 자동으로 해 주는 일을 여기서도 해 주는 것 | `Theirs only 1 → 0`, 자동 병합 버튼이 스스로 꺼짐 |
+| **완료** | 충돌만 이동 | `이전 충돌` / `다음 충돌` (`Alt+Shift+↑↓`). 아직 안 정한 충돌만 딛는다 | 3개를 차례로 딛고 마지막에 버튼이 꺼짐 |
+| **완료** | 병합 완료 판정 | ACTIONS 위쪽에 `남은 충돌 N개`(빨강) → `충돌 정리 끝`(초록) | 3개 → 2 → 1 → 끝 |
+
+**여기서 판단이 하나 필요했다** — 충돌인지 아닌지는 BASE·MINE·REMOTE 를 견주면 저절로 나오지만,
+"내 것으로 간다"고 정한 줄은 내용이 그대로라 여전히 충돌로 보인다. 그래서 `NeedsDecision`
+(충돌이면서 아직 반영·수정 표시가 없는 줄)을 따로 두었다. 그 결정은 글을 바꾸지 않으므로 편집 뒤
+다시 정렬하면 사라지는데, 얼어 있는 BASE 줄 번호(없으면 MINE 줄 번호)로 붙들어 되살린다.
+
+**곁들여 고친 것**: `Take Mine` · `Take Theirs` 버튼이 줄을 옮겨도 켜짐/꺼짐이 그대로였다.
+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** — 자동 병합 · 충돌만 이동 · 병합 완료 판정 | 아래 |
diff --git a/src/MergeHub/Models/MergeLine.cs b/src/MergeHub/Models/MergeLine.cs
index 96408d7..9065cd4 100644
--- a/src/MergeHub/Models/MergeLine.cs
+++ b/src/MergeHub/Models/MergeLine.cs
@@ -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
/// BASE 내용을 가져와 반영한 줄인지. 되돌리면 풀린다.
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(Highlight))]
+ [NotifyPropertyChangedFor(nameof(NeedsDecision))]
private bool _isApplied;
/// 사용자가 직접 타이핑해 고친 줄인지. "머지 반영"과 구분해서 표시한다.
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(Highlight))]
+ [NotifyPropertyChangedFor(nameof(NeedsDecision))]
private bool _isManuallyEdited;
/// 반영하면서 줄 끝에 붙인 주석(툴팁으로 보여준다).
@@ -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;
+ ///
+ /// 아직 사람이 골라야 하는 충돌 줄인지.
+ ///
+ /// 충돌인지 아닌지는 BASE·MINE·REMOTE 를 견주면 저절로 나오지만, "내 것으로 간다"고
+ /// 정한 줄은 내용이 그대로라 여전히 충돌로 보인다. 그래서 "정했다"는 표시
+ /// (반영했거나 직접 고쳤거나)를 함께 본다 - 이 수가 0 이 되면 이 파일의 병합은 끝난 것이다.
+ ///
+ public bool NeedsDecision => IsConflict && !IsApplied && !IsManuallyEdited && !IsMergeNote;
+
/// REMOTE 내용을 가져올 수 있는 줄인지. 이미 같으면 가져올 것이 없다.
public bool CanTakeRemote =>
IsThreeWay && !IsMergeNote && ThreeWay is ThreeWayState.RemoteChanged or ThreeWayState.Conflict;
diff --git a/src/MergeHub/Services/ShortcutMap.cs b/src/MergeHub/Services/ShortcutMap.cs
index ff74e8a..9400bed 100644
--- a/src/MergeHub/Services/ShortcutMap.cs
+++ b/src/MergeHub/Services/ShortcutMap.cs
@@ -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, "병합·편집", "다시 실행"),
diff --git a/src/MergeHub/ViewModels/HomeViewModel.cs b/src/MergeHub/ViewModels/HomeViewModel.cs
index d7e4e31..869473c 100644
--- a/src/MergeHub/ViewModels/HomeViewModel.cs
+++ b/src/MergeHub/ViewModels/HomeViewModel.cs
@@ -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();
}
+ // ================= 자동 병합 · 충돌만 보기 =================
+
+ ///
+ /// "내 것으로 간다"고 정한 충돌 줄의 BASE 줄 번호.
+ ///
+ /// 그 결정은 내용을 바꾸지 않으므로 글만 봐서는 되살릴 수 없다. 편집 뒤 다시 정렬하면
+ /// 행이 새로 만들어지는데, 이걸 들고 있지 않으면 이미 정한 충돌이 되살아나
+ /// "남은 충돌"이 줄지 않는다. BASE 는 얼어 있으므로 그 줄 번호가 안전한 열쇠다.
+ ///
+ private readonly HashSet _resolvedBaseNumbers = new HashSet();
+
+ ///
+ /// BASE 에 없는 충돌(양쪽이 각자 새 줄을 넣은 자리)용. 이런 줄은 BASE 줄 번호가 없으므로
+ /// 역시 얼어 있는 MINE 줄 번호로 붙든다.
+ ///
+ private readonly HashSet _resolvedMineNumbers = new HashSet();
+
+ private bool CanAutoMerge() => IsThreeWay && Lines.Any(l => l.ThreeWay == ThreeWayState.RemoteChanged);
+
+ ///
+ /// 저쪽만 고친 줄을 한 번에 결과로 가져온다. 사람은 충돌만 판단하면 된다.
+ ///
+ /// 안 겹치는 변경까지 하나씩 누르게 하면 100줄짜리 충돌 파일에서 정작 판단이 필요한
+ /// 서너 줄이 묻힌다 - `git merge` 가 자동으로 해 주는 일을 여기서도 해 준다.
+ /// 내가 고친 줄과 충돌 줄은 건드리지 않는다.
+ ///
+ [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);
+
+ /// 다음 충돌로. `다음 차이` 와 달리 아직 안 정한 충돌만 딛는다.
+ [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; }
+ }
+
+ /// 아직 사람이 골라야 하는 충돌 줄 수. 0 이면 이 파일의 병합은 끝난 것이다.
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(MergeStatusText))]
+ [NotifyPropertyChangedFor(nameof(IsMergeComplete))]
+ private int _unresolvedConflictCount;
+
+ /// 3-Way 에서 지금 어디까지 왔는지 한 줄로.
+ 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()
diff --git a/src/MergeHub/Views/HomeView.xaml b/src/MergeHub/Views/HomeView.xaml
index 6d5e955..d2a1483 100644
--- a/src/MergeHub/Views/HomeView.xaml
+++ b/src/MergeHub/Views/HomeView.xaml
@@ -1223,6 +1223,46 @@
Background="{DynamicResource Ubisam.Brush.Accent.Fill}"
Foreground="{DynamicResource Ubisam.Brush.Accent.OnFill}"
ToolTip="선택한 줄을 REMOTE(Theirs) 내용으로"/>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+