diff --git a/doc/history/2026-09-14-6차.md b/doc/history/2026-09-14-6차.md index e08ad37..2095b0f 100644 --- a/doc/history/2026-09-14-6차.md +++ b/doc/history/2026-09-14-6차.md @@ -146,3 +146,64 @@ A-1 검사를 넣고 하네스를 돌리다 **세 벌이 빌드가 안 된다** - 검사 모음은 이제 **모두 다시 빌드하고(`--no-incremental`), 이번에 새로 만든 실행 파일이 아니면 실패로 센다.** 그렇게 돌린 결과 **21벌 745건 통과** · 빌드 경고 0 · 오류 0. + + +--- + +## A-3. 충돌 파일을 고르면 상대(Theirs) 칸이 안 나온다 (2026-09-14, 완료) + +### 고치기 전에 다시 났다 + +새로 만든 저장소에서 병합 중에 `Io/Unit12.cs` 를 골랐다. + +| 본 것 | 값 | +|---|---| +| 3-Way 콤보 | `(3-Way 안 씀)` | +| 칸 | BASE 와 LOCAL 두 칸. **REMOTE · MERGED 없음** | +| BASE 제목 | `(merge base) Io/Unit12.cs` — 읽기는 충돌 갈래로 읽었다 | + +### 무엇이 원인이었나 + +`LoadFileAsync` 는 충돌 파일이면 git 이 들고 있는 세 벌(BASE · 내 쪽 · 상대 쪽)을 읽고 행도 세 칸으로 만든다. +그런데 켠 것은 지역 변수 `threeWay` 뿐이었다. 화면 틀을 정하는 `IsThreeWay` 는 **콤보에서 브랜치를 골랐을 때만** 참이라, +세 칸짜리 행이 두 칸 틀에 실렸다. REMOTE 칸 제목도 늘 `REMOTE · THEIRS` 라 어느 브랜치를 끌어오는지 알 수 없었다. + +### 고친 것 + +| 자리 | 무엇 | +|---|---| +| `HomeViewModel.IsThreeWay` | "콤보로 고른 3-Way" 또는 **"충돌 파일이라 켠 3분할"** 이면 참. 충돌 쪽은 따로 둔 깃발(`_conflictThreeWay`)이다 | +| `LoadFileAsync` | 행을 싣기 **전에** 틀부터 바꾼다. 읽기 갈래는 이제 **콤보로 고른 3-Way 만** 본다(`IsBranchThreeWay`) | +| `ClearPanes` | 깃발을 내린다 | +| `GitService.GetMergeTheirsName` (새 함수) | 들어오는 커밋(MERGE_HEAD)을 가리키는 브랜치 이름 → 병합 메시지의 이름 → 짧은 해시 차례로 찾는다 | +| `Services/MergeNames.cs` (새 파일) | 병합 메시지 첫 줄에서 브랜치·태그 이름을 꺼낸다. 저장소 없이 검사하려고 따로 뺐다 | +| REMOTE 칸 제목 | `REMOTE · feature/theirs` 처럼 상대 이름을 보인다 | + +**콤보 값은 건드리지 않는다.** 콤보를 상대 브랜치로 바꿔 두는 쪽도 생각했지만, 그러면 병합이 끝난 뒤에도 보통 파일이 +그 브랜치와 3-Way 로 열려 사람이 고르지 않은 비교가 남는다. + +읽기 갈래를 콤보에만 묶은 까닭 - 앞 파일이 충돌이라 켜 둔 3분할을 그대로 들고 오면, 충돌이 아닌 다음 파일을 +`(3-Way 안 씀)` 이라는 이름의 브랜치와 견주려다 깨진다. + +`MergeHeads` 를 쓰려 했는데 이 프로젝트의 LibGit2Sharp(0.32)에는 없었다. `.git/MERGE_HEAD` 파일의 첫 줄을 읽는다. + +### 확인한 것 (새로 만든 저장소) + +| 확인 | 결과 | +|---|---| +| 콤보를 안 건드리고 충돌 파일(`Io/Unit12.cs`)을 연다 | 콤보는 `(3-Way 안 씀)` 그대로, LOCAL (Mine) · BASE · REMOTE (Theirs) · MERGED **네 칸**, 충돌 잡힘 | +| REMOTE 칸 제목 | `REMOTE · feature/theirs` | +| 다른 충돌 파일(XAML)로 옮김 | 네 칸, 충돌 잡힘 | +| 콤보 없이 Take Theirs → 해결로 저장(A-2) | 단추 켜짐, "남은 충돌 21건", 다음 충돌 파일도 네 칸 | +| `git merge --abort` 뒤 충돌 아닌 파일을 연다 | 콤보 `(3-Way 안 씀)`, **두 칸**, 해결로 저장 단추 안 보임 | +| 콤보로 브랜치를 고른다 / 되돌린다 | 네 칸(`REMOTE · feature/theirs`) / 두 칸 — 예전과 같다 | + +화면 확인 **18건 통과**. `MergeSaveTest` 에 병합 메시지 이름 검사 14건을 넣었다(18 → 32). +검사 **21벌 759건 통과**(전부 새로 빌드한 실행 파일) · 빌드 경고 0 · 오류 0. + +### 확인하다 본 것 (B-1 으로 넘긴다) + +`git merge --abort` 뒤 파일 비교 탭의 [새로고침] 을 눌렀는데 **파일 목록이 병합 때의 21개 그대로**였다(커밋 파일은 303개). +A-2 에서 "건너뛴 목록 불러오기가 목록을 비우지 않게" 막았으므로 이제 **비지 않고 옛 목록이 남는다.** +새로고침 도중 게이트를 쥔 채로 목록 다시 세우기가 불리는 뿌리는 그대로라, B-1 에서 본다. +이번 확인에 쓴 `Io/Unit12.cs` 는 두 목록에 다 있어 결과에는 영향이 없다. diff --git a/doc/history/changelog.md b/doc/history/changelog.md index dd56581..2185a4c 100644 --- a/doc/history/changelog.md +++ b/doc/history/changelog.md @@ -8,6 +8,7 @@ | 날짜 | 항목 | 커밋 | |---|---|---| +| 2026-09-14 | **6차 A-3** — 병합 중 충돌 파일을 열면 콤보를 안 건드려도 네 칸이 뜬다. REMOTE 제목에 상대 브랜치 이름(MERGE_HEAD → 병합 메시지 → 짧은 해시) | 아래 | | 2026-09-14 | **6차 A-1** — 충돌 해결 저장이 내 쪽(ours) 파일의 줄끝·인코딩을 따른다(3,363줄 파일 +3,367/−3,363 → +8/−4). 검사 모음이 옛 실행 파일로 돌던 것을 찾아 세 벌을 고침 | 아래 | | 2026-09-14 | **6차 A-2** — 병합 중 [해결로 저장] 을 따로 둠(쓰고 스테이징만). Save & Commit 은 남은 충돌이 있으면 쓰기 전에 막는다. 건너뛴 목록 불러오기가 목록을 비우던 것도 막음 | 아래 | | 2026-09-14 | **지시서 6차 B-5 정함** — 머지 주석은 소스에 넣고, 비교·병합에서는 그 줄을 무시한다. git 은 모르므로 주석 차이뿐인 충돌은 MergeHub 가 푼다 | 아래 | diff --git a/doc/진행상황.md b/doc/진행상황.md index 75a7849..d494423 100644 --- a/doc/진행상황.md +++ b/doc/진행상황.md @@ -95,7 +95,7 @@ |---|---| | **완료** | A-1 3분할로 푼 파일을 저장하면 CRLF 가 LF 로 바뀐다 — 내 쪽(ours) 파일의 줄끝·인코딩을 따른다 | | **완료** | A-2 Save & Commit 이 "실패" 라는데 파일은 이미 써지고 스테이징됐다 — [해결로 저장] 을 따로 둠 | -| 대기 | A-3 충돌 파일을 고르면 상대(Theirs) 칸이 안 나온다 | +| **완료** | A-3 충돌 파일을 고르면 상대(Theirs) 칸이 안 나온다 — 콤보를 안 건드려도 네 칸, REMOTE 제목에 상대 브랜치 | | 대기 | A-4 상대가 지운 파일을 3분할이 다룰 줄 모른다 | | 대기 | A-5 양쪽 새 파일에 Accept Both 를 누르면 빌드가 깨진다 | diff --git a/src/MergeHub/Services/GitService.cs b/src/MergeHub/Services/GitService.cs index 605b7f8..11dc782 100644 --- a/src/MergeHub/Services/GitService.cs +++ b/src/MergeHub/Services/GitService.cs @@ -964,6 +964,48 @@ namespace MergeHub.Services return File.Exists(path) ? File.ReadAllText(path).Trim() : null; } + private string? ReadMergeHeadSha() + { + var path = Path.Combine(Repo.Info.Path, "MERGE_HEAD"); + if (!File.Exists(path)) return null; + + foreach (var line in File.ReadAllLines(path)) + { + var sha = line.Trim(); + if (sha.Length >= 40) return sha.Substring(0, 40); + } + return null; + } + + public string? GetMergeTheirsName() + { + if (Repo.Info.CurrentOperation != CurrentOperation.Merge && !Repo.Index.Conflicts.Any()) return null; + + // 1) 병합해 들어오는 커밋을 가리키는 브랜치가 있으면 그 이름이 가장 정확하다. + // 내 브랜치(HEAD)는 빼고, 로컬을 원격보다 먼저 본다. + // LibGit2Sharp 0.32 에는 MergeHeads 가 없다. git 이 남기는 MERGE_HEAD 파일의 첫 줄이 들어오는 커밋이다. + var sha = ReadMergeHeadSha(); + if (sha is not null) + { + var local = Repo.Branches + .Where(b => !b.IsRemote && !b.IsCurrentRepositoryHead && b.Tip?.Sha == sha) + .Select(b => b.FriendlyName) + .FirstOrDefault(); + if (local is not null) return local; + + var remote = Repo.Branches + .Where(b => b.IsRemote && b.Tip?.Sha == sha) + .Select(b => b.FriendlyName) + .FirstOrDefault(); + if (remote is not null) return remote; + } + + // 2) 브랜치가 이미 앞으로 나갔으면 git 이 남긴 병합 메시지에서 이름을 꺼낸다. + // 3) 그것도 없으면(커밋으로 병합) 짧은 해시. + return MergeNames.TheirsFromMergeMessage(ReadMergeMessage()) + ?? (sha is null ? null : Shorten(sha)); + } + // ================= Diff ================= public IReadOnlyList DiffCommits(string oldCommittish, string newCommittish, params string[] paths) diff --git a/src/MergeHub/Services/IGitService.cs b/src/MergeHub/Services/IGitService.cs index 2706d11..0a63220 100644 --- a/src/MergeHub/Services/IGitService.cs +++ b/src/MergeHub/Services/IGitService.cs @@ -82,6 +82,12 @@ namespace MergeHub.Services /// 충돌 파일의 Base/Mine/Theirs 3분할 본문. ThreeWayContent GetThreeWayContent(string path); + /// + /// 병합 중이면 끌어오는 쪽(상대)의 이름. 브랜치 이름 → 병합 메시지의 이름 → 짧은 해시 차례로 찾는다. + /// 병합 중이 아니면 null. + /// + string? GetMergeTheirsName(); + /// Accept Mine / Theirs / Both 버튼 처리. 파일에 기록하고 인덱스에서 충돌 해제. void ResolveConflict(string path, ConflictResolution resolution); diff --git a/src/MergeHub/Services/MergeNames.cs b/src/MergeHub/Services/MergeNames.cs new file mode 100644 index 0000000..3da3d5a --- /dev/null +++ b/src/MergeHub/Services/MergeNames.cs @@ -0,0 +1,36 @@ +using System.Text.RegularExpressions; + +namespace MergeHub.Services +{ + /// + /// 병합 중에 "상대가 누구인지" 를 사람이 읽는 이름으로 만든다. + /// + /// 3분할의 REMOTE 칸 제목에 쓴다(6차 A-3). 예전에는 늘 "REMOTE · THEIRS" 라고만 적어서 + /// 어느 브랜치를 끌어오는 중인지 화면에서 알 수 없었다. + /// git 이 남기는 병합 메시지만 보고 판단하므로 저장소 없이도 검사할 수 있게 따로 뺐다. + /// + public static class MergeNames + { + private static readonly Regex FirstLine = new Regex( + @"^Merge\s+(?:remote-tracking\s+)?(?:branch|tag)\s+'([^']+)'", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + + /// + /// MERGE_MSG 의 첫 줄에서 브랜치(또는 태그) 이름을 꺼낸다. 못 꺼내면 null. + /// + /// Merge commit '1742d8f...' 처럼 커밋으로 병합한 경우도 null 이다 - + /// 해시는 이름이 아니다. 그때는 부르는 쪽이 짧은 해시로 대신한다. + /// + public static string? TheirsFromMergeMessage(string? message) + { + if (string.IsNullOrWhiteSpace(message)) return null; + + var first = message!.Replace("\r\n", "\n").Split('\n')[0].Trim(); + var match = FirstLine.Match(first); + if (!match.Success) return null; + + var name = match.Groups[1].Value.Trim(); + return name.Length == 0 ? null : name; + } + } +} diff --git a/src/MergeHub/ViewModels/HomeViewModel.cs b/src/MergeHub/ViewModels/HomeViewModel.cs index bcd9643..7a0dc70 100644 --- a/src/MergeHub/ViewModels/HomeViewModel.cs +++ b/src/MergeHub/ViewModels/HomeViewModel.cs @@ -872,9 +872,12 @@ namespace MergeHub.ViewModels string baseText = string.Empty, localText = string.Empty, remoteText = string.Empty; var isConflict = false; - var threeWay = IsThreeWay; + // 콤보로 고른 3-Way 인지만 본다. 앞 파일이 충돌이라 켜 둔 3분할을 그대로 들고 오면 + // 충돌이 아닌 다음 파일을 "(3-Way 안 씀)" 이라는 이름의 브랜치와 견주려다 깨진다. + var threeWay = IsBranchThreeWay; var branch = RemoteBranch; var baseOrigin = string.Empty; + string? theirsName = null; // 다른 작업이 게이트를 쥐고 있으면 RunAsync 는 아무것도 하지 않고 false 를 준다. // 그대로 진행하면 빈 글로 화면을 갈아엎어 "차이 없음"이 뜬다(파일 비교 쪽에서 실제로 겪었다). @@ -891,6 +894,7 @@ namespace MergeHub.ViewModels isConflict = true; threeWay = true; baseOrigin = "merge base"; + theirsName = _workspace.Git.GetMergeTheirsName(); } else if (threeWay) { @@ -936,6 +940,9 @@ namespace MergeHub.ViewModels // 옛 파일인 상태가 된다 - 데모 전환에서 실제로 겪었다. if (!IsGitMode) return; + // 행을 싣기 전에 틀부터 바꾼다. 세 칸 행을 두 칸 틀에 실으면 REMOTE 칸이 안 보인다. + SetConflictThreeWay(isConflict); + Lines.Clear(); foreach (var row in rows) Lines.Add(row); @@ -952,7 +959,7 @@ namespace MergeHub.ViewModels : $"{commit.ShortSha} · {path}"; LocalHeader = _workspace.CurrentBranch; RemoteHeader = isConflict - ? "REMOTE · THEIRS" + ? $"REMOTE · {theirsName ?? "THEIRS"}" : $"REMOTE · {branch}"; _baseTextSnapshot = baseText; @@ -1003,6 +1010,7 @@ namespace MergeHub.ViewModels EditSummary = "-"; HasChanges = false; _loadedAsConflict = false; + SetConflictThreeWay(false); } /// @@ -1268,10 +1276,38 @@ namespace MergeHub.ViewModels [NotifyPropertyChangedFor(nameof(IsThreeWay))] private string _remoteBranch = NoRemoteBranch; - /// 지금 3-Way 로 보고 있는지. Git 모드에서 브랜치를 고른 경우에만 참이다. - public bool IsThreeWay => + /// + /// 지금 3-Way 로 보고 있는지. 두 가지 경우에 참이다. + /// - 콤보에서 브랜치를 골랐다(). + /// - 병합 중인 충돌 파일을 열었다(6차 A-3). 콤보를 안 건드려도 세 칸이 뜬다. + /// 예전에는 충돌 파일을 열어도 BASE 와 내 것 두 칸뿐이라, 상대가 무엇을 바꿨는지 모른 채 풀게 됐다. + /// + public bool IsThreeWay => IsGitMode && (_conflictThreeWay || IsBranchThreeWay); + + /// 콤보에서 브랜치를 골라 켠 3-Way 인지. 충돌 때문에 켠 것은 뺀다. + private bool IsBranchThreeWay => IsGitMode && !string.IsNullOrEmpty(RemoteBranch) && RemoteBranch != NoRemoteBranch; + /// + /// 병합 충돌 파일이라 켠 3분할. 콤보 값과 따로 둔다 - 콤보를 상대 브랜치로 바꿔 두면 + /// 병합이 끝난 뒤에도 보통 파일이 그 브랜치와 3-Way 로 열려, 사람이 고르지 않은 비교가 남는다. + /// + private bool _conflictThreeWay; + + private void SetConflictThreeWay(bool on) + { + if (_conflictThreeWay == on) return; + _conflictThreeWay = on; + + // 콤보로 바꿀 때(OnRemoteBranchChanged)와 같은 것들을 알린다 - 화면이 4분할로 바뀌고 제목·머리 높이도 바뀐다. + OnPropertyChanged(nameof(IsThreeWay)); + OnPropertyChanged(nameof(IsTwoWay)); + OnPropertyChanged(nameof(BaseTitle)); + OnPropertyChanged(nameof(LocalTitle)); + OnPropertyChanged(nameof(SourceHeaderHeight)); + OnPropertyChanged(nameof(IsMergeComplete)); + } + public bool IsTwoWay => !IsThreeWay; ///