diff --git a/doc/진행상황.md b/doc/진행상황.md
index 42c8af0..59e92d5 100644
--- a/doc/진행상황.md
+++ b/doc/진행상황.md
@@ -131,7 +131,7 @@ CommunityToolkit 커맨드는 CommandManager 를 구독하지 않는데 이 둘
| 대기 | D-3 폴더 안 전체 검색 |
| ~~취소~~ | ~~D-4 비교 창 여러 개~~ (2026-09-09 사용자 판단으로 뺌) |
| **완료** | D-5 덩어리 단위 반영 (3-Way 에서 BASE 를 가져오던 것을 REMOTE 로) |
-| 대기 | D-6 비교 결과 텍스트 내보내기 |
+| **완료** | D-6 비교 결과 텍스트 내보내기 |
| 대기 | D-7 드래그 앤 드롭으로 열기 |
### E. 추가 요청 (2026-09-09 오후)
@@ -160,6 +160,45 @@ BASE 머리글에 그렇게 적었다 - `BASE (merge base) · 이 파일 없음
조상에 파일이 있는 경우는 지금까지처럼 파일 이름이 그대로 나온다.
---
+### D-6. 비교 결과 텍스트 내보내기 — **완료** (2026-09-09)
+
+**왜 필요했나** - 결과를 남에게 보내거나 결재에 붙일 때 화면을 캡처하는 것 말고는 방법이 없었다.
+캡처는 검색도 안 되고 줄 번호를 짚어 말하기도 어렵다.
+
+`Services/CompareReport.cs` 신설. 파일 비교와 폴더 비교 양쪽에서 쓴다.
+글을 만드는 일만 여기서 하고, 어디에 저장할지 묻는 것은 화면 쪽 일이다 - 그래야 화면 없이 검사한다.
+
+파일 비교 결과는 이렇게 나온다.
+
+```
+MergeHub 파일 비교 결과
+만든 때: 2026-09-09 14:46:23
+왼쪽 : ...\WeldController_기준.cs
+오른쪽: ...\WeldController_내작업.cs
+----------------------------------------------------------------
+요약: 총 43 줄 · 다름 5 · 오른쪽에만 5 · 왼쪽에만 0
+
+다른 줄
+----------------------------------------------------------------
+[8/8] 다름
+ - private const int PowerWatt = 1800;
+ + private const int PowerWatt = 2000;
+[-/28] 오른쪽에만
+ + {
+```
+
+**줄 번호를 양쪽 다 적는다.** 한쪽에만 있는 줄이 있어 번호가 어긋나므로, 하나만 적으면
+파일에서 그 줄을 찾을 수 없다. 없는 쪽은 `-` 로 둔다.
+
+폴더 비교 결과는 상태와 경로를 줄마다 적는다. 하위 폴더 파일도 상대 경로로 들어가고,
+폴더 자체는 줄로 적지 않는다 - 그 안의 파일이 이미 다 적혀 있다.
+
+**곁들여** 요약에 주석만 다른 수를 덧붙였다 - `다름 3(주석만 1)`.
+D-1 로 새로 생긴 갈래가 요약에는 안 보였다.
+
+**확인**: 두 화면에서 각각 뽑아 메모장으로 열었다. `ReportTest` 23건 신설. 전체 **451건**.
+---
+
### D-1. 주석만 다른 파일을 다른 색으로 — **완료** (2026-09-09)
폴더 비교 결과에 `주석만 다름` 상태를 새로 두었다. 코드는 같고 주석·공백만 다른 파일이다.
diff --git a/src/MergeHub/Services/CompareReport.cs b/src/MergeHub/Services/CompareReport.cs
new file mode 100644
index 0000000..a26f82b
--- /dev/null
+++ b/src/MergeHub/Services/CompareReport.cs
@@ -0,0 +1,147 @@
+using System;
+using System.Collections.Generic;
+using System.Text;
+using MergeHub.Models;
+
+namespace MergeHub.Services
+{
+ ///
+ /// 비교 결과를 메모장으로 열어 읽을 수 있는 글로 만든다.
+ ///
+ /// 왜 글로 뽑나 - 결과를 남에게 보내거나 결재에 붙일 때 화면을 캡처하는 것 말고는 방법이 없었다.
+ /// 캡처는 검색도 안 되고 줄 번호를 짚어 말하기도 어렵다.
+ ///
+ /// 화면 없이 검사할 수 있어야 하므로 글을 만드는 일만 여기서 한다.
+ /// 어디에 저장할지 묻는 것은 화면 쪽 일이다.
+ ///
+ public static class CompareReport
+ {
+ private const string Rule = "----------------------------------------------------------------";
+
+ /// 파일 비교 결과.
+ public static string ForFile(string leftLabel, string rightLabel,
+ IReadOnlyList lines, DateTime when)
+ {
+ var text = new StringBuilder();
+
+ Head(text, "파일 비교 결과", leftLabel, rightLabel, when);
+
+ var changed = 0;
+ var added = 0;
+ var removed = 0;
+
+ foreach (var line in lines)
+ {
+ if (line.IsMergeNote || line.IsIgnored) continue;
+
+ switch (line.State)
+ {
+ case MergeLineState.Changed: changed++; break;
+ case MergeLineState.LocalOnly: added++; break;
+ case MergeLineState.BaseOnly: removed++; break;
+ }
+ }
+
+ text.AppendLine($"요약: 총 {lines.Count} 줄 · 다름 {changed} · 오른쪽에만 {added} · 왼쪽에만 {removed}");
+ text.AppendLine();
+
+ if (changed + added + removed == 0)
+ {
+ text.AppendLine("다른 줄이 없습니다.");
+ return text.ToString();
+ }
+
+ text.AppendLine("다른 줄");
+ text.AppendLine(Rule);
+
+ foreach (var line in lines)
+ {
+ if (line.IsMergeNote || line.IsIgnored) continue;
+ if (line.State == MergeLineState.Same) continue;
+
+ // 줄 번호는 양쪽을 다 적는다. 한쪽에만 있는 줄이 있어 번호가 어긋나므로,
+ // 하나만 적으면 파일에서 그 줄을 찾을 수 없다.
+ var number = $"{Number(line.BaseNumber)}/{Number(line.LocalNumber)}";
+
+ text.AppendLine($"[{number}] {StateName(line.State)}");
+ if (line.HasBase) text.AppendLine(" - " + line.BaseText);
+ if (line.HasLocal) text.AppendLine(" + " + line.LocalText);
+ }
+
+ return text.ToString();
+ }
+
+ /// 폴더 비교 결과.
+ public static string ForFolder(string leftFolder, string rightFolder,
+ FolderCompareResult result, DateTime when)
+ {
+ var text = new StringBuilder();
+
+ Head(text, "폴더 비교 결과", leftFolder, rightFolder, when);
+ text.AppendLine("요약: " + result.Summary);
+ if (result.Truncated) text.AppendLine("(항목이 너무 많아 도중에 멈춘 결과입니다)");
+ text.AppendLine();
+
+ var rows = new List<(string Status, string Path)>();
+ Collect(result.Items, rows);
+
+ if (rows.Count == 0)
+ {
+ text.AppendLine("다른 항목이 없습니다.");
+ return text.ToString();
+ }
+
+ text.AppendLine("다른 항목");
+ text.AppendLine(Rule);
+
+ foreach (var row in rows) text.AppendLine($"{row.Status,-12}{row.Path}");
+
+ return text.ToString();
+ }
+
+ /// 폴더 트리를 훑어 "같음"이 아닌 파일만 모은다.
+ private static void Collect(IEnumerable items, List<(string, string)> rows)
+ {
+ foreach (var item in items)
+ {
+ if (item.IsDirectory)
+ {
+ Collect(item.Children, rows);
+ continue;
+ }
+
+ if (item.Status == FolderEntryStatus.Same) continue;
+ rows.Add((StatusName(item.Status), item.RelativePath));
+ }
+ }
+
+ private static void Head(StringBuilder text, string title, string left, string right, DateTime when)
+ {
+ text.AppendLine($"MergeHub {title}");
+ text.AppendLine($"만든 때: {when:yyyy-MM-dd HH:mm:ss}");
+ text.AppendLine($"왼쪽 : {left}");
+ text.AppendLine($"오른쪽: {right}");
+ text.AppendLine(Rule);
+ }
+
+ private static string Number(int? value) => value is int n ? n.ToString() : "-";
+
+ private static string StateName(MergeLineState state) => state switch
+ {
+ MergeLineState.Changed => "다름",
+ MergeLineState.LocalOnly => "오른쪽에만",
+ MergeLineState.BaseOnly => "왼쪽에만",
+ _ => "같음"
+ };
+
+ private static string StatusName(FolderEntryStatus status) => status switch
+ {
+ FolderEntryStatus.Different => "다름",
+ FolderEntryStatus.CommentOnly => "주석만 다름",
+ FolderEntryStatus.Binary => "다름(이진)",
+ FolderEntryStatus.LeftOnly => "왼쪽에만",
+ FolderEntryStatus.RightOnly => "오른쪽에만",
+ _ => "같음"
+ };
+ }
+}
\ No newline at end of file
diff --git a/src/MergeHub/Services/FolderComparer.cs b/src/MergeHub/Services/FolderComparer.cs
index 7651f77..7b5a0af 100644
--- a/src/MergeHub/Services/FolderComparer.cs
+++ b/src/MergeHub/Services/FolderComparer.cs
@@ -101,11 +101,16 @@ namespace MergeHub.Services
public int LeftOnlyCount { get; set; }
public int RightOnlyCount { get; set; }
+ /// 그중 코드는 같고 주석만 다른 것. "다름" 안에 든 수다.
+ public int CommentOnlyCount { get; set; }
+
/// 항목 상한에 걸려 도중에 멈췄는지.
public bool Truncated { get; set; }
public string Summary =>
- $"전체 {TotalFiles}개 · 다름 {DifferentCount} · 왼쪽만 {LeftOnlyCount} · 오른쪽만 {RightOnlyCount} · 같음 {SameCount}"
+ $"전체 {TotalFiles}개 · 다름 {DifferentCount}"
+ + (CommentOnlyCount > 0 ? $"(주석만 {CommentOnlyCount})" : string.Empty)
+ + $" · 왼쪽만 {LeftOnlyCount} · 오른쪽만 {RightOnlyCount} · 같음 {SameCount}"
+ (Truncated ? " (너무 많아 일부만 봄)" : string.Empty);
}
@@ -198,6 +203,11 @@ namespace MergeHub.Services
switch (item.Status)
{
case FolderEntryStatus.Same: result.SameCount++; break;
+ // "주석만 다름"도 다름의 한 갈래다. 둘 다 센다 - 요약이 "다름 3(주석만 1)" 로 읽힌다.
+ case FolderEntryStatus.CommentOnly:
+ result.DifferentCount++;
+ result.CommentOnlyCount++;
+ break;
case FolderEntryStatus.LeftOnly: result.LeftOnlyCount++; break;
case FolderEntryStatus.RightOnly: result.RightOnlyCount++; break;
default: result.DifferentCount++; break;
diff --git a/src/MergeHub/ViewModels/FolderViewModel.cs b/src/MergeHub/ViewModels/FolderViewModel.cs
index d1701c6..1175ffe 100644
--- a/src/MergeHub/ViewModels/FolderViewModel.cs
+++ b/src/MergeHub/ViewModels/FolderViewModel.cs
@@ -208,6 +208,7 @@ namespace MergeHub.ViewModels
CopyToLeftCommand.NotifyCanExecuteChanged();
DeleteLeftCommand.NotifyCanExecuteChanged();
DeleteRightCommand.NotifyCanExecuteChanged();
+ ExportReportCommand.NotifyCanExecuteChanged();
}
[RelayCommand]
@@ -222,6 +223,57 @@ namespace MergeHub.ViewModels
if (AskForFolder("오른쪽(비교 대상) 폴더 고르기", RightFolder) is { } picked) RightFolder = picked;
}
+ // ================= 결과 내보내기 =================
+
+ private bool CanExportReport() => _lastResult != null;
+
+ ///
+ /// 비교 결과를 글로 뽑아 파일에 쓴다.
+ ///
+ /// 화면을 캡처하는 것 말고는 결과를 남에게 보낼 방법이 없었다. 캡처는 검색도 안 되고
+ /// 줄 번호를 짚어 말하기도 어렵다. 글이면 메일에 붙이든 결재에 넣든 그대로 쓴다.
+ ///
+ [RelayCommand(CanExecute = nameof(CanExportReport))]
+ private void ExportReport()
+ {
+ if (_lastResult is not { } result) return;
+
+ var dialog = new Microsoft.Win32.SaveFileDialog
+ {
+ Title = "폴더 비교 결과 저장",
+ Filter = "텍스트 파일 (*.txt)|*.txt|모든 파일 (*.*)|*.*",
+ DefaultExt = ".txt",
+ FileName = $"폴더비교_{DateTime.Now:yyyyMMdd_HHmm}.txt"
+ };
+
+ var start = PathUtil.FullFolderPath(LeftFolder);
+ if (start.Length > 0) dialog.InitialDirectory = start;
+
+ bool ok;
+ try
+ {
+ ok = dialog.ShowDialog() == true;
+ }
+ catch (Exception)
+ {
+ dialog.InitialDirectory = string.Empty;
+ ok = dialog.ShowDialog() == true;
+ }
+
+ if (!ok) return;
+
+ try
+ {
+ var report = CompareReport.ForFolder(LeftFolder, RightFolder, result, DateTime.Now);
+ TextFile.Write(dialog.FileName, report);
+ MessageUtil.ShowSuccessToast($"저장했습니다 · {Path.GetFileName(dialog.FileName)}");
+ }
+ catch (Exception ex)
+ {
+ MessageUtil.ShowErrorToast($"저장하지 못했습니다: {ex.Message}");
+ }
+ }
+
private static string? AskForFolder(string title, string current)
=> FolderPicker.Pick(title, current);
private bool CanCompare() => !IsBusy;
diff --git a/src/MergeHub/ViewModels/HomeViewModel.cs b/src/MergeHub/ViewModels/HomeViewModel.cs
index 40f55f0..d4c394b 100644
--- a/src/MergeHub/ViewModels/HomeViewModel.cs
+++ b/src/MergeHub/ViewModels/HomeViewModel.cs
@@ -471,6 +471,7 @@ namespace MergeHub.ViewModels
ReloadCommand.NotifyCanExecuteChanged();
SaveAndCommitCommand.NotifyCanExecuteChanged();
SavePlainCommand.NotifyCanExecuteChanged();
+ ExportReportCommand.NotifyCanExecuteChanged();
AutoMergeCommand.NotifyCanExecuteChanged();
AcceptBlockCommand.NotifyCanExecuteChanged();
@@ -1443,6 +1444,64 @@ namespace MergeHub.ViewModels
}
}
+ // ================= 결과 내보내기 =================
+
+ private bool CanExportReport() => Lines.Count > 0;
+
+ ///
+ /// 비교 결과를 글로 뽑아 파일에 쓴다.
+ ///
+ /// 화면을 캡처하는 것 말고는 결과를 남에게 보낼 방법이 없었다. 캡처는 검색도 안 되고
+ /// 줄 번호를 짚어 말하기도 어렵다.
+ ///
+ [RelayCommand(CanExecute = nameof(CanExportReport))]
+ private void ExportReport()
+ {
+ var dialog = new Microsoft.Win32.SaveFileDialog
+ {
+ Title = "파일 비교 결과 저장",
+ Filter = "텍스트 파일 (*.txt)|*.txt|모든 파일 (*.*)|*.*",
+ DefaultExt = ".txt",
+ FileName = $"파일비교_{DateTime.Now:yyyyMMdd_HHmm}.txt"
+ };
+
+ var start = PathUtil.FolderOfFile(RightFile.Length > 0 ? RightFile : LeftFile);
+ if (start.Length > 0) dialog.InitialDirectory = start;
+
+ bool ok;
+ try
+ {
+ ok = dialog.ShowDialog() == true;
+ }
+ catch (Exception)
+ {
+ dialog.InitialDirectory = string.Empty;
+ ok = dialog.ShowDialog() == true;
+ }
+
+ if (!ok) return;
+
+ try
+ {
+ var report = CompareReport.ForFile(ReportLabel(BaseHeader, LeftFile),
+ ReportLabel(LocalHeader, RightFile),
+ Lines, DateTime.Now);
+ TextFile.Write(dialog.FileName, report);
+ MessageUtil.ShowSuccessToast($"저장했습니다 · {Path.GetFileName(dialog.FileName)}");
+ }
+ catch (Exception ex)
+ {
+ MessageUtil.ShowErrorToast($"저장하지 못했습니다: {ex.Message}");
+ }
+ }
+
+ ///
+ /// 보고서에 적을 쪽 이름. Git 모드면 머리글(브랜치·커밋)이 더 쓸모 있고,
+ /// 파일 비교 모드면 경로가 있어야 어느 파일인지 알 수 있다.
+ ///
+ private string ReportLabel(string header, string path) =>
+ IsGitMode || path.Length == 0 ? header : path;
+
private static string? AskForFile(string title, string current)
{
var dialog = new Microsoft.Win32.OpenFileDialog
diff --git a/src/MergeHub/Views/FolderView.xaml b/src/MergeHub/Views/FolderView.xaml
index 26d355e..b76ea51 100644
--- a/src/MergeHub/Views/FolderView.xaml
+++ b/src/MergeHub/Views/FolderView.xaml
@@ -255,6 +255,9 @@
+
+
+