충돌 탭 파일 단위 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>
159 lines
7.8 KiB
C#
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 & 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('\\', '/');
|
|
}
|
|
}
|