using System; using System.Collections.Generic; using System.Linq; using System.Text; namespace MergeHub.Services { /// 병합 충돌을 어떻게 풀었는지(8차 F-1). public enum MergeResolutionKind { /// 네 칸(또는 두 칸)에서 줄을 골라 [Save] · Save & Commit. Lines, /// 충돌 탭 Accept Mine - 내 쪽 파일 그대로. Mine, /// 충돌 탭 Accept Theirs - 상대 쪽 파일 그대로. Theirs, /// 충돌 탭 Accept Both - 두 파일을 이어 붙임. Both, /// 한쪽이 지운 충돌에서 [파일 남기기]. Keep, /// 한쪽이 지운 충돌에서 [파일 지우기]. Delete, /// 머지 주석만 달라 MergeHub 가 저절로 풂(6차 B-5). NoteAuto } /// /// 병합 충돌을 누가 어떻게 풀었는지 적어 두었다가 병합 커밋 본문에 붙인다(8차 F-1, 사용자 요청). /// /// 따로 둔 까닭 - 충돌 탭의 파일 단위 Accept 는 고른 쪽 파일을 바이트째 쓰므로 소스에 머지 주석이 안 남는다. /// 로그는 화면에만 있고 플랫폼 로그에는 단추 이름뿐이라, "이 파일은 왜 상대 쪽으로 덮였나" 를 나중에 찾을 곳이 없었다. /// git 이력(병합 커밋 본문)에 남기면 소스를 더럽히지 않고 저장소와 함께 간다. /// /// 기록 파일(.git/MERGEHUB_RESOLUTIONS)은 첫 줄이 MERGE_HEAD, 그 아래 "종류 탭 경로" 한 줄씩이다. /// 첫 줄이 지금 병합과 다르면 지난 병합의 것이라 버린다 - 처음 충돌 목록(MERGEHUB_CONFLICTS)과 같은 규칙이다. /// public static class MergeResolutionLog { public const string FileName = "MERGEHUB_RESOLUTIONS"; /// 본문 블록의 첫 줄 머리. 메시지를 다시 만들 때 이 줄부터 끝까지를 갈아 끼운다. public const string Title = "충돌 해결"; /// 한 갈래에 파일 이름을 몇 개까지 적을지. 넘으면 "… 외 N개". public const int MaxPathsPerGroup = 30; private static readonly MergeResolutionKind[] GroupOrder = { MergeResolutionKind.Lines, MergeResolutionKind.Mine, MergeResolutionKind.Theirs, MergeResolutionKind.Both, MergeResolutionKind.Keep, MergeResolutionKind.Delete, MergeResolutionKind.NoteAuto }; /// 본문에 적는 갈래 이름. 화면의 단추 이름을 그대로 쓴다. 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() }; /// 기록 파일 글을 읽는다. 지금 병합(MERGE_HEAD)의 것이 아니면 빈 목록. 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; } /// 한 건을 더한 기록 파일 글을 돌려준다. 지난 병합의 기록이었으면 버리고 새로 시작한다. 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(); } /// /// 병합 커밋 본문 블록. 기록이 없으면 null. /// /// 충돌 해결 26개 · 김승준 /// Accept Theirs (상대 쪽 파일 그대로) 15 /// Common/Util/Unit15.cs /// /// 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(); } /// /// 커밋 메시지에 본문 블록을 붙인다. 이미 붙어 있던 블록( 로 시작하는 문단부터 끝까지)은 새것으로 갈아 끼운다 - /// 메시지 칸을 다시 채우거나 커밋 직전에 한 번 더 붙여도 두 번 들어가지 않는다. 본문이 없으면 옛 블록만 걷어낸다. /// 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('\\', '/'); } }