6차 A-3: 충돌 파일을 열면 콤보를 안 건드려도 세 칸이 뜬다

병합 중에 충돌 파일을 고르면 BASE 와 내 것 두 칸뿐이었다. 읽기는
충돌 갈래(세 벌)로 했지만 켠 것은 지역 변수 threeWay 뿐이고, 화면
틀을 정하는 IsThreeWay 는 콤보에서 브랜치를 골랐을 때만 참이었다.
상대가 무엇을 바꿨는지 안 보인 채 풀게 됐다.

- IsThreeWay: 콤보로 고른 3-Way 이거나 충돌 파일이라 켠 3분할이면 참.
  충돌 쪽은 따로 둔 깃발이라 콤보 값은 건드리지 않는다
- LoadFileAsync: 행을 싣기 전에 틀부터 바꾸고, 읽기 갈래는 콤보로
  고른 3-Way 만 본다(앞 파일의 3분할이 다음 파일로 새지 않게)
- GetMergeTheirsName: MERGE_HEAD 를 가리키는 브랜치 이름, 없으면
  병합 메시지의 이름, 그것도 없으면 짧은 해시
- MergeNames: 병합 메시지에서 이름 꺼내기(저장소 없이 검사)
- REMOTE 제목에 상대 브랜치 이름

화면 확인 18건 통과(충돌 파일 네 칸, 제목 REMOTE · feature/theirs,
콤보 없이 해결로 저장, 병합이 끝나면 두 칸, 콤보 3-Way 는 예전과 같음).
MergeSaveTest 14건 추가. 21벌 759건 통과(새로 빌드한 실행 파일).
경고 0, 오류 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
김승준 2026-09-14 10:47:28 +09:00
parent 538efdcd50
commit 0d9719d833
7 changed files with 187 additions and 5 deletions

View File

@ -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` 는 두 목록에 다 있어 결과에는 영향이 없다.

View File

@ -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 가 푼다 | 아래 |

View File

@ -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 를 누르면 빌드가 깨진다 |

View File

@ -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<FileDiff> DiffCommits(string oldCommittish, string newCommittish, params string[] paths)

View File

@ -82,6 +82,12 @@ namespace MergeHub.Services
/// <summary>충돌 파일의 Base/Mine/Theirs 3분할 본문.</summary>
ThreeWayContent GetThreeWayContent(string path);
/// <summary>
/// 병합 중이면 끌어오는 쪽(상대)의 이름. 브랜치 이름 → 병합 메시지의 이름 → 짧은 해시 차례로 찾는다.
/// 병합 중이 아니면 null.
/// </summary>
string? GetMergeTheirsName();
/// <summary>Accept Mine / Theirs / Both 버튼 처리. 파일에 기록하고 인덱스에서 충돌 해제.</summary>
void ResolveConflict(string path, ConflictResolution resolution);

View File

@ -0,0 +1,36 @@
using System.Text.RegularExpressions;
namespace MergeHub.Services
{
/// <summary>
/// 병합 중에 "상대가 누구인지" 를 사람이 읽는 이름으로 만든다.
///
/// 3분할의 REMOTE 칸 제목에 쓴다(6차 A-3). 예전에는 늘 "REMOTE · THEIRS" 라고만 적어서
/// 어느 브랜치를 끌어오는 중인지 화면에서 알 수 없었다.
/// git 이 남기는 병합 메시지만 보고 판단하므로 저장소 없이도 검사할 수 있게 따로 뺐다.
/// </summary>
public static class MergeNames
{
private static readonly Regex FirstLine = new Regex(
@"^Merge\s+(?:remote-tracking\s+)?(?:branch|tag)\s+'([^']+)'",
RegexOptions.Compiled | RegexOptions.CultureInvariant);
/// <summary>
/// MERGE_MSG 의 첫 줄에서 브랜치(또는 태그) 이름을 꺼낸다. 못 꺼내면 null.
///
/// <c>Merge commit '1742d8f...'</c> 처럼 커밋으로 병합한 경우도 null 이다 -
/// 해시는 이름이 아니다. 그때는 부르는 쪽이 짧은 해시로 대신한다.
/// </summary>
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;
}
}
}

View File

@ -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);
}
/// <summary>
@ -1268,10 +1276,38 @@ namespace MergeHub.ViewModels
[NotifyPropertyChangedFor(nameof(IsThreeWay))]
private string _remoteBranch = NoRemoteBranch;
/// <summary>지금 3-Way 로 보고 있는지. Git 모드에서 브랜치를 고른 경우에만 참이다.</summary>
public bool IsThreeWay =>
/// <summary>
/// 지금 3-Way 로 보고 있는지. 두 가지 경우에 참이다.
/// - 콤보에서 브랜치를 골랐다(<see cref="IsBranchThreeWay"/>).
/// - <b>병합 중인 충돌 파일을 열었다</b>(6차 A-3). 콤보를 안 건드려도 세 칸이 뜬다.
/// 예전에는 충돌 파일을 열어도 BASE 와 내 것 두 칸뿐이라, 상대가 무엇을 바꿨는지 모른 채 풀게 됐다.
/// </summary>
public bool IsThreeWay => IsGitMode && (_conflictThreeWay || IsBranchThreeWay);
/// <summary>콤보에서 브랜치를 골라 켠 3-Way 인지. 충돌 때문에 켠 것은 뺀다.</summary>
private bool IsBranchThreeWay =>
IsGitMode && !string.IsNullOrEmpty(RemoteBranch) && RemoteBranch != NoRemoteBranch;
/// <summary>
/// 병합 충돌 파일이라 켠 3분할. 콤보 값과 따로 둔다 - 콤보를 상대 브랜치로 바꿔 두면
/// 병합이 끝난 뒤에도 보통 파일이 그 브랜치와 3-Way 로 열려, 사람이 고르지 않은 비교가 남는다.
/// </summary>
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;
/// <summary>