diff --git a/doc/history/2026-09-14-6차.md b/doc/history/2026-09-14-6차.md index f5588ad..e08ad37 100644 --- a/doc/history/2026-09-14-6차.md +++ b/doc/history/2026-09-14-6차.md @@ -79,4 +79,70 @@ - **줄끝(CRLF → LF)은 그대로다.** 해결로 저장도 지금은 `ResolveConflictWithContent` 를 거치므로 A-1 이 남아 있다. 저장 길이 한 곳으로 모였으니 A-1 은 거기서 고친다. - 로그의 첫 번째 "건너뜀" 은 여전히 찍힌다. 기다렸다 다시 세우므로 화면에는 티가 안 난다. - 새로고침 도중에 목록을 다시 세우려는 것 자체는 B-1 에서 본다. \ No newline at end of file + 새로고침 도중에 목록을 다시 세우려는 것 자체는 B-1 에서 본다. + +--- + +## A-1. 3분할로 푼 파일을 저장하면 CRLF 가 LF 로 바뀐다 (2026-09-14, 완료) + +### 고치기 전에 다시 났다 + +새로 만든 저장소에서 A-2 의 [해결로 저장] 으로 두 파일을 풀었다. + +| 파일 | 원래 | 저장 뒤 | git 에 나온 변경 | +|---|---|---|---| +| `Io/Unit12.cs` (200줄 · UTF-8 BOM) | CRLF | **CR 0 / LF 200** | 399줄 (실제로 고친 것은 1줄 + 머지 주석) | +| `Common/Util/한글설명.cs` (11줄 · CP949) | CRLF | **CR 0 / LF 11** | 21줄 | + +### 무엇이 원인이었나 + +두 가지가 겹쳤다. + +1. 편집기가 주는 글(`BuildLocalText`)은 줄마다 `\n` 이다. `GitService.ResolveConflictWithContent` 는 + **인코딩만 맞추고 줄끝은 아예 보지 않고** 그 글을 그대로 썼다. 그래서 늘 LF 가 됐다. +2. 인코딩을 맞출 "원본" 으로 **워킹트리 파일**을 읽었다. 충돌 중의 워킹트리에는 git 이 LF 로 충돌 표시를 넣어 두므로 + 줄끝을 봤더라도 "혼합" 으로 읽혔을 것이다. 보통 저장(`TextFile.Write`)은 그 "혼합" 을 무조건 CRLF 로 썼다. + +### 고친 것 + +| 자리 | 무엇 | +|---|---| +| `TextFile.Encode` (새 함수) | 글을 저장할 바이트로 만드는 규칙을 한 곳으로 모았다. "원본 유지" 가 무엇을 따를지를 **원본 바이트로 받는다.** 인코딩은 원본에서 판별, 줄끝은 원본에서 **가장 많은 것**, 원본에 줄끝이 없으면 CRLF. BOM 은 맨 앞에 한 번만 붙이고 글 첫머리의 U+FEFF 는 뗀다 | +| `TextFile.Write` | 디스크의 파일을 원본으로 넘겨 `Encode` 를 쓴다. "혼합" 은 이제 많은 쪽을 따른다(예전에는 무조건 CRLF) | +| `GitService.ResolveConflictWithContent` | 원본을 워킹트리가 아니라 **내 쪽(ours) blob** 으로 잡는다. 내 쪽이 없으면 상대 쪽, 그것도 없으면 공통 조상. 고른 저장 형식을 받는다 | +| `HomeViewModel` 세 자리 | 해결로 저장 · Save & Commit · 확인 창의 "저장하고 계속" 이 모두 고른 저장 형식을 넘긴다 | + +로그 글도 따라 바뀌었다. `충돌 해결(직접 편집, ks_c_5601-1987)` 이 `충돌 해결(직접 편집, CP949 · CRLF)` 로 나온다 +(6차 C-1 의 "로그가 CP949 를 ks_c_5601-1987 로 적는다" 가 이 자리였다). + +### 확인한 것 (새로 만든 저장소, 3분할 + [해결로 저장]) + +| 파일 | 한 일 | 결과 | +|---|---|---| +| `Io/Unit12.cs` | Take Theirs 1 | CR 200 = LF 200 · BOM 한 번 · 내 쪽 대비 **+2 −1** (고른 줄 + 머지 주석) | +| `Motion/Unit0.cs` (3,363줄) | 충돌 6개 | CR 3,367 = LF 3,367 · 내 쪽 대비 **+8 −4** (5차 C-1 에서는 +3,367 −3,363) | +| `Common/Util/한글설명.cs` | Take Theirs 1 | CR 11 = LF 11 · CP949 그대로(UTF-8 로 안 읽힘) · '가접' 들어감 | +| `Io/LineEnding.cs` | 내 쪽 CRLF · 상대가 LF 로 바꾼 파일에서 Take Theirs | **내 쪽을 따라 CRLF** (CR 61 = LF 61) | +| `Io/Unit20.cs` | 저장 형식 줄바꿈을 **LF 로 고르고** 저장 | 고른 대로 LF (CR 0 · LF 182) | + +화면 확인 **10건 통과**. `EncTest` 에 저장 바이트 규칙 22건을 넣었다(16 → 38). + +### 검사 모음이 옛 실행 파일로 돌고 있었다 + +A-1 검사를 넣고 하네스를 돌리다 **세 벌이 빌드가 안 된다**는 것을 알았다. 검사 모음 스크립트가 빌드 출력을 버리고 +`bin` 에 남아 있던 **옛 실행 파일**을 그대로 돌려서, 그동안 통과로 세어지고 있었다. + +| 하네스 | 무엇이 깨졌나 | 언제부터 | +|---|---|---| +| `LangTest` (52건) | 토크나이저의 줄 넘김 상태를 `bool` 로 넘겼다. 5차 B-1 에서 `CarryOver` 로 바뀌었다. 고치자 **1건이 실제로 실패** - XAML 언어 이름을 옛 이름("XML / HTML")으로 기대하고 있었다 | 5차 B-1 (09-11) | +| `ReportTest` (31건) | 프로젝트에 `CarryOver.cs` · `MarkupTokenizer.cs` 가 빠져 있었다 | 5차 B-1 (09-11) | +| `TokenTest` (15건) | **`Program.cs` 가 없다.** 언제 왜 사라졌는지 모른다. 실행 파일은 09-06 것이었다 | 알 수 없음 | + +- 5차 B-1 · B-2 · C-1 과 6차 A-2 기록의 "전체 N건 통과" 에는 **이 세 벌의 옛 결과가 섞여 있다.** + 고치고 새로 빌드해 돌려 보니 LangTest 의 이름 기대값 1건 말고는 모두 통과했다 - 제품 코드가 틀린 것은 없었다. +- `LangTest` 는 부르는 모양과 이름 기대값만 새 형식으로 맞췄다. 검사 내용은 그대로다. +- `ReportTest` 는 빠진 파일 둘을 넣었다. +- `TokenTest` 는 되살릴 원본이 없어 **같은 범위(C# 줄 하나를 갈래로 쪼개기)로 새로 썼다**(22건). 예전 15건과 같은 검사는 아니다. +- 검사 모음은 이제 **모두 다시 빌드하고(`--no-incremental`), 이번에 새로 만든 실행 파일이 아니면 실패로 센다.** + +그렇게 돌린 결과 **21벌 745건 통과** · 빌드 경고 0 · 오류 0. diff --git a/doc/history/changelog.md b/doc/history/changelog.md index f852e9c..dd56581 100644 --- a/doc/history/changelog.md +++ b/doc/history/changelog.md @@ -8,6 +8,7 @@ | 날짜 | 항목 | 커밋 | |---|---|---| +| 2026-09-14 | **6차 A-1** — 충돌 해결 저장이 내 쪽(ours) 파일의 줄끝·인코딩을 따른다(3,363줄 파일 +3,367/−3,363 → +8/−4). 검사 모음이 옛 실행 파일로 돌던 것을 찾아 세 벌을 고침 | 아래 | | 2026-09-14 | **6차 A-2** — 병합 중 [해결로 저장] 을 따로 둠(쓰고 스테이징만). Save & Commit 은 남은 충돌이 있으면 쓰기 전에 막는다. 건너뛴 목록 불러오기가 목록을 비우던 것도 막음 | 아래 | | 2026-09-14 | **지시서 6차 B-5 정함** — 머지 주석은 소스에 넣고, 비교·병합에서는 그 줄을 무시한다. git 은 모르므로 주석 차이뿐인 충돌은 MergeHub 가 푼다 | 아래 | | 2026-09-14 | **지시서 6차** — 5차 C-1 에서 걸린 23가지를 A(망가뜨리는 것 5) · B(헷갈리는 것 8) · C(사소 1) · D(다시 끝까지)로 묶음 | 아래 | diff --git a/doc/진행상황.md b/doc/진행상황.md index 45d9c78..75a7849 100644 --- a/doc/진행상황.md +++ b/doc/진행상황.md @@ -93,7 +93,7 @@ | 상태 | 항목 | |---|---| -| 대기 | A-1 3분할로 푼 파일을 저장하면 CRLF 가 LF 로 바뀐다 | +| **완료** | A-1 3분할로 푼 파일을 저장하면 CRLF 가 LF 로 바뀐다 — 내 쪽(ours) 파일의 줄끝·인코딩을 따른다 | | **완료** | A-2 Save & Commit 이 "실패" 라는데 파일은 이미 써지고 스테이징됐다 — [해결로 저장] 을 따로 둠 | | 대기 | A-3 충돌 파일을 고르면 상대(Theirs) 칸이 안 나온다 | | 대기 | A-4 상대가 지운 파일을 3분할이 다룰 줄 모른다 | diff --git a/src/MergeHub/Services/GitService.cs b/src/MergeHub/Services/GitService.cs index b052484..605b7f8 100644 --- a/src/MergeHub/Services/GitService.cs +++ b/src/MergeHub/Services/GitService.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Linq; @@ -915,21 +915,32 @@ namespace MergeHub.Services Report($"충돌 해결({resolution}): {normalized}"); } - public void ResolveConflictWithContent(string path, string content) + public void ResolveConflictWithContent(string path, string content, + string? encodingName = null, string? newLineName = null) { var normalized = NormalizePath(path); var fullPath = ToFullPath(normalized); var directory = Path.GetDirectoryName(fullPath); if (!string.IsNullOrEmpty(directory)) Directory.CreateDirectory(directory); - // 편집 전 파일의 인코딩을 그대로 유지한다. (CP949 소스를 UTF-8 로 덮어쓰지 않도록) - var encoding = File.Exists(fullPath) - ? DetectEncoding(File.ReadAllBytes(fullPath)) - : new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); + // "원본" 은 워킹트리 파일이 아니라 내 쪽(ours) blob 이다(6차 A-1). + // 충돌 중 워킹트리에는 git 이 LF 로 충돌 표시를 넣어 두어서, 그 파일을 보면 줄끝이 "혼합" 으로 읽힌다. + // 예전에는 인코딩만 워킹트리에서 보고 줄끝은 아예 안 봐서 CRLF 파일이 통째로 LF 가 됐다. + // 내 쪽이 없으면(내가 지운 파일) 상대 쪽, 그것도 없으면 공통 조상을 본다. + var conflict = Repo.Index.Conflicts[normalized]; + var referenceBlob = conflict is null + ? null + : LookupBlob(conflict.Ours) ?? LookupBlob(conflict.Theirs) ?? LookupBlob(conflict.Ancestor); - File.WriteAllText(fullPath, content, encoding); + var reference = referenceBlob is not null + ? ReadBlobBytes(referenceBlob, normalized) + : File.Exists(fullPath) ? File.ReadAllBytes(fullPath) : null; + + File.WriteAllBytes(fullPath, TextFile.Encode(content, encodingName, newLineName, reference)); Commands.Stage(Repo, normalized); - Report($"충돌 해결(직접 편집, {encoding.WebName}): {normalized}"); + + var written = TextFile.Describe(fullPath); + Report($"충돌 해결(직접 편집, {written.Encoding} · {written.NewLine}): {normalized}"); } public string CommitMerge(string? message = null) diff --git a/src/MergeHub/Services/IGitService.cs b/src/MergeHub/Services/IGitService.cs index a8b1988..2706d11 100644 --- a/src/MergeHub/Services/IGitService.cs +++ b/src/MergeHub/Services/IGitService.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using MergeHub.Models; @@ -92,7 +92,12 @@ namespace MergeHub.Services TextFileInfo DescribeWorkingFile(string path); /// 편집기에서 직접 수정한 본문으로 해결. - void ResolveConflictWithContent(string path, string content); + /// + /// 충돌 파일을 이 글로 풀고 스테이징한다. 저장 형식이 "원본 유지"(또는 null)면 내 쪽(ours) 파일의 + /// 인코딩과 줄끝을 따른다(6차 A-1). + /// + void ResolveConflictWithContent(string path, string content, + string? encodingName = null, string? newLineName = null); /// 충돌을 모두 해결한 뒤 병합 커밋 생성. 메시지 생략 시 기본 메시지 사용. string CommitMerge(string? message = null); diff --git a/src/MergeHub/Services/TextFile.cs b/src/MergeHub/Services/TextFile.cs index f4225a6..e99c0fe 100644 --- a/src/MergeHub/Services/TextFile.cs +++ b/src/MergeHub/Services/TextFile.cs @@ -76,16 +76,44 @@ namespace MergeHub.Services /// public static void Write(string path, string content, string? encodingName, string? newLineName) { - var encoding = ResolveEncoding(path, encodingName); - var newLine = ResolveNewLine(path, newLineName); - - var text = content.Replace("\r\n", "\n"); - if (newLine != "LF") text = text.Replace("\n", "\r\n"); - - File.WriteAllText(path, text, encoding); + var reference = File.Exists(path) ? File.ReadAllBytes(path) : null; + File.WriteAllBytes(path, Encode(content, encodingName, newLineName, reference)); } - private static Encoding ResolveEncoding(string path, string? name) + /// + /// 글을 저장할 바이트로 만든다. "원본 유지" 가 무엇을 따를지를 로 받는다. + /// + /// 따로 뺀 까닭(6차 A-1) - 병합 충돌 중의 워킹트리 파일에는 git 이 LF 로 충돌 표시를 넣어 둔다. + /// 그 파일을 "원본" 으로 보면 줄끝이 "혼합" 이 되고, 충돌 해결 저장은 아예 줄끝을 안 봐서 전부 LF 로 썼다. + /// 3,363줄 CRLF 파일에서 12줄을 고쳤는데 git 에는 파일 전체가 바뀐 것으로 나왔다. + /// 원본이 무엇인지는 부르는 쪽이 제일 잘 안다 - 충돌이면 내 쪽(ours) blob, 보통이면 디스크의 파일. + /// + /// 규칙 + /// - 인코딩: 고른 값이 있으면 그것. "원본 유지" 면 원본에서 판별. 원본이 없으면 UTF-8(BOM 없음). + /// - 줄끝: 고른 값이 있으면 그것. "원본 유지" 면 원본에서 가장 많은 것. 원본에 줄끝이 없으면 CRLF. + /// - BOM: 인코딩이 BOM 을 가지면 맨 앞에 한 번만 붙인다. 글 첫머리의 U+FEFF 는 떼고 쓴다 + /// (blob 을 그대로 풀면 BOM 이 글자로 딸려 와 두 번 붙는다). + /// + public static byte[] Encode(string content, string? encodingName, string? newLineName, byte[]? reference) + { + var encoding = PickEncoding(encodingName, reference); + var newLine = PickNewLine(newLineName, reference); + + var text = (content ?? string.Empty).TrimStart('\uFEFF').Replace("\r\n", "\n"); + if (newLine == "CRLF") text = text.Replace("\n", "\r\n"); + else if (newLine == "CR") text = text.Replace("\n", "\r"); + + var body = encoding.GetBytes(text); + var preamble = encoding.GetPreamble(); + if (preamble.Length == 0) return body; + + var all = new byte[preamble.Length + body.Length]; + Buffer.BlockCopy(preamble, 0, all, 0, preamble.Length); + Buffer.BlockCopy(body, 0, all, preamble.Length, body.Length); + return all; + } + + private static Encoding PickEncoding(string? name, byte[]? reference) { switch (name) { @@ -94,8 +122,8 @@ namespace MergeHub.Services case "CP949": return GetCp949(); } - return File.Exists(path) - ? Detect(File.ReadAllBytes(path)) + return reference is { Length: > 0 } + ? Detect(reference) : new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); } @@ -111,10 +139,38 @@ namespace MergeHub.Services } } - private static string ResolveNewLine(string path, string? name) + private static string PickNewLine(string? name, byte[]? reference) { if (name == "CRLF" || name == "LF") return name; - return File.Exists(path) ? Describe(path).NewLine : "CRLF"; + if (reference is not { Length: > 0 }) return "CRLF"; + + return DominantNewLine(Detect(reference).GetString(reference)); + } + + /// + /// 가장 많이 쓰인 줄끝. 줄끝이 하나도 없으면 CRLF(윈도우 사내 소스의 기본). + /// 같으면 CRLF · LF · CR 차례로 고른다. + /// + /// "혼합" 을 그대로 두지 않는 까닭 - 저장은 한 가지로 해야 한다. 예전에는 혼합이면 무조건 CRLF 로 썼는데, + /// 거의 LF 인 파일에 CRLF 한 줄이 섞였을 때 파일 전체가 CRLF 로 바뀌었다. + /// + private static string DominantNewLine(string text) + { + int crlf = 0, lf = 0, cr = 0; + + for (var i = 0; i < text.Length; i++) + { + if (text[i] == '\r') + { + if (i + 1 < text.Length && text[i + 1] == '\n') { crlf++; i++; } + else cr++; + } + else if (text[i] == '\n') lf++; + } + + if (crlf == 0 && lf == 0 && cr == 0) return "CRLF"; + if (crlf >= lf && crlf >= cr) return "CRLF"; + return lf >= cr ? "LF" : "CR"; } /// diff --git a/src/MergeHub/ViewModels/HomeViewModel.cs b/src/MergeHub/ViewModels/HomeViewModel.cs index ac2fc51..bcd9643 100644 --- a/src/MergeHub/ViewModels/HomeViewModel.cs +++ b/src/MergeHub/ViewModels/HomeViewModel.cs @@ -613,7 +613,7 @@ namespace MergeHub.ViewModels if (isConflict) { // 충돌 해결 경로 - 인덱스의 충돌까지 함께 해제된다. - _workspace.Git.ResolveConflictWithContent(path!, content); + _workspace.Git.ResolveConflictWithContent(path!, content, encoding, newLine); } else { @@ -691,7 +691,7 @@ namespace MergeHub.ViewModels var isConflict = _workspace.Git.GetConflicts().Any(c => c.Path == path); if (isConflict) { - _workspace.Git.ResolveConflictWithContent(path!, content); + _workspace.Git.ResolveConflictWithContent(path!, content, encoding, newLine); } else { @@ -2200,7 +2200,7 @@ namespace MergeHub.ViewModels if (conflictPath is not null && _workspace.IsMergeInProgress && _workspace.Git.GetConflicts().Any(c => c.Path == conflictPath)) { - _workspace.Git.ResolveConflictWithContent(conflictPath, content); + _workspace.Git.ResolveConflictWithContent(conflictPath, content, SaveEncoding, SaveNewLine); } else {