Ubisam-MergeTool/src/MergeHub/Services/MergeResolutionLog.cs
ksj09406 d25f374a0e 8차 F-1: 병합 커밋 본문에 충돌 해결 내역을 자동으로 적기
충돌 탭 파일 단위 Accept 는 소스에 주석이 안 남고, 병합 커밋 본문은 비어 있었고,
Git 탭 로그는 끄면 사라져 누가 어느 파일을 어떻게 풀었는지 남는 곳이 없었다(사용자 요청).

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 09:33:32 +09:00

159 lines
7.8 KiB
C#

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