From 85c5e9e08550c9906c8ade154fbbaa93ddff557c Mon Sep 17 00:00:00 2001 From: ksj09406 Date: Tue, 8 Sep 2026 10:49:28 +0900 Subject: [PATCH] =?UTF-8?q?3-Way=20=EB=A5=BC=20"=EB=81=9D=EB=82=BC=20?= =?UTF-8?q?=EC=88=98=20=EC=9E=88=EA=B2=8C"=20=EB=A7=8C=EB=93=A4=EA=B8=B0?= =?UTF-8?q?=20(=EC=9E=90=EB=8F=99=20=EB=B3=91=ED=95=A9=20=C2=B7=20?= =?UTF-8?q?=EC=B6=A9=EB=8F=8C=EB=A7=8C=20=EC=9D=B4=EB=8F=99=20=C2=B7=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C=20=ED=8C=90=EC=A0=95)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 지금까지는 안 겹치는 변경까지 사람이 하나씩 눌러야 해서, 정작 판단이 필요한 서너 줄이 그 속에 묻혔다. 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 --- doc/진행상황.md | 26 ++++-- src/MergeHub/Models/MergeLine.cs | 13 +++ src/MergeHub/Services/ShortcutMap.cs | 10 +++ src/MergeHub/ViewModels/HomeViewModel.cs | 106 ++++++++++++++++++++++- src/MergeHub/Views/HomeView.xaml | 40 +++++++++ src/MergeHub/Views/HomeView.xaml.cs | 4 + 6 files changed, 191 insertions(+), 8 deletions(-) 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) 내용으로"/> + + + + + + + + + + +