From 8dd9fa98a65077d8816dcaa89e9f2f2e5d185d4f Mon Sep 17 00:00:00 2001 From: ksj09406 Date: Mon, 14 Sep 2026 13:27:36 +0900 Subject: [PATCH] =?UTF-8?q?6=EC=B0=A8=20B-5:=20=EB=A8=B8=EC=A7=80=20?= =?UTF-8?q?=EC=A3=BC=EC=84=9D=EC=9D=80=20=EB=B9=84=EA=B5=90=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EA=B0=80=EB=A6=AC=EA=B3=A0=20=EC=A3=BC=EC=84=9D?= =?UTF-8?q?=EB=A7=8C=20=EB=8B=A4=EB=A5=B8=20=EC=B6=A9=EB=8F=8C=EC=9D=80=20?= =?UTF-8?q?=EC=96=91=EC=AA=BD=20=EC=A3=BC=EC=84=9D=EC=9D=84=20=EB=82=A8?= =?UTF-8?q?=EA=B2=A8=20=EC=9E=90=EB=8F=99=EC=9C=BC=EB=A1=9C=20=ED=91=BC?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 --- doc/history/2026-09-14-6차.md | 70 +++++++++ doc/history/changelog.md | 1 + doc/진행상황.md | 2 +- src/MergeHub/MergeHubSettings.cs | 8 + src/MergeHub/Services/CompareRules.cs | 36 ++++- src/MergeHub/Services/FolderComparer.cs | 72 +++++++++ src/MergeHub/Services/GitService.cs | 27 ++++ src/MergeHub/Services/IGitService.cs | 3 + src/MergeHub/Services/MergeNote.cs | 54 +++++++ src/MergeHub/Services/NoteConflict.cs | 166 +++++++++++++++++++++ src/MergeHub/Services/NoteRows.cs | 148 ++++++++++++++++++ src/MergeHub/ViewModels/FolderViewModel.cs | 1 + src/MergeHub/ViewModels/GitViewModel.cs | 17 ++- src/MergeHub/ViewModels/HomeViewModel.cs | 17 ++- 14 files changed, 615 insertions(+), 7 deletions(-) create mode 100644 src/MergeHub/Services/NoteConflict.cs create mode 100644 src/MergeHub/Services/NoteRows.cs diff --git a/doc/history/2026-09-14-6차.md b/doc/history/2026-09-14-6차.md index 1bc7294..e38a7f4 100644 --- a/doc/history/2026-09-14-6차.md +++ b/doc/history/2026-09-14-6차.md @@ -584,3 +584,73 @@ MERGED 는 git 이 충돌 표시를 넣어 둔 파일이라, 두 칸으로는 위쪽 LOCAL(Mine) 칸 제목은 MERGED 칸과 같은 값을 쓴다(Git 모드에서는 둘 다 브랜치 이름). mergetool 에서는 결과 파일 이름 `Weld.cs` 가 보인다. 손대지 않았다. 빌드 경고 0 · 오류 0. + +## B-5. 머지 주석은 소스에 넣고, 병합 뒤에는 그 줄을 무시한다 (2026-09-14, 완료) + +사용자가 정한 것: 머지 주석은 지금처럼 소스에 넣는다. 비교하고 병합할 때 그 줄을 무시한다. + +### 생성기에 넣은 경우 (`C1Repo`, D-1 에서도 쓴다) + +| 이름 | 파일 | 내 쪽 | 상대 쪽 | +|---|---|---|---| +| K | `Ui/Views/Unit45.cs` | 13줄 코드 위에 `// [김승준] 2026-09-10` 한 줄 | 같은 자리에 `// [박지훈] 2026-09-11` 한 줄 | +| L | `Ui/Dialogs/Unit46.cs` | 13줄 끝에 ` // [김승준] 2026-09-10` | 같은 줄 끝에 ` // [박지훈] 2026-09-11` | +| M | `Common/Util/Unit47.cs` | 9줄 `= 111` 로 고치고 그 위에 내 주석 | `= 222` 로 고치고 그 위에 상대 주석 | + +이제 병합하면 git 충돌은 23 → **26**개다. + +### 고치기 전에 다시 났다 + +| 확인 | 결과 | +|---|---| +| MergeHub 로 병합 | git 충돌 **26**개, K · L · M 모두 충돌 | +| 두 칸 비교: 줄 끝 주석 1 · 주석 줄 1 만 다른 두 파일 | **남은 차이 2줄** | +| 네 칸: K · L · M | 모두 충돌로 물음 | + +(파일을 바꾸고 5초 뒤에 읽은 값이 한 파일씩 밀려 있었다 - M 이라고 찍은 화면이 K 였다. 그래서 파일별 "예전 모습" 은 아래 "설정을 끄면" 에서 다시 읽은 값을 쓴다.) + +### 무엇이 원인이었나 + +- 비교 규칙(`CompareRules`)에 머지 주석을 가리는 규칙이 없었다. 줄 필터는 사용자가 정규식을 적어야 했고, 줄 끝에 붙은 주석은 줄을 통째로 걸러야 해서 코드 차이까지 가려졌다. +- git(libgit2)은 주석 줄도 글자로 본다. 양쪽이 같은 자리에 다른 주석을 달면 그 줄끼리 충돌한다. MergeHub 는 그대로 사람에게 물었다. + +### 고친 것 + +| 파일 | 무엇 | +|---|---| +| `Services/MergeNote.cs` | `TailStart` · `WithoutTail` · `TailOf` — 줄 끝 머지 주석 조각을 찾는다. 주석이 둘 이어 붙어도 첫 주석부터. 주석 기호 앞이 공백이 아니면 글 속 기호로 본다(`"http://..."`). XAML 은 닫는 기호까지 있어야 한다 | +| `Services/CompareRules.cs` | `IgnoreMergeNotes` · `NotePath`. 켜면 주석만 있는 줄은 걸러지고(줄 필터 켜짐과 따로), 비교 키는 **줄 끝 주석을 뗀 코드**. 주석 줄끼리는 같은 키라 이름 · 날짜가 달라도 짝지어지고, 빈 줄과는 짝지어지지 않는다. 상태바 "무시: 머지 주석" | +| `Services/NoteRows.cs` (새) | 정렬기가 **주석 줄과 코드 줄을 한 행에 짝지으면** 주석 행 · 코드 행으로 나누고, 떼어 낸 코드 조각은 칸이 안 겹치는 다음 행과 합친다. M 에서 BASE 코드 줄이 양쪽 주석 줄과 한 행이 되어 "BASE 와 다름" 이 하나 더 세지고, 코드 충돌이 BASE 없이 보이던 것을 막는다 | +| `Services/NoteConflict.cs` (새) | git 이 워킹트리에 남긴 충돌 표시를 읽어, **모든 덩어리가** 주석을 빼면 같은 코드일 때만 푼다. 양쪽 주석을 다 남긴다 - 줄 위 주석은 내 쪽 다음 상대 쪽(같은 줄은 한 번), 줄 끝 주석은 이어 붙인다. `|||||||` (diff3) 모양도 읽는다 | +| `Services/GitService.cs` · `IGitService.cs` | `ResolveNoteOnlyConflicts` — 양쪽 다 있는 글 파일 충돌에만. 쓰기는 직접 편집 해결과 같은 길(내 쪽 형식 따름, A-1) | +| `ViewModels/GitViewModel.cs` | [Merge] 뒤 충돌이 있으면 자동으로 풀고, 알림 · 로그에 몇 건 풀었는지와 남은 충돌 수 | +| `Services/FolderComparer.cs` | 켜면 주석 줄을 빼고 줄 끝 주석을 뗀 글이 같으면 **같음**. 뗀 것이 없거나 줄끝(`\r`)이 다르면 가리지 않는다 | +| `ViewModels/HomeViewModel.cs` · `FolderViewModel.cs` | 설정을 규칙에 넘기고 파일 경로로 주석 기호를 고른다. 두 칸 · 네 칸 정렬 뒤 `NoteRows` | +| `MergeHubSettings.cs` | [머지 주석] "비교·병합에서 머지 주석 무시 (사람이 '// [이름] 2026-01-01' 모양으로 쓴 주석도 무시됨)", 기본 켬 | + +검사: NoteTest 29 → **64**, RulesTest 21 → **36**, FolderTest 51 → **57**. 검사 모음 21개를 모두 새로 빌드해 **865건 통과**. + +### 확인한 것 (새로 만든 저장소) + +| 확인 | 결과 | +|---|---| +| MergeHub 로 병합 | git 충돌 **24**개. 알림 "충돌 24건 (머지 주석만 다른 2건은 자동으로 풀림)", 로그에 파일별 "머지 주석만 다른 충돌 1곳 자동 해결" | +| K 스테이징된 글 | 13줄 `// [김승준] 2026-09-10`, 14줄 `// [박지훈] 2026-09-11`, 15줄 코드. BOM · CRLF 342 · 홀로 LF 0 · 충돌 표시 0. 워킹트리 = 인덱스 | +| L 스테이징된 글 | 13줄 `Log("step 5"); // [김승준] 2026-09-10 // [박지훈] 2026-09-11`. BOM · CRLF 242 · 홀로 LF 0 | +| M (코드도 다름) | 충돌로 남음(`UU`). 네 칸: 주석 행은 따로 **무시 1**, 코드 행은 BASE `101` · 내 쪽 `111` · 상대 `222` 가 한 행. **남은 충돌 1 · 남은 차이 1** | +| 두 칸 비교: 줄 끝 주석 1 · 주석 줄 1 | **남은 차이 0줄** | + +### 설정을 끄면 (설정 파일을 백업해 끄고 확인한 뒤 원래 바이트로 되돌림 - 해시 같음) + +| 확인 | 결과 | +|---|---| +| 병합 | git 충돌 **26**개, K · L · M 모두 남음 | +| 네 칸 K · L · M | 남은 충돌 1 · 1 · **2** (M 은 주석 줄끼리도 충돌) | +| 두 칸 비교 | 남은 차이 **2줄** | + +빌드 경고 0 · 오류 0. + +### 남긴 것 + +- 자동 풀기는 Git 탭 [Merge] 에만 걸었다. pull · rebase · cherry-pick 이 낸 충돌에는 아직 걸지 않았다. +- MERGED 칸에서 반영하며 새로 넣은 주석 줄은, 다시 정렬하면(타이핑 멈춤 · 규칙 변경) "무시" 색이 된다. 차이 수에는 원래도 세지 않던 줄이다. diff --git a/doc/history/changelog.md b/doc/history/changelog.md index 917f879..ee8381e 100644 --- a/doc/history/changelog.md +++ b/doc/history/changelog.md @@ -8,6 +8,7 @@ | 날짜 | 항목 | 커밋 | |---|---|---| +| 2026-09-14 | **6차 B-5** — 머지 주석을 비교에서 무시(주석 줄은 거르고 줄 끝 주석은 떼고 견줌). 주석만 다른 git 충돌은 양쪽 주석을 남겨 자동으로 풂. 정렬이 주석 줄과 코드 줄을 짝지으면 떼어 놓음. 폴더 비교도. 설정 [머지 주석] 에서 끔 | 아래 | | 2026-09-14 | **6차 B-4 확인 중 발견 두 건** — "마지막 차이" 로 멀리 건너뛰면 표시층 보정이 화면 밖 행으로 재져 네 칸이 9줄 위로 밀리던 것을 고침. `git mergetool` 이 넘긴 세 벌을 두 칸이 아닌 네 칸으로 열고 결과를 MERGED 파일에 쓴다 | 아래 | | 2026-09-14 | **6차 B-4** — 3분할 MERGED 칸 줄 번호 자리가 20px 라 2949 가 "294" 로 잘리던 것을 고침. 네 칸 여백을 54 → 66px 로 맞춰 넓혀 다섯 자리까지 보인다 | 아래 | | 2026-09-14 | **6차 B-3** — 병합 커밋 기본 메시지를 `Merge branch '상대' into 내 브랜치` 로. 첫 파일 이름이 굳어 남던 것, 커밋에 `#Conflicts:` 줄 24개와 해시 첫 줄이 들어가던 것을 고침. 병합이 끝나면 스스로 채운 글만 지운다 | 아래 | diff --git a/doc/진행상황.md b/doc/진행상황.md index 6cf7f26..8992d68 100644 --- a/doc/진행상황.md +++ b/doc/진행상황.md @@ -108,7 +108,7 @@ | **완료** | B-3 커밋 메시지가 엉뚱하게 남는다 — 기본값 `Merge branch '상대' into 내 브랜치`, `#` 줄 뺌, 병합이 끝나면 칸을 비움 | | **완료** | B-4 MERGED 칸 줄 번호가 네 자리에서 잘린다 — 네 칸 여백을 66px 로, MERGED 번호 자리 20 → 36px(다섯 자리) | | **완료** | B-4 확인 중 발견 두 건 — 멀리 건너뛰면 네 칸이 위로 밀려 아래가 빔 · `git mergetool` 세 벌이 두 칸으로 열림 | -| 대기 | B-5 머지 주석은 소스에 넣고, 병합 뒤에는 그 줄을 무시한다 | +| **완료** | B-5 머지 주석은 소스에 넣고, 병합 뒤에는 그 줄을 무시한다 — 비교(두 칸 · 네 칸 · 폴더)에서 가리고, 주석만 다른 git 충돌은 양쪽 주석을 남겨 자동으로 푼다. 설정에서 끔 | | 대기 | B-6 충돌 탭에서 하나씩 누를 때마다 오래 기다린다 | | 대기 | B-7 상태바가 병합 중에 믿을 수 없는 값을 보인다 | | 대기 | B-8 병합 커밋 뒤 파일 비교 탭이 어정쩡하게 남는다 | diff --git a/src/MergeHub/MergeHubSettings.cs b/src/MergeHub/MergeHubSettings.cs index 4f790d7..f753854 100644 --- a/src/MergeHub/MergeHubSettings.cs +++ b/src/MergeHub/MergeHubSettings.cs @@ -83,6 +83,14 @@ namespace MergeHub [DisplayName("반영한 줄에 주석 남기기")] public bool InsertMergeComment { get; set; } = true; + /// + /// 비교하고 병합할 때 머지 주석을 무시할지(6차 B-5). 주석은 소스에 그대로 남는다 - 보는 방식만 바뀐다. + /// 켜면 비교 화면(두 칸 · 네 칸 · 폴더)에서 차이로 세지 않고, git 병합에서 주석만 다른 충돌은 자동으로 푼다. + /// + [Category("머지 주석")] + [DisplayName("비교·병합에서 머지 주석 무시 (사람이 '// [이름] 2026-01-01' 모양으로 쓴 주석도 무시됨)")] + public bool IgnoreMergeNotes { get; set; } = true; + [Category("머지 주석")] [DisplayName("작성자 (비우면 git config user.name)")] public string MergeCommentAuthor { get; set; } = string.Empty; diff --git a/src/MergeHub/Services/CompareRules.cs b/src/MergeHub/Services/CompareRules.cs index 1f421d8..aab3053 100644 --- a/src/MergeHub/Services/CompareRules.cs +++ b/src/MergeHub/Services/CompareRules.cs @@ -41,12 +41,30 @@ namespace MergeHub.Services /// 줄 필터를 켤지. 식은 그대로 두고 잠시 끄고 싶을 때가 있다. public bool LineFilterEnabled { get; set; } = true; + /// + /// 머지 주석(// [이름] 날짜)을 차이로 세지 않을지(6차 B-5). + /// + /// 주석은 소스에 남기되 비교에서는 무시한다. 줄 전체가 주석이면 줄 필터처럼 걸러지고, + /// 줄 끝에 붙은 주석은 그 조각만 떼고 견준다 - 코드가 같으면 같은 줄이다. + /// 보는 방식일 뿐 파일의 글은 건드리지 않는다. + /// + public bool IgnoreMergeNotes { get; set; } + + /// 머지 주석 기호를 고를 파일 경로. 확장자만 본다. + public string? NotePath { get; set; } + + /// + /// 주석만 있는 줄의 비교용 키. 줄마다 이름·날짜가 달라도 서로 같은 줄로 짝지어진다. + /// 빈 줄과 짝지어지지 않도록 소스에 나올 수 없는 글자로 만든다. + /// + public const string MergeNoteKey = "\u0000merge-note"; + private Regex[]? _compiled; private string? _compiledFrom; /// 어떤 규칙도 켜져 있지 않으면 비교용 변환을 건너뛸 수 있다. public bool IsPlain => - Whitespace == WhitespaceMode.Exact && !IgnoreCase; + Whitespace == WhitespaceMode.Exact && !IgnoreCase && !IgnoreMergeNotes; /// /// 정렬에서 "같은 줄인가"를 따질 때 쓰는 문자열. @@ -56,6 +74,14 @@ namespace MergeHub.Services { if (line.Length == 0) return line; + if (IgnoreMergeNotes) + { + if (MergeNote.LooksLikeNote(line, NotePath)) return MergeNoteKey; + + var cut = MergeNote.TailStart(line, NotePath); + if (cut > 0) line = line.Substring(0, cut); + } + var text = Whitespace switch { WhitespaceMode.IgnoreAll => StripSpaces(line), @@ -69,7 +95,12 @@ namespace MergeHub.Services /// 줄 필터에 걸리는 줄인지. 걸리면 차이로 세지 않는다. public bool IsFiltered(string? line) { - if (!LineFilterEnabled || line is null) return false; + if (line is null) return false; + + // 머지 주석만 있는 줄은 줄 필터 켜짐과 따로 본다 - 설정이 둘이다(6차 B-5). + if (IgnoreMergeNotes && MergeNote.LooksLikeNote(line, NotePath)) return true; + + if (!LineFilterEnabled) return false; foreach (var rule in Compiled()) { @@ -88,6 +119,7 @@ namespace MergeHub.Services else if (Whitespace == WhitespaceMode.IgnoreAll) parts.Add("공백 전부"); if (IgnoreCase) parts.Add("대소문자"); + if (IgnoreMergeNotes) parts.Add("머지 주석"); var rules = Compiled().Length; if (rules > 0) parts.Add($"줄필터 {rules}"); diff --git a/src/MergeHub/Services/FolderComparer.cs b/src/MergeHub/Services/FolderComparer.cs index b121d14..4bbb7fc 100644 --- a/src/MergeHub/Services/FolderComparer.cs +++ b/src/MergeHub/Services/FolderComparer.cs @@ -55,6 +55,12 @@ namespace MergeHub.Services /// 하위 폴더까지 훑을지. public bool IncludeSubfolders { get; set; } = true; + /// + /// 머지 주석(// [이름] 날짜)만 다른 파일을 같은 파일로 볼지(6차 B-5). + /// 주석 줄을 빼고 줄 끝 주석을 뗀 글이 같으면 "같음" 이다. 줄끝 · 인코딩 차이는 가리지 않는다. + /// + public bool IgnoreMergeNotes { get; set; } + /// /// 수정 시각을 같다고 볼 오차. 파일 시스템마다 정밀도가 달라(FAT 는 2초) 정확히 같기를 /// 기대하면 안 된다. @@ -393,6 +399,9 @@ namespace MergeHub.Services { if (TextFile.IsBinary(leftPath) || TextFile.IsBinary(rightPath)) return FolderEntryStatus.Binary; + if (options.IgnoreMergeNotes && OnlyMergeNotesDiffer(leftPath, rightPath, size, options)) + return FolderEntryStatus.Same; + return OnlyCommentsDiffer(leftPath, rightPath, size, options) ? FolderEntryStatus.CommentOnly : FolderEntryStatus.Different; @@ -431,6 +440,69 @@ namespace MergeHub.Services return string.Equals(left, right, StringComparison.Ordinal); } + /// + /// 머지 주석만 다른가(6차 B-5). 주석만 있는 줄은 빼고, 줄 끝 주석은 떼고 견준다. + /// + /// 어느 쪽에서도 주석을 뗀 것이 없으면 볼 것도 없다 - 바이트가 다른 까닭이 주석이 아니다. + /// 줄끝(\r)은 그대로 남겨 견준다. 주석을 핑계로 줄끝 바꿈까지 "같음" 이 되면 안 된다. + /// + private static bool OnlyMergeNotesDiffer(string leftPath, string rightPath, long size, + FolderCompareOptions options) + { + if (size > options.FullCompareLimitBytes) return false; + + var left = WithoutMergeNotes(leftPath, out var leftCut); + if (left is null) return false; + + var right = WithoutMergeNotes(rightPath, out var rightCut); + if (right is null) return false; + + return (leftCut || rightCut) && string.Equals(left, right, StringComparison.Ordinal); + } + + /// 머지 주석을 걷어낸 글. 읽지 못하면 null. 하나라도 걷어냈으면 이 참. + private static string? WithoutMergeNotes(string path, out bool cut) + { + cut = false; + + try + { + var text = TextFile.Read(path); + var builder = new System.Text.StringBuilder(text.Length); + + foreach (var raw in text.Split('\n')) + { + var hasCr = raw.EndsWith("\r", StringComparison.Ordinal); + var line = hasCr ? raw.Substring(0, raw.Length - 1) : raw; + + if (MergeNote.LooksLikeNote(line, path)) + { + cut = true; + continue; + } + + var at = MergeNote.TailStart(line, path); + if (at > 0) + { + line = line.Substring(0, at); + cut = true; + } + + builder.Append(line).Append(hasCr ? "\r\n" : "\n"); + } + + return builder.ToString(); + } + catch (IOException) + { + return null; + } + catch (UnauthorizedAccessException) + { + return null; + } + } + /// 주석과 공백을 걷어낸 글. 읽지 못하면 null. private static string? CodeOnly(string path, LanguageProfile profile) { diff --git a/src/MergeHub/Services/GitService.cs b/src/MergeHub/Services/GitService.cs index 5ae0b93..7eedff4 100644 --- a/src/MergeHub/Services/GitService.cs +++ b/src/MergeHub/Services/GitService.cs @@ -938,6 +938,33 @@ namespace MergeHub.Services Report($"충돌 해결(직접 편집, {written.Encoding} · {written.NewLine}): {normalized}"); } + /// + /// 머지 주석만 다른 충돌 파일을 풀고 스테이징한다(6차 B-5). 푼 파일 경로를 돌려준다. + /// + /// git 은 주석 줄도 글자로 보고 충돌을 낸다. 워킹트리에 남긴 충돌 표시를 읽어 모든 덩어리가 + /// 주석을 빼면 같은 코드일 때만 푼다(). 양쪽 주석을 다 남긴다. + /// 형식(인코딩 · 줄끝)은 직접 편집 해결과 같은 길로 내 쪽 파일을 따른다. + /// 한쪽이 지운 파일 · 이진 파일은 보지 않는다. + /// + public IReadOnlyList ResolveNoteOnlyConflicts() + { + var solved = new List(); + + foreach (var conflict in GetConflicts()) + { + if (conflict.IsBinary || !conflict.HasOurs || !conflict.HasTheirs) continue; + + var marked = ReadWorkingFileText(conflict.Path); + if (!NoteConflict.TryResolve(marked, conflict.Path, out var resolved, out var blocks)) continue; + + ResolveConflictWithContent(conflict.Path, resolved); + Report($"머지 주석만 다른 충돌 {blocks}곳 자동 해결: {conflict.Path}"); + solved.Add(conflict.Path); + } + + return solved; + } + public string CommitMerge(string? message = null) { var remaining = GetConflicts(); diff --git a/src/MergeHub/Services/IGitService.cs b/src/MergeHub/Services/IGitService.cs index 0a63220..f819fbf 100644 --- a/src/MergeHub/Services/IGitService.cs +++ b/src/MergeHub/Services/IGitService.cs @@ -105,6 +105,9 @@ namespace MergeHub.Services void ResolveConflictWithContent(string path, string content, string? encodingName = null, string? newLineName = null); + /// 머지 주석만 다른 충돌 파일을 풀고 스테이징한다(6차 B-5). 푼 파일 경로를 돌려준다. + IReadOnlyList ResolveNoteOnlyConflicts(); + /// 충돌을 모두 해결한 뒤 병합 커밋 생성. 메시지 생략 시 기본 메시지 사용. string CommitMerge(string? message = null); diff --git a/src/MergeHub/Services/MergeNote.cs b/src/MergeHub/Services/MergeNote.cs index af2f91f..47574c0 100644 --- a/src/MergeHub/Services/MergeNote.cs +++ b/src/MergeHub/Services/MergeNote.cs @@ -81,6 +81,60 @@ namespace MergeHub.Services return NoteBody.IsMatch(body); } + /// + /// 줄 끝에 붙은 머지 주석 조각이 시작하는 자리(앞 공백 포함). 없으면 -1(6차 B-5). + /// + /// laser.Power = 150; // [김승준] 2026-09-10 설명 에서 // [김승준] ... 가 시작하는 자리다. + /// 주석이 두 개 이어 붙어 있어도(병합에서 양쪽 주석을 다 남긴 줄) 첫 번째부터 끝까지를 조각으로 본다. + /// 줄 전체가 주석이면 0 이다. 주석 기호 앞이 공백이나 줄 머리가 아니면 글 속 기호로 보고 넘어간다 + /// ("http://..." 의 //). + /// + public static int TailStart(string? line, string? path) + { + if (string.IsNullOrEmpty(line)) return -1; + + var (open, close) = CommentTokensFor(path); + var text = line!; + var from = 0; + + while (from < text.Length) + { + var at = text.IndexOf(open, from, StringComparison.Ordinal); + if (at < 0) return -1; + + if (at == 0 || char.IsWhiteSpace(text[at - 1])) + { + var body = text.Substring(at + open.Length).TrimStart(); + var closes = close is null || text.TrimEnd().EndsWith(close, StringComparison.Ordinal); + + if (closes && NoteBody.IsMatch(body)) + { + var start = at; + while (start > 0 && char.IsWhiteSpace(text[start - 1])) start--; + return start; + } + } + + from = at + open.Length; + } + + return -1; + } + + /// 줄 끝 머지 주석을 뗀 코드. 주석이 없으면 그대로(6차 B-5). + public static string WithoutTail(string line, string? path) + { + var at = TailStart(line, path); + return at < 0 ? line : line.Substring(0, at); + } + + /// 줄 끝 머지 주석 조각(앞뒤 공백 없이). 없으면 빈 글(6차 B-5). + public static string TailOf(string line, string? path) + { + var at = TailStart(line, path); + return at < 0 ? string.Empty : line.Substring(at).Trim(); + } + /// 코드 줄 끝에 주석을 붙인다. 줄 끝 공백은 정리한다. public static string Append(string line, string note) { diff --git a/src/MergeHub/Services/NoteConflict.cs b/src/MergeHub/Services/NoteConflict.cs new file mode 100644 index 0000000..16d71b8 --- /dev/null +++ b/src/MergeHub/Services/NoteConflict.cs @@ -0,0 +1,166 @@ +using System; +using System.Collections.Generic; + +namespace MergeHub.Services +{ + /// + /// git 이 낸 충돌 가운데 머지 주석만 다른 것을 푼다(6차 B-5). + /// + /// 머지 주석(// [이름] 날짜)은 소스에 남긴다 - 누가 언제 반영했는지가 이력이다. + /// 그런데 git 은 이 규칙을 모른다. 양쪽이 같은 코드 줄 위에 서로 다른 주석을 달면 그 주석 줄끼리 충돌한다. + /// 사람이 볼 것은 코드인데 주석 때문에 파일마다 물어보게 된다. + /// + /// 그래서 git 이 워킹트리에 남긴 충돌 표시(<<<<<<< · ======= · >>>>>>>)를 읽어 + /// 모든 덩어리가 "주석을 빼면 양쪽 코드가 같다" 일 때만 푼다. 한 덩어리라도 코드가 다르면 손대지 않는다 - + /// 그 파일은 사람이 네 칸에서 코드 충돌만 보고 정한다(주석 차이는 비교 규칙이 가린다). + /// + /// 풀 때는 양쪽 주석을 모두 남긴다. 이력은 지우지 않는다. + /// · 코드 줄 위의 주석 줄: 내 쪽 줄 다음에 상대 쪽 줄. 똑같은 줄은 한 번만. + /// · 줄 끝 주석: 내 쪽 줄 끝에 상대 주석을 이어 붙인다. 이미 들어 있으면 그대로. + /// 결과 줄끝은 \n 이다. 파일에 쓰는 쪽()이 원래 형식으로 되돌린다. + /// + public static class NoteConflict + { + /// 코드와 줄 끝 주석 사이 간격. 와 같다. + private const string Gap = " "; + + /// + /// 충돌 표시가 든 글을 푼다. 충돌 덩어리가 없거나, 코드가 다른 덩어리가 하나라도 있으면 false. + /// + /// 푼 충돌 덩어리 수. + public static bool TryResolve(string? markedText, string? path, out string resolved, out int blocks) + { + resolved = markedText ?? string.Empty; + blocks = 0; + if (string.IsNullOrEmpty(markedText)) return false; + + var text = markedText!.Replace("\r\n", "\n"); + var endsWithNewLine = text.EndsWith("\n", StringComparison.Ordinal); + var lines = text.Split('\n'); + var count = endsWithNewLine ? lines.Length - 1 : lines.Length; + + var output = new List(count); + var i = 0; + + while (i < count) + { + if (!IsMarker(lines[i], "<<<<<<<")) + { + output.Add(lines[i]); + i++; + continue; + } + + var ours = new List(); + var theirs = new List(); + var part = Part.Ours; + var closed = false; + + for (i++; i < count; i++) + { + var line = lines[i]; + + if (part == Part.Ours && IsMarker(line, "|||||||")) { part = Part.Base; continue; } + if (part != Part.Theirs && IsMarker(line, "=======")) { part = Part.Theirs; continue; } + if (part == Part.Theirs && IsMarker(line, ">>>>>>>")) { closed = true; i++; break; } + + if (part == Part.Ours) ours.Add(line); + else if (part == Part.Theirs) theirs.Add(line); + } + + if (!closed) return false; + + var merged = MergeBlock(ours, theirs, path); + if (merged is null) return false; + + output.AddRange(merged); + blocks++; + } + + if (blocks == 0) return false; + + resolved = string.Join("\n", output) + (endsWithNewLine ? "\n" : string.Empty); + return true; + } + + /// + /// 충돌 한 덩어리(내 쪽 줄들 · 상대 쪽 줄들)를 푼다. 주석을 빼도 코드가 다르면 null. + /// + public static List? MergeBlock(IReadOnlyList ours, IReadOnlyList theirs, string? path) + { + var oursCode = CodeRows(ours, path); + var theirsCode = CodeRows(theirs, path); + if (oursCode.Count != theirsCode.Count) return null; + + for (var k = 0; k < oursCode.Count; k++) + { + if (!SameCode(ours[oursCode[k]], theirs[theirsCode[k]], path)) return null; + } + + var result = new List(ours.Count + theirs.Count); + int o = 0, t = 0; + + for (var k = 0; k <= oursCode.Count; k++) + { + var oursEnd = k < oursCode.Count ? oursCode[k] : ours.Count; + var theirsEnd = k < theirsCode.Count ? theirsCode[k] : theirs.Count; + + // 이 코드 줄 앞의 주석 줄. 내 쪽 먼저, 상대 쪽은 내 쪽에 똑같은 줄이 없을 때만. + var mine = new HashSet(StringComparer.Ordinal); + for (; o < oursEnd; o++) + { + result.Add(ours[o]); + mine.Add(ours[o].Trim()); + } + + for (; t < theirsEnd; t++) + { + if (!mine.Contains(theirs[t].Trim())) result.Add(theirs[t]); + } + + if (k == oursCode.Count) break; + + result.Add(JoinTails(ours[o], theirs[t], path)); + o++; + t++; + } + + return result; + } + + private enum Part { Ours, Base, Theirs } + + /// 충돌 표시 줄인지. 표시 뒤에는 공백과 이름이 오거나 아무것도 없다. + private static bool IsMarker(string line, string marker) => + line.StartsWith(marker, StringComparison.Ordinal) && + (line.Length == marker.Length || line[marker.Length] == ' '); + + /// 머지 주석만 있는 줄이 아닌 줄(코드 줄 · 빈 줄)의 자리. + private static List CodeRows(IReadOnlyList lines, string? path) + { + var rows = new List(lines.Count); + for (var i = 0; i < lines.Count; i++) + { + if (!MergeNote.LooksLikeNote(lines[i], path)) rows.Add(i); + } + + return rows; + } + + private static bool SameCode(string a, string b, string? path) => + string.Equals(MergeNote.WithoutTail(a, path).TrimEnd(), MergeNote.WithoutTail(b, path).TrimEnd(), + StringComparison.Ordinal); + + /// 코드가 같은 두 줄의 줄 끝 주석을 합친다. + private static string JoinTails(string mine, string theirs, string? path) + { + var mineTail = MergeNote.TailOf(mine, path); + var theirsTail = MergeNote.TailOf(theirs, path); + + if (theirsTail.Length == 0 || mineTail.IndexOf(theirsTail, StringComparison.Ordinal) >= 0) return mine; + if (mineTail.Length == 0) return theirs; + + return mine.TrimEnd() + Gap + theirsTail; + } + } +} diff --git a/src/MergeHub/Services/NoteRows.cs b/src/MergeHub/Services/NoteRows.cs new file mode 100644 index 0000000..0b1c46f --- /dev/null +++ b/src/MergeHub/Services/NoteRows.cs @@ -0,0 +1,148 @@ +using System; +using System.Collections.Generic; + +namespace MergeHub.Services +{ + /// + /// 정렬이 머지 주석 줄과 코드 줄을 한 행에 짝지은 것을 떼어 놓는다(6차 B-5). + /// + /// 머지 주석을 무시하면 주석만 있는 줄은 모두 같은 비교 키()가 된다. + /// 그런데 정렬기는 같은 자리에서 서로 다른 줄을 "고친 줄" 로 짝짓는다. 내 쪽과 상대 쪽이 코드 줄 위에 + /// 주석을 넣고 코드도 고치면, BASE 의 코드 줄이 양쪽 주석 줄과 한 행이 되고 + /// 양쪽의 새 코드 줄은 BASE 없이 다음 행으로 밀린다. 그러면 주석 행이 "BASE 와 다름" 으로 세지고 + /// 코드 충돌은 BASE 가 빠진 채로 보인다(C-1 저장소 Common/Util/Unit47.cs 에서 "남은 차이 2줄"). + /// + /// 그래서 한 행에 주석 줄과 코드 줄이 섞여 있으면 주석 행과 코드 행 둘로 나눈다. 떼어 낸 코드 조각은 + /// 바로 다음 행과 칸이 겹치지 않으면 그 행과 합친다 - 정렬기가 주석에 붙이느라 떼어 놓은 짝이다. + /// 칸이 겹치면 합치지 않는다(정렬기가 일부러 나눠 둔 것이다). + /// + public static class NoteRows + { + private enum Kind { None, Note, Code } + + public static IReadOnlyList SplitFour(IReadOnlyList rows, Func isNote) + { + var output = new List(rows.Count); + + for (var i = 0; i < rows.Count; i++) + { + var row = rows[i]; + var b = KindOf(row.BaseText, isNote); + var m = KindOf(row.MineText, isNote); + var r = KindOf(row.RemoteText, isNote); + var g = KindOf(row.MergedText, isNote); + + if (!Mixed(b, m, r, g)) + { + output.Add(row); + continue; + } + + output.Add(new FourWayRow( + b == Kind.Note ? row.BaseText : null, b == Kind.Note ? row.BaseNumber : null, + m == Kind.Note ? row.MineText : null, m == Kind.Note ? row.MineNumber : null, + r == Kind.Note ? row.RemoteText : null, r == Kind.Note ? row.RemoteNumber : null, + g == Kind.Note ? row.MergedText : null, g == Kind.Note ? row.MergedNumber : null)); + + var code = new FourWayRow( + b == Kind.Code ? row.BaseText : null, b == Kind.Code ? row.BaseNumber : null, + m == Kind.Code ? row.MineText : null, m == Kind.Code ? row.MineNumber : null, + r == Kind.Code ? row.RemoteText : null, r == Kind.Code ? row.RemoteNumber : null, + g == Kind.Code ? row.MergedText : null, g == Kind.Code ? row.MergedNumber : null); + + if (i + 1 < rows.Count && CanJoin(code, rows[i + 1], isNote)) + { + var next = rows[i + 1]; + output.Add(new FourWayRow( + code.BaseText ?? next.BaseText, code.BaseText is not null ? code.BaseNumber : next.BaseNumber, + code.MineText ?? next.MineText, code.MineText is not null ? code.MineNumber : next.MineNumber, + code.RemoteText ?? next.RemoteText, code.RemoteText is not null ? code.RemoteNumber : next.RemoteNumber, + code.MergedText ?? next.MergedText, code.MergedText is not null ? code.MergedNumber : next.MergedNumber)); + i++; + } + else + { + output.Add(code); + } + } + + return output; + } + + public static IReadOnlyList SplitTwo(IReadOnlyList rows, Func isNote) + { + var output = new List(rows.Count); + + for (var i = 0; i < rows.Count; i++) + { + var row = rows[i]; + var b = KindOf(row.BaseText, isNote); + var l = KindOf(row.LocalText, isNote); + + if (!Mixed(b, l)) + { + output.Add(row); + continue; + } + + // 두 칸이면 섞인 행은 한쪽이 주석 · 한쪽이 코드뿐이다. + var note = b == Kind.Note + ? new AlignedRow(row.BaseText, row.BaseNumber, null, null) + : new AlignedRow(null, null, row.LocalText, row.LocalNumber); + var code = b == Kind.Code + ? new AlignedRow(row.BaseText, row.BaseNumber, null, null) + : new AlignedRow(null, null, row.LocalText, row.LocalNumber); + + output.Add(note); + + var next = i + 1 < rows.Count ? rows[i + 1] : null; + if (next is not null && KindOf(next.BaseText, isNote) != Kind.Note && KindOf(next.LocalText, isNote) != Kind.Note && + (code.BaseText is null || next.BaseText is null) && (code.LocalText is null || next.LocalText is null)) + { + output.Add(new AlignedRow( + code.BaseText ?? next.BaseText, code.BaseText is not null ? code.BaseNumber : next.BaseNumber, + code.LocalText ?? next.LocalText, code.LocalText is not null ? code.LocalNumber : next.LocalNumber)); + i++; + } + else + { + output.Add(code); + } + } + + return output; + } + + private static Kind KindOf(string? text, Func isNote) => + text is null ? Kind.None : isNote(text) ? Kind.Note : Kind.Code; + + private static bool Mixed(params Kind[] kinds) + { + bool note = false, code = false; + foreach (var kind in kinds) + { + note |= kind == Kind.Note; + code |= kind == Kind.Code; + } + + return note && code; + } + + /// 떼어 낸 코드 조각과 다음 행이 칸을 나눠 가질 수 있는지. 다음 행에 주석 줄이 있으면 합치지 않는다. + private static bool CanJoin(FourWayRow code, FourWayRow next, Func isNote) + { + if (Mixed(KindOf(next.BaseText, isNote), KindOf(next.MineText, isNote), + KindOf(next.RemoteText, isNote), KindOf(next.MergedText, isNote))) return false; + + foreach (var text in new[] { next.BaseText, next.MineText, next.RemoteText, next.MergedText }) + { + if (text is not null && isNote(text)) return false; + } + + return (code.BaseText is null || next.BaseText is null) && + (code.MineText is null || next.MineText is null) && + (code.RemoteText is null || next.RemoteText is null) && + (code.MergedText is null || next.MergedText is null); + } + } +} diff --git a/src/MergeHub/ViewModels/FolderViewModel.cs b/src/MergeHub/ViewModels/FolderViewModel.cs index 7386ba1..a356658 100644 --- a/src/MergeHub/ViewModels/FolderViewModel.cs +++ b/src/MergeHub/ViewModels/FolderViewModel.cs @@ -333,6 +333,7 @@ namespace MergeHub.ViewModels IncludeSubfolders = IncludeSubfolders, IncludePattern = IncludePattern, ExcludePattern = ExcludePattern, + IgnoreMergeNotes = _workspace.Settings.IgnoreMergeNotes, }; var left = LeftFolder; diff --git a/src/MergeHub/ViewModels/GitViewModel.cs b/src/MergeHub/ViewModels/GitViewModel.cs index ea1e647..90c6fb7 100644 --- a/src/MergeHub/ViewModels/GitViewModel.cs +++ b/src/MergeHub/ViewModels/GitViewModel.cs @@ -230,14 +230,27 @@ namespace MergeHub.ViewModels var branch = SelectedBranch!.FriendlyName; var result = _workspace.Git.Merge(branch, commitOnSuccess: false); + // 머지 주석만 다른 충돌은 사람에게 묻지 않고 푼다. 양쪽 주석을 다 남긴다(6차 B-5). + IReadOnlyList noteOnly = result.HasConflicts && _workspace.Settings.IgnoreMergeNotes + ? _workspace.Git.ResolveNoteOnlyConflicts() + : Array.Empty(); + var remaining = noteOnly.Count > 0 ? _workspace.Git.GetConflicts().Count : result.Conflicts.Count; + _workspace.OnUi(() => { _workspace.AppendLog(result.Message); + if (noteOnly.Count > 0) + _workspace.AppendLog($"머지 주석만 다른 충돌 {noteOnly.Count}개 파일은 자동으로 풀었습니다. 남은 충돌 {remaining}건."); + if (string.IsNullOrWhiteSpace(CommitMessage)) CommitMessage = $"Merge branch '{branch}' into {_workspace.CurrentBranch}"; - if (result.HasConflicts) - MessageUtil.ShowWarningToast($"충돌 {result.Conflicts.Count}건 - 충돌 탭에서 파일을 두 번 누르면 파일 비교 탭에서 세 칸으로 풉니다."); + var autoNote = noteOnly.Count > 0 ? $" (머지 주석만 다른 {noteOnly.Count}건은 자동으로 풀림)" : string.Empty; + + if (result.HasConflicts && remaining > 0) + MessageUtil.ShowWarningToast($"충돌 {remaining}건{autoNote} - 충돌 탭에서 파일을 두 번 누르면 파일 비교 탭에서 세 칸으로 풉니다."); + else if (result.HasConflicts) + MessageUtil.ShowSuccessToast($"머지 주석만 다른 충돌 {noteOnly.Count}건을 자동으로 풀었습니다 - 병합 커밋을 할 수 있습니다."); else MessageUtil.ShowToast(result.Message); }); diff --git a/src/MergeHub/ViewModels/HomeViewModel.cs b/src/MergeHub/ViewModels/HomeViewModel.cs index 010eb19..6e3847d 100644 --- a/src/MergeHub/ViewModels/HomeViewModel.cs +++ b/src/MergeHub/ViewModels/HomeViewModel.cs @@ -1368,6 +1368,7 @@ namespace MergeHub.ViewModels string? mergedText = null) { Rules.LinePatterns = SplitLines(_workspace.Settings.LineFilters ?? string.Empty); + Rules.NotePath = CurrentFilePath; // 머지 주석 기호는 확장자로 고른다(6차 B-5) var baseLines = TrimTrailingBlank(SplitLines(baseText)); var mineLines = TrimTrailingBlank(SplitLines(mineText)); @@ -1386,6 +1387,9 @@ namespace MergeHub.ViewModels var aligned = ThreeWayAligner.AlignFour(baseLines, mineLines, remoteLines, mergedLines, baseKeys, mineKeys, remoteKeys, mergedKeys); + // 머지 주석 줄과 코드 줄이 한 행에 짝지어졌으면 떼어 놓는다(6차 B-5, NoteRows 설명 참고). + if (Rules.IgnoreMergeNotes) aligned = NoteRows.SplitFour(aligned, IsNoteLine); + var rows = new List(aligned.Count); foreach (var row in aligned) @@ -1423,7 +1427,8 @@ namespace MergeHub.ViewModels /// 네 쪽 모두(있는 쪽만) 줄 필터에 걸리면 차이로 세지 않는다. private bool IsFilteredFourWayRow(FourWayRow row) { - if (!Rules.LineFilterEnabled) return false; + // 머지 주석 무시는 줄 필터와 따로 켠다(6차 B-5). 둘 다 꺼져 있을 때만 바로 돌아간다. + if (!Rules.LineFilterEnabled && !Rules.IgnoreMergeNotes) return false; var any = false; @@ -1441,6 +1446,7 @@ namespace MergeHub.ViewModels { // 줄 필터는 설정 화면에서 언제든 바뀔 수 있다. 비교할 때마다 최신 값을 가져온다. Rules.LinePatterns = SplitLines(_workspace.Settings.LineFilters ?? string.Empty); + Rules.NotePath = CurrentFilePath; // 머지 주석 기호는 확장자로 고른다(6차 B-5) // 파일 끝의 빈 줄(마지막 엔터)은 내용 차이가 아니다 - 한쪽만 있어도 무시한다. var baseLines = TrimTrailingBlank(SplitLines(baseText)); @@ -1455,6 +1461,9 @@ namespace MergeHub.ViewModels var aligned = LineAligner.Align(baseLines, localLines, baseKeys, localKeys); + // 머지 주석 줄과 코드 줄이 한 행에 짝지어졌으면 떼어 놓는다(6차 B-5). + if (Rules.IgnoreMergeNotes) aligned = NoteRows.SplitTwo(aligned, IsNoteLine); + var rows = new List(aligned.Count); foreach (var row in aligned) { @@ -1517,6 +1526,9 @@ namespace MergeHub.ViewModels return Rules.IsFiltered(row.BaseText) && Rules.IsFiltered(row.LocalText); } + /// 지금 파일의 주석 기호로 본 머지 주석 줄인지. + private bool IsNoteLine(string line) => MergeNote.LooksLikeNote(line, Rules.NotePath); + private static string[] SplitLines(string text) => text.Length == 0 ? Array.Empty() : text.Replace("\r\n", "\n").Split('\n'); @@ -2672,6 +2684,7 @@ namespace MergeHub.ViewModels Rules.IgnoreCase = settings.IgnoreCase; Rules.LineFilterEnabled = settings.UseLineFilters; + Rules.IgnoreMergeNotes = settings.IgnoreMergeNotes; Rules.LinePatterns = SplitLines(settings.LineFilters ?? string.Empty); _appliedSettingsSignature = SettingsSignature(); @@ -2684,7 +2697,7 @@ namespace MergeHub.ViewModels private string SettingsSignature() { var s = _workspace.Settings; - return $"{s.WhitespaceMode}|{s.IgnoreCase}|{s.UseLineFilters}|{s.LineFilters}"; + return $"{s.WhitespaceMode}|{s.IgnoreCase}|{s.UseLineFilters}|{s.LineFilters}|{s.IgnoreMergeNotes}"; } ///