8차 F-1: 병합 커밋 본문에 충돌 해결 내역을 자동으로 적기

충돌 탭 파일 단위 Accept 는 소스에 주석이 안 남고, 병합 커밋 본문은 비어 있었고,
Git 탭 로그는 끄면 사라져 누가 어느 파일을 어떻게 풀었는지 남는 곳이 없었다(사용자 요청).

- MergeResolutionLog: 기록 파일(첫 줄 MERGE_HEAD) 읽기 · 쓰기, 본문 만들기, 메시지에 붙이기
- GitService: 병합 중일 때만 .git/MERGEHUB_RESOLUTIONS 에 적고, CommitMerge 가 본문을 붙인다.
  병합 커밋 · 작업 취소 · 새로고침 때 쓸모없는 기록을 치운다
- 충돌 탭 Accept Mine/Theirs/Both · [Save] · Save & Commit · 파일 남기기/지우기 · 머지 주석 자동이 적는다
- Git 탭 메시지 칸에 본문을 미리 채우고, 풀 때마다 새로 채운다(사람이 고치면 두고 커밋 때 붙임)
- 하네스 MergeSaveTest 183건(21건 추가), 22벌 1,094건 통과

확인: 병합 "충돌 해결 26개 · 김승준"(줄 단위 7 · Theirs 15 · 남기기 1 · 지우기 1 · 주석 자동 2),
pull 26개, Accept Mine 기록 뒤 [작업 취소] 로 기록 파일 없음. 줄끝 · BOM 0/33, 회귀 없음.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
김승준 2026-09-17 09:33:32 +09:00
parent 82d4b277f3
commit d25f374a0e
10 changed files with 399 additions and 1 deletions

View File

@ -344,3 +344,79 @@ MergeHub 를 다시 빌드해 배포본으로 같은 값을 다시 쟀다(앱
- 발행: 같은 주소의 아티팩트를 **버전 7** 로 갱신했다(읽어서 옛 내용을 확인한 뒤 올림). 저장소의 `doc/web/index.html` 과 같은 파일이다.
제품 코드는 바꾸지 않았다.
## F-1. 병합 커밋 메시지 본문에 충돌 해결 내역을 자동으로 적기 (2026-09-17, 추가 요청, 완료)
사용자: "파일단위 머지할때 이력은 그럼 어디남아?" → 제안 중 **병합 커밋 본문에 해결 내역을 자동으로 적기**로 정함.
함께 "스타일 맞추는건 안할거야" — 비교할 때만 C# 스타일을 맞추고 저장은 원래대로 하는 선택지는 만들지 않는다.
### 고치기 전
충돌을 어떻게 풀었는지가 오래 남는 곳이 없었다.
| 어디 | 남는 것 | 한계 |
|---|---|---|
| 소스 | 네 칸에서 줄을 가져오면 `// [이름] 날짜` | 충돌 탭 **파일 단위 Accept** 는 고른 쪽 파일을 바이트째 써서 주석이 없다 |
| git 병합 커밋 | 부모 둘 · 제목 · 작성자 | **본문이 비어 있었다** — 7차 D-1(`fce391f` "본문 없음") · 8차 A-1(`3ba6314`) 에서 잰 대로 |
| Git 탭 로그 | `충돌 해결(Theirs): 경로` | 화면에만 있고 끄면 사라진다(`GitWorkspace.AppendLog` 는 글 속성에만 쌓음) |
| 플랫폼 로그 `D:\UbisamConfig\Log` | `버튼 클릭: Accept Theirs` | 파일 이름도 사람도 없다 |
### 고친 것
| 파일 | 무엇 |
|---|---|
| `Services/MergeResolutionLog.cs` (새 파일) | `MergeResolutionKind`(줄 단위 · Accept Mine · Theirs · Both · 파일 남기기 · 지우기 · 머지 주석 자동). 기록 파일 읽기 · 쓰기(`Parse` · `Record` — 첫 줄 MERGE_HEAD, "종류 탭 경로", 같은 파일은 마지막 것). 본문 만들기(`Body` — "충돌 해결 N개 · 이름", 갈래마다 파일 한 줄씩, 한 갈래 30개 넘으면 "… 외 N개"). 메시지에 붙이기(`WithBody` — 옛 블록은 갈아 끼워 두 번 안 들어가고, 사람이 쓴 본문은 지키고 뒤에 붙인다) |
| `Services/GitService.cs` | `RecordResolution` — 병합 중(MERGE_HEAD)일 때만 `.git/MERGEHUB_RESOLUTIONS` 에 적는다. 못 적어도 푸는 일은 막지 않는다. `GetResolutionBody` · `ResolverName`. 머지 주석 자동 풀기가 적는다. **`CommitMerge` 가 본문을 붙인다** — 사람이 메시지를 고쳤어도 블록은 붙는다(유일한 기록). `ForgetStaleMergeConflicts` 가 기록 파일도 같은 규칙으로 치운다(병합 커밋 · 작업 취소 · 새로고침) |
| `Services/IGitService.cs` | 선언 |
| `Services/GitWorkspace.cs` | 상태를 읽을 때 이름을 넘기고(`Git.ResolverName`) 본문을 `MergeResolutionBody` 로 들고 있는다 |
| `ViewModels/GitViewModel.cs` | 충돌 탭 Accept Mine · Theirs · Both 가 적는다. 병합 메시지를 채울 때 본문을 붙이고, 본문이 바뀌면(충돌을 하나 풀 때마다) **메시지 칸을 새로 채운다** — 사람이 칸을 고쳤으면 두고, 커밋 때 붙는다 |
| `ViewModels/HomeViewModel.cs` | [Save] · Save & Commit 은 "줄 단위", 띠 [파일 남기기] · [파일 지우기] 가 적는다 |
| 하네스 `MergeSaveTest` | 21건 — 기록(첫 줄 · 다른 병합 버림 · 같은 파일 마지막 것 · 역슬래시 · 빈 글), 본문(첫 줄 · 갈래 이름 · 파일마다 한 줄 · 순서 · 없음 · 사람 모름 · 30개 넘음), 메시지(제목 · 빈 줄 · 본문 · 두 번 안 붙음 · 갈아 끼움 · 사람 본문 지킴 · 걷어냄 · CRLF · 제목 없음) |
cherry-pick · revert · rebase 는 원래 커밋 메시지를 쓰므로 이번에는 적지 않는다(MERGE_HEAD 가 없어 `RecordResolution` 이 건너뛴다).
### 확인한 것 (길마다 저장소를 새로 만들어, MergeHub 만으로)
**① 병합을 사람 순서 그대로 끝까지** (파일 단위 15 · 네 칸 7 · 띠 2 · 머지 주석 자동 2)
- [병합 커밋] 전 **Git 탭 메시지 칸에 본문이 미리 보였다.**
- 커밋 `d023a8c` 본문:
```
Merge branch 'feature/theirs' into feature/mine
충돌 해결 26개 · 김승준
줄 단위 (파일 비교에서 골라 Save) 7
Ui/Views/NewPanel.cs … Weld/Unit1.cs
Accept Theirs (상대 쪽 파일 그대로) 15
Common/Util/Unit15.cs … Weld/Unit17.cs
파일 남기기 1
Recipe/ToBeDeletedByMe.cs
파일 지우기 1
Recipe/ToBeDeleted.cs
머지 주석만 달라 자동으로 풂 2
Ui/Dialogs/Unit46.cs
Ui/Views/Unit45.cs
```
- 7 + 15 + 1 + 1 + 2 = **26 = 처음 충돌 수**. 커밋 뒤 `MERGEHUB_RESOLUTIONS` · `MERGEHUB_CONFLICTS` 없음. 줄끝 · BOM 바뀐 파일 0/33 · 충돌 표식 0 · class 이름 검사 0 · 워킹트리 Clean (회귀 없음).
**② 병합 → Accept Mine 두 개 → [작업 취소]**
| 때 | 결과 |
|---|---|
| Accept Mine 뒤 기록 파일 | 첫 줄 MERGE_HEAD, `NoteAuto` 2 · **`Mine Io/Unit12.cs` · `Mine Io/Unit20.cs`** |
| 메시지 칸 | "충돌 해결 4개 · 김승준 / Accept Mine (내 쪽 파일 그대로) 2 / 머지 주석만 달라 자동으로 풂 2" |
| [작업 취소] 뒤 | git 충돌 0 · **기록 파일 없음** |
**③ pull → 전부 파일 단위 Accept Theirs → [병합 커밋]**
- 본문 "`Merge remote-tracking branch 'origin/feature/mine' into feature/mine`" 아래 **충돌 해결 26개** — Accept Theirs 24(지운 파일 둘도 파일 단위로 골랐으므로 여기에) · 머지 주석 자동 2. 기록 파일 없음 · Clean.
하네스 22벌 1,094건 통과(MergeSaveTest 183). 빌드 경고 0 · 오류 0. 설정 파일은 바이트 그대로 되돌림.
### 알아 둘 것
- 병합 커밋 **작성자**는 지금처럼 git `user.name` 이고, 본문의 이름은 MergeHub 의 작업자 이름(설정 [머지 주석] 작성자 → 켤 때 이름 → git `user.name` 순)이다. 둘이 다르면 둘 다 남는다.
- 메시지 칸에서 본문을 지워도 커밋할 때 다시 붙는다 — 파일 단위 Accept 의 유일한 기록이라 뺄 수 없게 했다.
- MergeHub 밖(터미널)에서 푼 충돌은 적히지 않는다. 본문 건수가 처음 충돌 수보다 적으면 그만큼은 밖에서 푼 것이다.

View File

@ -8,6 +8,7 @@
| 날짜 | 항목 | 커밋 |
|---|---|---|
| 2026-09-17 | **8차 F-1 (추가 요청)** — 충돌 탭 파일 단위 Accept 는 소스에 주석이 안 남고, 병합 커밋 본문은 비어 있었고, Git 탭 로그는 끄면 사라져 **누가 어느 파일을 어떻게 풀었는지 남는 곳이 없었다**. 병합 · pull 중 푼 방법(줄 단위 · Accept Mine/Theirs/Both · 파일 남기기/지우기 · 머지 주석 자동)을 .git/MERGEHUB_RESOLUTIONS 에 적고, [병합 커밋] 본문에 "충돌 해결 N개 · 이름" 과 갈래별 파일 목록을 붙인다(Git 탭 메시지 칸에 미리 보임, 사람이 고쳐도 커밋 때 붙음). 기록은 병합 커밋 · 작업 취소 때 치운다. 병합 26개 · pull 26개 · Accept Mine · 취소 확인. 스타일 맞춤 선택지는 만들지 않기로(사용자) | 아래 |
| 2026-09-16 | **8차 D-1** — 웹 문서(doc/web/index.html)가 6차에서 멈춰 있었다. 7차 · 8차에서 바뀐 것을 매뉴얼에 넣고(창 크기 조절 · 이름 창 잠금 · 폴더 끌어다 놓기 차례 · 작업별 네 칸 제목과 끝내기 단추 · MERGED 줄 번호 · pull · rebase · cherry-pick · revert 가 같은 길), "알아 둘 한계" 를 지금 기준으로 다시 썼다(사라진 네 줄 빼고 스냅 한계 추가). 남은 일 표를 7차 · 8차로 갈아 끼우고 숫자 갱신(22벌 1,073건 · 커밋 94). 발행된 아티팩트도 버전 7 로 갱신. 제품 코드 변경 없음 | 아래 |
| 2026-09-16 | **8차 C-3** — 7차 E-4 에서 남긴 넷을 실제 픽셀로 쟀다. 기억 없이 처음 Win+↓ 로 내리면 1600×900 DIP 가 화면 아래로 16px 넘치고 작업 표시줄을 가렸고, 최대화 창에서 이름 창이 세로 36px 위에 떴다. 사용자 결정대로 첫 복원을 두 번 누르기 복원과 같게(작업 영역 80% · 가운데), 덮는 창을 본 창 실제 사각형 가운데로 고침(플랫폼 ShellWindow · 새 OwnerCentering). 스냅은 최소 크기(1280×720 DIP) 때문에 화면 반쪽이 될 수 없음을 적어 둠, 1px 테두리는 그대로. 플랫폼 수정 · 배포 | 아래 |
| 2026-09-16 | **8차 C-2** — 7차에서 코드로만 봤던 "cherry-pick 은 원래 작성자를 남긴다" 를 화면으로 확인(제품 코드 변경 없음). 생성기에서 상대 쪽 작업 만 작성자를 박지훈으로 두고, MergeHub 와 git 이 각각 cherry-pick · revert 를 하게 해 견줌 — cherry-pick 은 둘 다 작성자 박지훈 · 커밋한 사람 시험, revert 는 둘 다 시험. 이력 목록에도 박지훈으로 보인다 | 아래 |

View File

@ -149,6 +149,33 @@
---
## F. 추가 요청
### F-1. 병합 커밋 메시지 본문에 충돌 해결 내역을 자동으로 적기 (2026-09-17, 사용자)
사용자: "파일단위 머지할때 이력은 그럼 어디남아?" → 제안 중 "병합 커밋 메시지 본문에 해결 내역을 자동으로 적기" 로 정함. "스타일 맞추는건 안할거야"(비교할 때만 스타일을 맞추고 저장은 원래대로 하는 선택지는 만들지 않는다).
**지금 모양** — 충돌을 어떻게 풀었는지가 오래 남는 곳이 없다.
| 어디 | 남는 것 | 한계 |
|---|---|---|
| 소스 | 네 칸에서 줄을 가져오면 `// [이름] 날짜` 주석 | 충돌 탭 **파일 단위 Accept** 는 고른 쪽 파일을 바이트째 써서 주석이 없다 |
| git 병합 커밋 | 부모 둘 · 제목 · 작성자(git `user.name`) | "이 파일은 Theirs 로 골랐다" 가 직접 적히지 않는다 — 7차 D-1 · 8차 A-1 의 병합 커밋 본문은 비어 있었다 |
| Git 탭 로그 | `충돌 해결(Theirs): 경로` | 화면에만 있고 끄면 사라진다 |
| 플랫폼 로그 | `버튼 클릭: Accept Theirs` | 파일 이름도 사람도 없다 |
**방향**
- 병합(및 pull) 중에 충돌을 풀 때마다 **무엇으로 풀었는지** 적어 둔다 — 줄 단위(Save) · Accept Mine · Accept Theirs · Accept Both · 파일 남기기 · 파일 지우기 · 머지 주석 자동. 앱을 껐다 켜도 이어지게 `.git` 에 적고, 처음 충돌 목록(`MERGEHUB_CONFLICTS`)과 같은 규칙(첫 줄 MERGE_HEAD · 병합 커밋 · 작업 취소 · 새로고침 때 쓸모없으면 치움)을 따른다.
- [병합 커밋](Save & Commit 포함) 때 본문에 **"충돌 해결 N개 · 이름"** 과 갈래별 파일 목록을 붙인다. Git 탭 메시지 칸에도 미리 채워 보인다. 사람이 칸을 고쳐도 커밋 때 블록은 붙는다(유일한 기록이므로).
- cherry-pick · revert · rebase 는 원래 커밋 메시지를 쓰므로 이번에는 적지 않는다.
**판정 기준**
- 만든 저장소를 MergeHub 로 병합해 여러 방법으로 풀고 [병합 커밋] 하면, `git log -1 --format=%B` 본문에 방법별 파일이 모두 적힌다. 파일 수 합 = 푼 충돌 수.
- 병합 커밋 · 작업 취소 뒤 기록 파일이 남지 않는다. 다음 병합에 지난 기록이 섞이지 않는다.
- 병합 제목 · 커밋 방식 · 줄끝 등 7차 · 8차 결과는 그대로(회귀 없음).
---
## 이번 지시서에서 하지 않는 것
- **실제 소스로 병합해 보기** — 실제 소스는 열지 않는다(2026-09-11 결정).

View File

@ -229,6 +229,12 @@
|---|---|
| 대기 | E-1 네 길(+ revert)을 사람 순서 그대로 다시 끝까지 — 파일 비교 탭을 미리 열지 않고 |
### F. 추가 요청
| 상태 | 항목 |
|---|---|
| **완료** | F-1 병합 커밋 메시지 본문에 충돌 해결 내역을 자동으로 적기 (2026-09-17 사용자 요청) — 병합 · pull 중 푼 방법(줄 단위 · Accept Mine/Theirs/Both · 남기기 · 지우기 · 머지 주석 자동)을 `.git` 에 적고 [병합 커밋] 본문에 "충돌 해결 N개 · 이름" + 파일 목록. 메시지 칸에 미리 보임, 커밋 · 취소 때 기록 치움. 스타일 맞춤 선택지는 만들지 않기로 |
---
## 3~4주차로 미룬 것

View File

@ -900,6 +900,9 @@ namespace MergeHub.Services
/// </summary>
public bool ForgetStaleMergeConflicts()
{
// 해결 기록(8차 F-1)도 같은 규칙으로 치운다 - 첫 줄이 지금 MERGE_HEAD 가 아니면 쓸모없다.
ForgetStaleRecord(MergeResolutionLog.FileName);
var path = Path.Combine(Repo.Info.Path, MergeConflictListFile);
if (!File.Exists(path)) return false;
@ -915,6 +918,58 @@ namespace MergeHub.Services
catch (UnauthorizedAccessException) { return false; }
}
private void ForgetStaleRecord(string fileName)
{
var path = Path.Combine(Repo.Info.Path, fileName);
if (!File.Exists(path)) return;
try
{
var first = File.ReadAllLines(path).FirstOrDefault();
if (MergeConflictRecord.IsStale(ReadMergeHeadSha(), first)) File.Delete(path);
}
catch (IOException) { }
catch (UnauthorizedAccessException) { }
}
/// <summary>병합 커밋 본문에 "누가" 로 적을 이름(8차 F-1). 작업 공간이 상태를 읽을 때 채운다.</summary>
public string? ResolverName { get; set; }
/// <summary>
/// 병합 충돌 하나를 어떻게 풀었는지 적어 둔다(8차 F-1). 병합 중이 아니면(cherry-pick · revert · rebase) 적지 않는다.
/// 적는 데 실패해도 푸는 일은 막지 않는다 - 기록은 덤이다.
/// </summary>
public void RecordResolution(string path, MergeResolutionKind kind)
{
var head = ReadMergeHeadSha();
if (string.IsNullOrEmpty(head) || string.IsNullOrWhiteSpace(path)) return;
try
{
var file = Path.Combine(Repo.Info.Path, MergeResolutionLog.FileName);
var existing = File.Exists(file) ? File.ReadAllText(file) : null;
File.WriteAllText(file, MergeResolutionLog.Record(existing, head!, NormalizePath(path), kind), new UTF8Encoding(false));
}
catch (IOException) { }
catch (UnauthorizedAccessException) { }
}
/// <summary>지금 병합의 해결 내역 본문(8차 F-1). 적힌 것이 없으면 null.</summary>
public string? GetResolutionBody()
{
var head = ReadMergeHeadSha();
if (string.IsNullOrEmpty(head)) return null;
try
{
var file = Path.Combine(Repo.Info.Path, MergeResolutionLog.FileName);
if (!File.Exists(file)) return null;
return MergeResolutionLog.Body(MergeResolutionLog.Parse(File.ReadAllText(file), head), ResolverName);
}
catch (IOException) { return null; }
catch (UnauthorizedAccessException) { return null; }
}
public ThreeWayContent GetThreeWayContent(string path)
{
var normalized = NormalizePath(path);
@ -1033,6 +1088,7 @@ namespace MergeHub.Services
if (!NoteConflict.TryResolve(marked, conflict.Path, out var resolved, out var blocks)) continue;
ResolveConflictWithContent(conflict.Path, resolved);
RecordResolution(conflict.Path, MergeResolutionKind.NoteAuto);
Report($"머지 주석만 다른 충돌 {blocks}곳 자동 해결: {conflict.Path}");
solved.Add(conflict.Path);
}
@ -1052,9 +1108,13 @@ namespace MergeHub.Services
if (string.IsNullOrWhiteSpace(message))
message = MergeNames.CleanMergeMessage(ReadMergeMessage(), GetMergeTheirsName(), Repo.Head.FriendlyName);
// 충돌을 누가 어떻게 풀었는지 본문에 붙인다(8차 F-1). 사람이 메시지를 고쳤어도 이 블록은 붙는다 -
// 파일 단위 Accept 는 소스에 흔적이 없어 여기가 유일한 기록이다. 이미 붙어 있으면 새것으로 갈아 끼운다.
message = MergeResolutionLog.WithBody(message, GetResolutionBody());
var signature = BuildSignature();
var commit = Repo.Commit(message, signature, signature);
ForgetStaleMergeConflicts(); // 병합이 끝났으니 처음 충돌 목록도 치운다(7차 B-1)
ForgetStaleMergeConflicts(); // 병합이 끝났으니 처음 충돌 목록 · 해결 기록도 치운다(7차 B-1 · 8차 F-1)
Report($"병합 커밋: {Shorten(commit.Sha)} {commit.MessageShort}");
return commit.Sha;
}

View File

@ -182,6 +182,9 @@ namespace MergeHub.Services
/// <summary>이번 병합에서 처음 난 충돌 파일 수(6차 C-1). 병합 중이 아니거나 모르면 null.</summary>
[ObservableProperty] private int? _mergeConflictTotal;
/// <summary>지금 병합에서 충돌을 어떻게 풀었는지 - 병합 커밋 본문에 붙는 글(8차 F-1). 병합 중이 아니거나 적힌 것이 없으면 null.</summary>
[ObservableProperty] private string? _mergeResolutionBody;
/// <summary>
/// 충돌 탭 목록 위의 진행 한 줄(6차 C-1). 예전에는 몇 개를 풀었는지 알 수 없었다.
/// 처음 충돌 수는 병합할 때 .git 에 적어 두므로 앱을 다시 켜도 이어진다.
@ -371,6 +374,10 @@ namespace MergeHub.Services
var head = branches.FirstOrDefault(b => b.IsCurrentHead);
var author = ResolveAuthorName();
// 병합 커밋 본문의 "누가" 와 메시지 칸에 미리 보일 해결 내역(8차 F-1).
Git.ResolverName = author;
var resolutionBody = mergeInProgress ? Git.GetResolutionBody() : null;
OnUi(() =>
{
RepositoryPath = info.WorkingDirectory;
@ -402,6 +409,7 @@ namespace MergeHub.Services
Replace(StatusFiles, status.Files);
Replace(Conflicts, conflicts);
MergeConflictTotal = conflictTotal;
MergeResolutionBody = resolutionBody;
OnPropertyChanged(nameof(ConflictProgressText));
Replace(Tags, tags);
Replace(Submodules, submodules);

View File

@ -104,6 +104,15 @@ namespace MergeHub.Services
/// <summary>인덱스가 HEAD 와 다른가 - 지금 커밋할 것이 있는가(8차 B-2).</summary>
bool HasStagedChanges();
/// <summary>병합 커밋 본문에 "누가" 로 적을 이름(8차 F-1).</summary>
string? ResolverName { get; set; }
/// <summary>병합 충돌 하나를 어떻게 풀었는지 적어 둔다(8차 F-1). 병합 중이 아니면 적지 않는다.</summary>
void RecordResolution(string path, MergeResolutionKind kind);
/// <summary>지금 병합의 해결 내역 본문(8차 F-1). 없으면 null.</summary>
string? GetResolutionBody();
/// <summary>충돌을 다 푼 cherry-pick · revert 를 커밋해 끝낸다. 병합이면 <see cref="CommitMerge"/> 와 같다(7차 C-2).</summary>
string CommitPendingOperation(string? message = null);

View File

@ -0,0 +1,158 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
namespace MergeHub.Services
{
/// <summary>병합 충돌을 어떻게 풀었는지(8차 F-1).</summary>
public enum MergeResolutionKind
{
/// <summary>네 칸(또는 두 칸)에서 줄을 골라 [Save] · Save &amp; Commit.</summary>
Lines,
/// <summary>충돌 탭 Accept Mine - 내 쪽 파일 그대로.</summary>
Mine,
/// <summary>충돌 탭 Accept Theirs - 상대 쪽 파일 그대로.</summary>
Theirs,
/// <summary>충돌 탭 Accept Both - 두 파일을 이어 붙임.</summary>
Both,
/// <summary>한쪽이 지운 충돌에서 [파일 남기기].</summary>
Keep,
/// <summary>한쪽이 지운 충돌에서 [파일 지우기].</summary>
Delete,
/// <summary>머지 주석만 달라 MergeHub 가 저절로 풂(6차 B-5).</summary>
NoteAuto
}
/// <summary>
/// 병합 충돌을 누가 어떻게 풀었는지 적어 두었다가 병합 커밋 본문에 붙인다(8차 F-1, 사용자 요청).
///
/// <b>따로 둔 까닭</b> - 충돌 탭의 파일 단위 Accept 는 고른 쪽 파일을 바이트째 쓰므로 소스에 머지 주석이 안 남는다.
/// 로그는 화면에만 있고 플랫폼 로그에는 단추 이름뿐이라, "이 파일은 왜 상대 쪽으로 덮였나" 를 나중에 찾을 곳이 없었다.
/// git 이력(병합 커밋 본문)에 남기면 소스를 더럽히지 않고 저장소와 함께 간다.
///
/// 기록 파일(<c>.git/MERGEHUB_RESOLUTIONS</c>)은 첫 줄이 MERGE_HEAD, 그 아래 "종류 탭 경로" 한 줄씩이다.
/// 첫 줄이 지금 병합과 다르면 지난 병합의 것이라 버린다 - 처음 충돌 목록(<c>MERGEHUB_CONFLICTS</c>)과 같은 규칙이다.
/// </summary>
public static class MergeResolutionLog
{
public const string FileName = "MERGEHUB_RESOLUTIONS";
/// <summary>본문 블록의 첫 줄 머리. 메시지를 다시 만들 때 이 줄부터 끝까지를 갈아 끼운다.</summary>
public const string Title = "충돌 해결";
/// <summary>한 갈래에 파일 이름을 몇 개까지 적을지. 넘으면 "… 외 N개".</summary>
public const int MaxPathsPerGroup = 30;
private static readonly MergeResolutionKind[] GroupOrder =
{
MergeResolutionKind.Lines, MergeResolutionKind.Mine, MergeResolutionKind.Theirs, MergeResolutionKind.Both,
MergeResolutionKind.Keep, MergeResolutionKind.Delete, MergeResolutionKind.NoteAuto
};
/// <summary>본문에 적는 갈래 이름. 화면의 단추 이름을 그대로 쓴다.</summary>
public static string Label(MergeResolutionKind kind) => kind switch
{
MergeResolutionKind.Lines => "줄 단위 (파일 비교에서 골라 Save)",
MergeResolutionKind.Mine => "Accept Mine (내 쪽 파일 그대로)",
MergeResolutionKind.Theirs => "Accept Theirs (상대 쪽 파일 그대로)",
MergeResolutionKind.Both => "Accept Both (두 파일 이어 붙임)",
MergeResolutionKind.Keep => "파일 남기기",
MergeResolutionKind.Delete => "파일 지우기",
MergeResolutionKind.NoteAuto => "머지 주석만 달라 자동으로 풂",
_ => kind.ToString()
};
/// <summary>기록 파일 글을 읽는다. 지금 병합(MERGE_HEAD)의 것이 아니면 빈 목록.</summary>
public static IReadOnlyList<(string Path, MergeResolutionKind Kind)> Parse(string? text, string? mergeHead)
{
var entries = new List<(string Path, MergeResolutionKind Kind)>();
if (string.IsNullOrWhiteSpace(text) || string.IsNullOrWhiteSpace(mergeHead)) return entries;
var lines = text!.Replace("\r\n", "\n").Split('\n');
if (!string.Equals(lines[0].Trim(), mergeHead!.Trim(), StringComparison.OrdinalIgnoreCase)) return entries;
foreach (var raw in lines.Skip(1))
{
var tab = raw.IndexOf('\t');
if (tab <= 0) continue;
var path = Normalize(raw.Substring(tab + 1));
if (path.Length == 0) continue;
if (!Enum.TryParse(raw.Substring(0, tab).Trim(), out MergeResolutionKind kind)) continue;
// 같은 파일을 다시 풀었으면(되돌리고 다시 병합 등) 마지막 것이 맞다.
entries.RemoveAll(e => string.Equals(e.Path, path, StringComparison.Ordinal));
entries.Add((path, kind));
}
return entries;
}
/// <summary>한 건을 더한 기록 파일 글을 돌려준다. 지난 병합의 기록이었으면 버리고 새로 시작한다.</summary>
public static string Record(string? existing, string mergeHead, string path, MergeResolutionKind kind)
{
var entries = Parse(existing, mergeHead).ToList();
var normalized = Normalize(path);
entries.RemoveAll(e => string.Equals(e.Path, normalized, StringComparison.Ordinal));
if (normalized.Length > 0) entries.Add((normalized, kind));
var sb = new StringBuilder();
sb.Append(mergeHead.Trim()).Append('\n');
foreach (var entry in entries) sb.Append(entry.Kind).Append('\t').Append(entry.Path).Append('\n');
return sb.ToString();
}
/// <summary>
/// 병합 커밋 본문 블록. 기록이 없으면 null.
/// <code>
/// 충돌 해결 26개 · 김승준
/// Accept Theirs (상대 쪽 파일 그대로) 15
/// Common/Util/Unit15.cs
/// </code>
/// </summary>
public static string? Body(IReadOnlyList<(string Path, MergeResolutionKind Kind)> entries, string? who)
{
if (entries is null || entries.Count == 0) return null;
var sb = new StringBuilder();
sb.Append(Title).Append(' ').Append(entries.Count).Append('개');
if (!string.IsNullOrWhiteSpace(who)) sb.Append(" · ").Append(who!.Trim());
foreach (var kind in GroupOrder)
{
var paths = entries.Where(e => e.Kind == kind).Select(e => e.Path).ToList();
if (paths.Count == 0) continue;
sb.Append('\n').Append(Label(kind)).Append(' ').Append(paths.Count);
foreach (var path in paths.Take(MaxPathsPerGroup)) sb.Append("\n ").Append(path);
if (paths.Count > MaxPathsPerGroup) sb.Append("\n … 외 ").Append(paths.Count - MaxPathsPerGroup).Append('개');
}
return sb.ToString();
}
/// <summary>
/// 커밋 메시지에 본문 블록을 붙인다. 이미 붙어 있던 블록(<see cref="Title"/> 로 시작하는 문단부터 끝까지)은 새것으로 갈아 끼운다 -
/// 메시지 칸을 다시 채우거나 커밋 직전에 한 번 더 붙여도 두 번 들어가지 않는다. 본문이 없으면 옛 블록만 걷어낸다.
/// </summary>
public static string WithBody(string? message, string? body)
{
var text = (message ?? string.Empty).Replace("\r\n", "\n");
var marker = "\n\n" + Title + " ";
var at = text.IndexOf(marker, StringComparison.Ordinal);
if (at >= 0) text = text.Substring(0, at);
else if (text.StartsWith(Title + " ", StringComparison.Ordinal)) text = string.Empty;
text = text.TrimEnd();
if (string.IsNullOrWhiteSpace(body)) return text;
return text.Length == 0 ? body!.Trim() : text + "\n\n" + body!.Trim();
}
private static string Normalize(string? path) =>
string.IsNullOrWhiteSpace(path) ? string.Empty : path!.Trim().Replace('\\', '/');
}
}

View File

@ -28,6 +28,10 @@ namespace MergeHub.ViewModels
_workspace.Refreshed += () => OnPropertyChanged(nameof(SelectedCommit));
_workspace.Refreshed += SyncStashSelection;
_workspace.StateChanged += RaiseCanExecuteChanged;
_workspace.PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(GitWorkspace.MergeResolutionBody)) RefreshMergeMessageBody();
};
// 원격을 이미 쓰고 있으면 펼쳐 둔다 - 접어 두면 쓰던 사람이 사라진 줄 안다.
_isRemoteOpen = _workspace.Settings.GitRemoteExpanded ||
@ -272,8 +276,17 @@ namespace MergeHub.ViewModels
_workspace.AppendLog($"머지 주석만 다른 충돌 {noteOnly.Count}개 파일은 자동으로 풀었습니다. 남은 충돌 {remaining}건.");
if (string.IsNullOrWhiteSpace(CommitMessage) && !string.IsNullOrWhiteSpace(fillMessage))
{
CommitMessage = fillMessage!;
// 병합 메시지는 우리가 채운 것이다. 해결 내역이 쌓일 때마다 본문을 새로 붙이려고 기억해 둔다(8차 F-1).
if (kind == GitOperationKind.Merge)
{
_autoMergeMessage = MergeResolutionLog.WithBody(fillMessage, _workspace.MergeResolutionBody);
CommitMessage = _autoMergeMessage;
}
}
var autoNote = noteOnly.Count > 0 ? $" (머지 주석만 다른 {noteOnly.Count}건은 자동으로 풀림)" : string.Empty;
var then = kind == GitOperationKind.Merge ? string.Empty : $" 다 풀면 {finish}.";
@ -286,6 +299,41 @@ namespace MergeHub.ViewModels
});
}
/// <summary>메시지 칸에 우리가 채운 병합 메시지(본문 포함). 사람이 고치면 이것과 달라져 더는 건드리지 않는다.</summary>
private string _autoMergeMessage = string.Empty;
/// <summary>
/// 충돌을 하나 풀 때마다 메시지 칸의 해결 내역을 새로 붙인다(8차 F-1). 사람이 칸을 고쳤으면 두고,
/// 커밋할 때 <see cref="Services.GitService.CommitMerge"/> 가 본문을 붙인다 - 어느 쪽이든 기록은 남는다.
/// </summary>
private void RefreshMergeMessageBody()
{
if (_autoMergeMessage.Length == 0) return;
if (!_workspace.IsMergeInProgress)
{
_autoMergeMessage = string.Empty;
return;
}
if (!string.Equals(CommitMessage, _autoMergeMessage, StringComparison.Ordinal)) return;
var updated = MergeResolutionLog.WithBody(_autoMergeMessage, _workspace.MergeResolutionBody);
if (string.Equals(updated, CommitMessage, StringComparison.Ordinal)) return;
_autoMergeMessage = updated;
CommitMessage = updated;
}
/// <summary>충돌 탭 Accept 가 무엇으로 푼 것인지. BASE 로 되돌리기는 이 단추에 없어 적지 않는다.</summary>
private static MergeResolutionKind? ToResolutionKind(ConflictResolution resolution) => resolution switch
{
ConflictResolution.Mine => MergeResolutionKind.Mine,
ConflictResolution.Theirs => MergeResolutionKind.Theirs,
ConflictResolution.Both => MergeResolutionKind.Both,
_ => null
};
private bool CanAbort() =>
_workspace.IsOperationInProgress || _workspace.IsMergeInProgress || _workspace.IsRebaseInProgress;
@ -339,6 +387,7 @@ namespace MergeHub.ViewModels
_workspace.OnUi(() =>
{
CommitMessage = string.Empty;
_autoMergeMessage = string.Empty;
MessageUtil.ShowSuccessToast($"병합 커밋 완료 ({sha.Substring(0, 7)})");
});
});
@ -433,6 +482,7 @@ namespace MergeHub.ViewModels
try
{
_workspace.Git.ResolveConflict(conflict.Path, resolution);
if (ToResolutionKind(resolution) is { } kind) _workspace.Git.RecordResolution(conflict.Path, kind);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{

View File

@ -834,6 +834,7 @@ namespace MergeHub.ViewModels
{
// 충돌 해결 경로 - 인덱스의 충돌까지 함께 해제된다.
_workspace.Git.ResolveConflictWithContent(path!, content, encoding, newLine);
_workspace.Git.RecordResolution(path!, MergeResolutionKind.Lines); // 병합 커밋 본문에 남긴다(8차 F-1)
}
else
{
@ -921,6 +922,7 @@ namespace MergeHub.ViewModels
if (isConflict)
{
_workspace.Git.ResolveConflictWithContent(path!, content, encoding, newLine);
_workspace.Git.RecordResolution(path!, MergeResolutionKind.Lines); // 병합 커밋 본문에 남긴다(8차 F-1)
}
else
{
@ -1017,6 +1019,7 @@ namespace MergeHub.ViewModels
var ok = await _workspace.RunAsync(keep ? "파일 남기기" : "파일 지우기", () =>
{
_workspace.Git.ResolveConflict(path!, resolution);
_workspace.Git.RecordResolution(path!, keep ? MergeResolutionKind.Keep : MergeResolutionKind.Delete); // 8차 F-1
remaining = MergeSavePlan.RemainingAfterSave(_workspace.Git.GetConflicts().Select(c => c.Path), path);
});