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}";
}
///