diff --git a/doc/history/2026-09-16-8차.md b/doc/history/2026-09-16-8차.md index e9cfed8..49a3d1a 100644 --- a/doc/history/2026-09-16-8차.md +++ b/doc/history/2026-09-16-8차.md @@ -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 밖(터미널)에서 푼 충돌은 적히지 않는다. 본문 건수가 처음 충돌 수보다 적으면 그만큼은 밖에서 푼 것이다. diff --git a/doc/history/changelog.md b/doc/history/changelog.md index 7abe5c5..bb9aa93 100644 --- a/doc/history/changelog.md +++ b/doc/history/changelog.md @@ -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 는 둘 다 시험. 이력 목록에도 박지훈으로 보인다 | 아래 | diff --git a/doc/작업지시/8차.md b/doc/작업지시/8차.md index cd74505..9d58ead 100644 --- a/doc/작업지시/8차.md +++ b/doc/작업지시/8차.md @@ -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 결정). diff --git a/doc/진행상황.md b/doc/진행상황.md index 9f08128..fa4525b 100644 --- a/doc/진행상황.md +++ b/doc/진행상황.md @@ -229,6 +229,12 @@ |---|---| | 대기 | E-1 네 길(+ revert)을 사람 순서 그대로 다시 끝까지 — 파일 비교 탭을 미리 열지 않고 | +### F. 추가 요청 + +| 상태 | 항목 | +|---|---| +| **완료** | F-1 병합 커밋 메시지 본문에 충돌 해결 내역을 자동으로 적기 (2026-09-17 사용자 요청) — 병합 · pull 중 푼 방법(줄 단위 · Accept Mine/Theirs/Both · 남기기 · 지우기 · 머지 주석 자동)을 `.git` 에 적고 [병합 커밋] 본문에 "충돌 해결 N개 · 이름" + 파일 목록. 메시지 칸에 미리 보임, 커밋 · 취소 때 기록 치움. 스타일 맞춤 선택지는 만들지 않기로 | + --- ## 3~4주차로 미룬 것 diff --git a/src/MergeHub/Services/GitService.cs b/src/MergeHub/Services/GitService.cs index a7d2c1f..701993a 100644 --- a/src/MergeHub/Services/GitService.cs +++ b/src/MergeHub/Services/GitService.cs @@ -900,6 +900,9 @@ namespace MergeHub.Services /// 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) { } + } + + /// 병합 커밋 본문에 "누가" 로 적을 이름(8차 F-1). 작업 공간이 상태를 읽을 때 채운다. + public string? ResolverName { get; set; } + + /// + /// 병합 충돌 하나를 어떻게 풀었는지 적어 둔다(8차 F-1). 병합 중이 아니면(cherry-pick · revert · rebase) 적지 않는다. + /// 적는 데 실패해도 푸는 일은 막지 않는다 - 기록은 덤이다. + /// + 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) { } + } + + /// 지금 병합의 해결 내역 본문(8차 F-1). 적힌 것이 없으면 null. + 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; } diff --git a/src/MergeHub/Services/GitWorkspace.cs b/src/MergeHub/Services/GitWorkspace.cs index 027d8b2..0107a17 100644 --- a/src/MergeHub/Services/GitWorkspace.cs +++ b/src/MergeHub/Services/GitWorkspace.cs @@ -182,6 +182,9 @@ namespace MergeHub.Services /// 이번 병합에서 처음 난 충돌 파일 수(6차 C-1). 병합 중이 아니거나 모르면 null. [ObservableProperty] private int? _mergeConflictTotal; + /// 지금 병합에서 충돌을 어떻게 풀었는지 - 병합 커밋 본문에 붙는 글(8차 F-1). 병합 중이 아니거나 적힌 것이 없으면 null. + [ObservableProperty] private string? _mergeResolutionBody; + /// /// 충돌 탭 목록 위의 진행 한 줄(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); diff --git a/src/MergeHub/Services/IGitService.cs b/src/MergeHub/Services/IGitService.cs index eb14417..a4fe11f 100644 --- a/src/MergeHub/Services/IGitService.cs +++ b/src/MergeHub/Services/IGitService.cs @@ -104,6 +104,15 @@ namespace MergeHub.Services /// 인덱스가 HEAD 와 다른가 - 지금 커밋할 것이 있는가(8차 B-2). bool HasStagedChanges(); + /// 병합 커밋 본문에 "누가" 로 적을 이름(8차 F-1). + string? ResolverName { get; set; } + + /// 병합 충돌 하나를 어떻게 풀었는지 적어 둔다(8차 F-1). 병합 중이 아니면 적지 않는다. + void RecordResolution(string path, MergeResolutionKind kind); + + /// 지금 병합의 해결 내역 본문(8차 F-1). 없으면 null. + string? GetResolutionBody(); + /// 충돌을 다 푼 cherry-pick · revert 를 커밋해 끝낸다. 병합이면 와 같다(7차 C-2). string CommitPendingOperation(string? message = null); diff --git a/src/MergeHub/Services/MergeResolutionLog.cs b/src/MergeHub/Services/MergeResolutionLog.cs new file mode 100644 index 0000000..3a4879b --- /dev/null +++ b/src/MergeHub/Services/MergeResolutionLog.cs @@ -0,0 +1,158 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace MergeHub.Services +{ + /// 병합 충돌을 어떻게 풀었는지(8차 F-1). + public enum MergeResolutionKind + { + /// 네 칸(또는 두 칸)에서 줄을 골라 [Save] · Save & Commit. + Lines, + /// 충돌 탭 Accept Mine - 내 쪽 파일 그대로. + Mine, + /// 충돌 탭 Accept Theirs - 상대 쪽 파일 그대로. + Theirs, + /// 충돌 탭 Accept Both - 두 파일을 이어 붙임. + Both, + /// 한쪽이 지운 충돌에서 [파일 남기기]. + Keep, + /// 한쪽이 지운 충돌에서 [파일 지우기]. + Delete, + /// 머지 주석만 달라 MergeHub 가 저절로 풂(6차 B-5). + NoteAuto + } + + /// + /// 병합 충돌을 누가 어떻게 풀었는지 적어 두었다가 병합 커밋 본문에 붙인다(8차 F-1, 사용자 요청). + /// + /// 따로 둔 까닭 - 충돌 탭의 파일 단위 Accept 는 고른 쪽 파일을 바이트째 쓰므로 소스에 머지 주석이 안 남는다. + /// 로그는 화면에만 있고 플랫폼 로그에는 단추 이름뿐이라, "이 파일은 왜 상대 쪽으로 덮였나" 를 나중에 찾을 곳이 없었다. + /// git 이력(병합 커밋 본문)에 남기면 소스를 더럽히지 않고 저장소와 함께 간다. + /// + /// 기록 파일(.git/MERGEHUB_RESOLUTIONS)은 첫 줄이 MERGE_HEAD, 그 아래 "종류 탭 경로" 한 줄씩이다. + /// 첫 줄이 지금 병합과 다르면 지난 병합의 것이라 버린다 - 처음 충돌 목록(MERGEHUB_CONFLICTS)과 같은 규칙이다. + /// + public static class MergeResolutionLog + { + public const string FileName = "MERGEHUB_RESOLUTIONS"; + + /// 본문 블록의 첫 줄 머리. 메시지를 다시 만들 때 이 줄부터 끝까지를 갈아 끼운다. + public const string Title = "충돌 해결"; + + /// 한 갈래에 파일 이름을 몇 개까지 적을지. 넘으면 "… 외 N개". + public const int MaxPathsPerGroup = 30; + + private static readonly MergeResolutionKind[] GroupOrder = + { + MergeResolutionKind.Lines, MergeResolutionKind.Mine, MergeResolutionKind.Theirs, MergeResolutionKind.Both, + MergeResolutionKind.Keep, MergeResolutionKind.Delete, MergeResolutionKind.NoteAuto + }; + + /// 본문에 적는 갈래 이름. 화면의 단추 이름을 그대로 쓴다. + 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() + }; + + /// 기록 파일 글을 읽는다. 지금 병합(MERGE_HEAD)의 것이 아니면 빈 목록. + 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; + } + + /// 한 건을 더한 기록 파일 글을 돌려준다. 지난 병합의 기록이었으면 버리고 새로 시작한다. + 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(); + } + + /// + /// 병합 커밋 본문 블록. 기록이 없으면 null. + /// + /// 충돌 해결 26개 · 김승준 + /// Accept Theirs (상대 쪽 파일 그대로) 15 + /// Common/Util/Unit15.cs + /// + /// + 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(); + } + + /// + /// 커밋 메시지에 본문 블록을 붙인다. 이미 붙어 있던 블록( 로 시작하는 문단부터 끝까지)은 새것으로 갈아 끼운다 - + /// 메시지 칸을 다시 채우거나 커밋 직전에 한 번 더 붙여도 두 번 들어가지 않는다. 본문이 없으면 옛 블록만 걷어낸다. + /// + 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('\\', '/'); + } +} diff --git a/src/MergeHub/ViewModels/GitViewModel.cs b/src/MergeHub/ViewModels/GitViewModel.cs index 99a2b87..c858ca6 100644 --- a/src/MergeHub/ViewModels/GitViewModel.cs +++ b/src/MergeHub/ViewModels/GitViewModel.cs @@ -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 }); } + /// 메시지 칸에 우리가 채운 병합 메시지(본문 포함). 사람이 고치면 이것과 달라져 더는 건드리지 않는다. + private string _autoMergeMessage = string.Empty; + + /// + /// 충돌을 하나 풀 때마다 메시지 칸의 해결 내역을 새로 붙인다(8차 F-1). 사람이 칸을 고쳤으면 두고, + /// 커밋할 때 가 본문을 붙인다 - 어느 쪽이든 기록은 남는다. + /// + 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; + } + + /// 충돌 탭 Accept 가 무엇으로 푼 것인지. BASE 로 되돌리기는 이 단추에 없어 적지 않는다. + 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) { diff --git a/src/MergeHub/ViewModels/HomeViewModel.cs b/src/MergeHub/ViewModels/HomeViewModel.cs index a819f24..24b2c24 100644 --- a/src/MergeHub/ViewModels/HomeViewModel.cs +++ b/src/MergeHub/ViewModels/HomeViewModel.cs @@ -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); });