6차 A-1: 충돌 해결 저장이 내 쪽 파일의 줄끝과 인코딩을 따른다

3분할로 푼 파일을 저장하면 줄끝이 전부 LF 가 됐다. 3,363줄 CRLF
파일에서 충돌 6개를 풀었는데 git 에는 파일 전체가 바뀐 것으로 나왔다.

원인은 둘이었다.
- ResolveConflictWithContent 가 인코딩만 맞추고 줄끝을 안 봤다.
  편집기 글은 줄마다 \n 이라 늘 LF 로 써졌다.
- 원본으로 워킹트리 파일을 읽었다. 충돌 중에는 git 이 LF 로 충돌
  표시를 넣어 두어 원본이 아니다.

- TextFile.Encode: 저장 바이트 규칙을 한 곳으로. "원본 유지" 는
  넘겨받은 원본 바이트를 따르고, 줄끝은 가장 많은 것, BOM 은 한 번
- TextFile.Write: 디스크 파일을 원본으로 Encode 를 쓴다
- ResolveConflictWithContent: 원본을 ours blob 으로(없으면 theirs,
  base). 고른 저장 형식을 받는다
- HomeViewModel 세 자리가 저장 형식을 넘긴다

  Io/Unit12.cs      +399 줄 -> +2 -1, CRLF 유지
  Motion/Unit0.cs   +3367 -3363 -> +8 -4
  한글설명.cs        CP949 와 CRLF 유지
  LineEnding.cs     상대가 LF 로 바꿔도 내 쪽 CRLF
  줄바꿈 LF 를 고르면 LF

검사 모음이 빌드 실패를 삼키고 옛 실행 파일을 돌리던 것을 찾았다.
LangTest(5차 B-1 의 CarryOver 변경), ReportTest(파일 누락),
TokenTest(Program.cs 없음, 새로 씀)를 고쳤다. 이제 모두 다시 빌드한
새 실행 파일만 센다. 21벌 745건 통과. 경고 0, 오류 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
김승준 2026-09-14 10:37:05 +09:00
parent 0485c60745
commit 538efdcd50
7 changed files with 166 additions and 27 deletions

View File

@ -79,4 +79,70 @@
- **줄끝(CRLF → LF)은 그대로다.** 해결로 저장도 지금은 `ResolveConflictWithContent` 를 거치므로 A-1 이 남아 있다.
저장 길이 한 곳으로 모였으니 A-1 은 거기서 고친다.
- 로그의 첫 번째 "건너뜀" 은 여전히 찍힌다. 기다렸다 다시 세우므로 화면에는 티가 안 난다.
새로고침 도중에 목록을 다시 세우려는 것 자체는 B-1 에서 본다.
새로고침 도중에 목록을 다시 세우려는 것 자체는 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.

View File

@ -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(다시 끝까지)로 묶음 | 아래 |

View File

@ -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분할이 다룰 줄 모른다 |

View File

@ -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);
// "원본" 은 워킹트리 파일이 아니라 <b>내 쪽(ours) blob</b> 이다(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)

View File

@ -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);
/// <summary>편집기에서 직접 수정한 본문으로 해결.</summary>
void ResolveConflictWithContent(string path, string content);
/// <summary>
/// 충돌 파일을 이 글로 풀고 스테이징한다. 저장 형식이 "원본 유지"(또는 null)면 내 쪽(ours) 파일의
/// 인코딩과 줄끝을 따른다(6차 A-1).
/// </summary>
void ResolveConflictWithContent(string path, string content,
string? encodingName = null, string? newLineName = null);
/// <summary>충돌을 모두 해결한 뒤 병합 커밋 생성. 메시지 생략 시 기본 메시지 사용.</summary>
string CommitMerge(string? message = null);

View File

@ -76,16 +76,44 @@ namespace MergeHub.Services
/// </summary>
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)
/// <summary>
/// 글을 저장할 바이트로 만든다. <b>"원본 유지" 가 무엇을 따를지</b>를 <paramref name="reference"/> 로 받는다.
///
/// 따로 뺀 까닭(6차 A-1) - 병합 충돌 중의 워킹트리 파일에는 git 이 LF 로 충돌 표시를 넣어 둔다.
/// 그 파일을 "원본" 으로 보면 줄끝이 "혼합" 이 되고, 충돌 해결 저장은 아예 줄끝을 안 봐서 전부 LF 로 썼다.
/// 3,363줄 CRLF 파일에서 12줄을 고쳤는데 git 에는 파일 전체가 바뀐 것으로 나왔다.
/// 원본이 무엇인지는 부르는 쪽이 제일 잘 안다 - 충돌이면 내 쪽(ours) blob, 보통이면 디스크의 파일.
///
/// 규칙
/// - 인코딩: 고른 값이 있으면 그것. "원본 유지" 면 원본에서 판별. 원본이 없으면 UTF-8(BOM 없음).
/// - 줄끝: 고른 값이 있으면 그것. "원본 유지" 면 원본에서 <b>가장 많은 것</b>. 원본에 줄끝이 없으면 CRLF.
/// - BOM: 인코딩이 BOM 을 가지면 맨 앞에 한 번만 붙인다. 글 첫머리의 U+FEFF 는 떼고 쓴다
/// (blob 을 그대로 풀면 BOM 이 글자로 딸려 와 두 번 붙는다).
/// </summary>
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));
}
/// <summary>
/// 가장 많이 쓰인 줄끝. 줄끝이 하나도 없으면 CRLF(윈도우 사내 소스의 기본).
/// 같으면 CRLF · LF · CR 차례로 고른다.
///
/// "혼합" 을 그대로 두지 않는 까닭 - 저장은 한 가지로 해야 한다. 예전에는 혼합이면 무조건 CRLF 로 썼는데,
/// 거의 LF 인 파일에 CRLF 한 줄이 섞였을 때 파일 전체가 CRLF 로 바뀌었다.
/// </summary>
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";
}
/// <summary>

View File

@ -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
{