diff --git a/doc/history/2026-09-16-8차.md b/doc/history/2026-09-16-8차.md new file mode 100644 index 0000000..2801f3a --- /dev/null +++ b/doc/history/2026-09-16-8차.md @@ -0,0 +1,72 @@ +# 8차 — 고른 파일에 고른 내용을 · 작업마다 화면이 사실대로 (2026-09-16 ~) + +지시서: [../작업지시/8차.md](../작업지시/8차.md). 7차 D-1 에서 새로 걸린 넷과 7차에서 남긴 것 · 웹 문서. + +## A-1. 고른 충돌 파일 대신 다른 파일이 보이고, 그 화면을 저장하면 고른 파일에 써진다 (2026-09-16, 완료) + +### 고치기 전에 다시 났다 (새로 만든 저장소, MergeHub 로 병합) + +파일 비교 탭을 **한 번도 열지 않은 채** Git 탭 충돌 목록에서 `Common/Util/Unit47.cs` 를 고르고 [3분할로 열기]. + +| 확인 | 결과 | +|---|---| +| 파일 비교 탭 화면 | **`(merge base) Common/Util/Unit15.cs`** — 고른 파일이 아니라 첫 충돌 파일 | +| [다음 충돌] → Take Theirs → [Save] | 눌린다 | +| git | **`M Common/Util/Unit47.cs`** · `UU Common/Util/Unit15.cs` | +| 스테이징된 `Unit47.cs` 내용 | **123줄 · `public sealed class Unit15`** — Unit15 의 내용(고른 줄 · 머지 주석까지)이 Unit47 에 써졌다 | +| 로그 | `충돌 해결(직접 편집, UTF-8 (BOM) · CRLF): Common/Util/Unit47.cs` | +| 저장 뒤 화면 | 30초 동안 Unit15 · "남은 충돌 1개" 그대로 | + +7차 D-1 에서 병합 · pull 둘 다 이렇게 났다(그때는 파일 비교 탭을 먼저 열어 두고 네 길을 확인했다). + +### 원인 + +파일 비교 화면은 **탭을 처음 열 때 만들어진다.** 그때 두 가지가 겹친다. + +1. 목록 세우기(`RebuildListAfterRefreshAsync` → `LoadCommitAsync`)가 첫 파일(`Unit15`)을 읽는다. +2. 넘겨받은 요청(`DeliverPendingOpenConflict` → `OpenConflictAsync`)이 고른 파일(`Unit47`)을 고른다. + +`LoadFileAsync` 는 읽기가 끝난 뒤 **늦게 끝난 결과를 `IsGitMode` 로만 버렸다.** 읽기 시작한 경로가 지금 고른 파일과 같은지는 보지 않았다 +(같은 자리 주석이 "경로는 새 파일인데 내용은 옛 파일" 을 이미 겪었다고 적고 있었다 - 데모 전환에서). +그래서 Unit15 의 내용이 화면을 덮었고, [Save] 는 화면 내용을 **지금 고른 파일**(Unit47)에 썼다. + +### 고친 것 + +| 파일 | 무엇 | +|---|---| +| `Services/MergeSavePlan.cs` | `IsStaleLoad(읽은 경로 · 읽은 커밋, 지금 경로 · 지금 커밋)` — 읽는 사이에 파일 · 커밋이 바뀌었으면 버린다. `ShownFileMismatch(화면에 실린 파일, 저장할 파일)` — 다르거나 실린 것이 없으면 까닭을 돌려준다 | +| `ViewModels/HomeViewModel.cs` | `_shownFile` — **화면에 실제로 실린 파일**. `LoadFileAsync` 는 행을 만들기 전과 화면에 옮기기 직전 두 곳에서 `IsStaleLoad` 로 버리고, 실을 때 `_shownFile` 을 적는다. 버린 뒤 화면이 지금 고른 파일과 다르면 `ReloadWhenShownIsStale` 로 **한 번 더 읽는다**(되풀이 방지 플래그). `ClearPanes` 는 `_shownFile` 을 지운다 | +| 〃 | 쓰기 세 길(`SaveAndCommitAsync` · `SaveResolvedAsync` · `DecideDeletionAsync`)이 **아무것도 쓰기 전에** `ShownFileMismatch` 로 막고 알린다 | +| 하네스 `MergeSaveTest` | 검사 15건 — 같은 파일 · 다른 파일 · 다른 커밋 · 선택 없음 · 경로 구분자 · 대소문자 · 해시 대소문자, 저장 막기(같음 · 다름 · 두 이름이 까닭에 들어감 · 화면이 빔 · 저장할 파일이 빔) | + +읽기 쪽만 고치고 끝내지 않았다. 되돌릴 수 없는 쓰기 앞에 문을 하나 더 뒀다. + +**고치는 중에 한 번 더 걸렸다.** 버리기만 했더니 [3분할로 열기] 로 처음 열 때 **화면이 빈 채로** 남았다(단추도 없음). +버린 뒤 다시 읽는 `ReloadWhenShownIsStale` 을 넣어 고쳤다. + +### 확인한 것 (길마다 저장소를 새로 만들어, 파일 비교 탭을 미리 열지 않고) + +| 길 | [3분할로 열기] 뒤 화면 | Save 뒤 git | 스테이징된 Unit47 안의 class | +|---|---|---|---| +| 병합 | **`(merge base) Common/Util/Unit47.cs`** · 남은 충돌 1개 · REMOTE `feature/theirs` | `M Unit47.cs` · `UU Unit15.cs` | **Unit47** | +| pull | 같음 | 같음 | **Unit47** | +| rebase | 같음 · REMOTE `다시 올리는 내 커밋 1839bd3` | 같음 | **Unit47** | +| cherry-pick | 같음 · REMOTE `가져오는 커밋 f707175` | 같음 | **Unit47** | + +이어서 **병합을 사람 순서 그대로 끝까지**(파일 비교 탭을 미리 열지 않고) 했다. + +| 확인 | 결과 | +|---|---| +| 파일 단위 Accept Theirs 15개 | 24 → 9, 1.0초 | +| 3분할로 푼 파일 | NewPanel · Unit47 · 한글설명 · LineEnding · Unit0(6) · 띠 둘 · View250 · Unit1(6) — 저장할 때마다 다음 파일이 저절로 열림 | +| 마지막 Save 뒤 | `12b0402 · Weld/Unit1.cs · 병합 전 내 쪽` · 충돌 정리 끝 · 병합 커밋을 할 수 있습니다 · 내 쪽 대비 받은 변경 3줄 (7차 A-1 그대로) | +| 병합 커밋 | `3ba6314` 부모 둘 · `Merge branch 'feature/theirs' into feature/mine` · 워킹트리 Clean | +| 줄끝 · BOM · 인코딩이 첫 부모와 다른 파일 | **0 / 33** · 충돌 표식 0줄 | +| class 이름이 파일 이름과 다른 Unit 파일 | **0** | + +하네스 18벌 906건 통과(MergeSaveTest 140). 빌드 경고 0 · 오류 0. 설정 파일은 바이트 그대로 되돌림. + +### 남긴 것 + +- 자동 조작 쪽: Save 직후 찍은 화면이 Git 탭이라 파일 비교 탭 제목이 빈 것으로 읽혔다. 화면은 제대로였다(스크린샷). +- 7차 D-1 때 넣은 우회(파일 비교 탭을 먼저 한 번 열기)는 스위치(`D7_NO_PREOPEN`)로 껐다 켤 수 있게 두었다. 8차 E-1 은 끈 채로 한다. diff --git a/doc/history/changelog.md b/doc/history/changelog.md index 38e3546..eee540b 100644 --- a/doc/history/changelog.md +++ b/doc/history/changelog.md @@ -1,4 +1,4 @@ -# 변경 이력 +# 변경 이력 무엇을 왜 바꿨는지 한 줄로 남긴다. 자세한 이야기는 날짜별 파일에 있다. @@ -8,6 +8,7 @@ | 날짜 | 항목 | 커밋 | |---|---|---| +| 2026-09-16 | **8차 A-1** — 파일 비교 탭을 처음 여는 것이 [3분할로 열기] 면 고른 파일 대신 첫 충돌 파일이 보였고, 그 화면을 [Save] 하면 **고른 파일에 그 내용이 써졌다**(Unit47 이 Unit15 내용으로 스테이징). 탭을 만들 때의 목록 세우기와 고른 파일 열기가 겹쳐, 늦게 끝난 읽기가 화면을 덮은 것. 읽기는 경로 · 커밋이 바뀌었으면 버리고 한 번 더 읽게, 쓰기(저장 · Save & Commit · 파일 지우기/남기기)는 화면에 실린 파일과 다르면 아무것도 쓰지 않게 했다. 네 길 모두 고른 파일이 열리고 저장이 그 파일에만 감, 병합은 사람 순서 그대로 끝까지(줄끝 0/33 · 내용 검사 0) | 아래 | | 2026-09-15 | **8차 지시서** — 7차 전부 끝. D-1 에서 새로 걸린 넷(A-1 고른 파일에 다른 파일 내용이 저장됨 · B-1 rebase / cherry-pick "남은 차이" · B-2 빈 cherry-pick [커밋] · B-3 MERGED 번호)과 7차에서 남긴 것(revert 끝까지 · 원래 작성자 · 창 크기 스냅 등) · 웹 문서 갱신 · 다섯 길 재확인(파일 비교 탭을 미리 열지 않고). 코드 변경 없음 | 아래 | | 2026-09-15 | **7차 D-1** — 병합 · pull · rebase · cherry-pick 을 새 저장소에서 MergeHub 만으로 끝까지(코드 변경 없음). 네 길 모두 커밋까지 · 줄끝 · BOM 바뀐 파일 0 · A-2 · B-1 다시 안 남 · MERGED 줄 번호 안 잘림. 새로 걸린 것: **N1 파일 비교 탭을 처음 여는 것이 [3분할로 열기] 면 다른 파일이 보이고 그대로 [Save] 하면 고른 파일에 그 내용이 써짐** · N2 rebase · cherry-pick 마지막 저장 뒤 "남은 차이"(A-1 이 병합만) · N3 MERGED 번호가 결과 번호가 아님 · N4 빈 cherry-pick [커밋] 이 켜져 실패 → 8차 | 아래 | | 2026-09-15 | **7차 E-4** — 셸 창이 최대화 전용이었다. 가장자리를 끌어 크기 조절(최소 1280×720 DIP) · 브랜딩 바 두 번 누르기 최대화 ↔ 복원 · 보통 크기로 끌면 크기 그대로 · 화면 이동이 DIP 크기를 지킴 · 마지막 크기 · 모니터 · 최대화 기억. 최대화는 작업 표시줄까지 덮는다(사용자 결정 — 틀을 붙인 뒤 작업 표시줄이 하단 탭 바를 가려 최대화 동안 틀을 뗌). 크기를 바꿀 때 창이 검게 깜빡이던 것(99.7% → 3%). 플랫폼 수정 · 배포, MergeHub 코드 변경 없음 | 아래 | diff --git a/doc/진행상황.md b/doc/진행상황.md index b280ff8..8d0fabf 100644 --- a/doc/진행상황.md +++ b/doc/진행상황.md @@ -1,4 +1,4 @@ -# MergeHub 진행 상황 +# MergeHub 진행 상황 **지금 무엇이 남았는지만** 적는다. 끝난 것의 상세는 [history/](history/) 로 옮겼다. @@ -192,13 +192,14 @@ ## 8차 — 고른 파일에 고른 내용을 · 작업마다 화면이 사실대로 (2026-09-15 ~) +진행 기록: [history/2026-09-16-8차.md](history/2026-09-16-8차.md) 지시서: [작업지시/8차.md](작업지시/8차.md). 7차 D-1 에서 새로 걸린 넷과 7차에서 남긴 것(revert · 원래 작성자 · 창 크기의 안 본 곳) · 웹 문서. ### A. 사용자의 파일을 망가뜨리는 것 | 상태 | 항목 | |---|---| -| 대기 | A-1 고른 충돌 파일 대신 다른 파일이 보이고, 그 화면을 저장하면 고른 파일에 써진다 (**가장 먼저**) | +| **완료** | A-1 고른 충돌 파일 대신 다른 파일이 보이고, 그 화면을 저장하면 고른 파일에 써진다 — 늦게 끝난 읽기를 버리고 다시 읽음, 화면에 실린 파일과 저장할 파일이 다르면 쓰지 않음. 네 길 모두 고른 파일이 열리고 저장이 그 파일에만 감 | ### B. 작업마다 화면이 사실대로 말하게 diff --git a/src/MergeHub/Services/MergeSavePlan.cs b/src/MergeHub/Services/MergeSavePlan.cs index e69618a..8683b99 100644 --- a/src/MergeHub/Services/MergeSavePlan.cs +++ b/src/MergeHub/Services/MergeSavePlan.cs @@ -68,6 +68,41 @@ namespace MergeHub.Services public static string MergeBaseHeader(string shortSha, string path) => $"{shortSha} · {path} · 병합 전 내 쪽"; /// + /// + /// 읽어 온 내용이 지금 화면이 보여야 할 것과 다른가(8차 A-1). + /// + /// 파일 하나를 읽는 동안 사람이(또는 프로그램이) 다른 파일을 고를 수 있다. 늦게 끝난 읽기가 화면을 덮으면 + /// 고른 파일은 새 파일인데 화면 내용은 옛 파일이 된다. 그 화면에서 [Save] 를 누르면 고른 파일에 + /// 남의 내용이 써진다 - 파일 비교 탭을 [3분할로 열기] 로 처음 열 때 실제로 그랬다(7차 D-1 N1). + /// + public static bool IsStaleLoad(string? loadedPath, string? loadedSha, string? currentPath, string? currentSha) + { + if (!string.Equals(Normalize(loadedPath), Normalize(currentPath), StringComparison.Ordinal)) return true; + + return !string.Equals((loadedSha ?? string.Empty).Trim(), (currentSha ?? string.Empty).Trim(), + StringComparison.OrdinalIgnoreCase); + } + + /// + /// 화면에 실린 파일과 저장하려는 파일이 다르면 까닭, 같으면 null(8차 A-1). + /// + /// 읽기 쪽을 고쳐도 되돌릴 수 없는 쓰기 앞에 문을 하나 더 둔다. 화면에 아무것도 안 실렸으면 그것도 막는다 - + /// 무엇을 쓰는지 사람이 볼 수 없는 상태다. + /// + public static string? ShownFileMismatch(string? shownPath, string? savingPath) + { + var shown = Normalize(shownPath); + var saving = Normalize(savingPath); + + if (shown.Length == 0) + return "화면에 실린 파일이 없어 저장하지 않았습니다. 파일을 다시 고른 뒤 저장하세요."; + + if (string.Equals(shown, saving, StringComparison.Ordinal)) return null; + + return $"화면에 실린 파일({shown})과 저장하려는 파일({saving})이 달라 저장하지 않았습니다. " + + "파일을 다시 고른 뒤 저장하세요."; + } + /// git 은 경로를 '/' 로, 대소문자를 그대로 본다. 윈도우 경로 구분자로 들어와도 같게 맞춘다. /// 대소문자는 맞추지 않는다 - git 이 서로 다른 파일로 볼 수 있다. /// diff --git a/src/MergeHub/ViewModels/HomeViewModel.cs b/src/MergeHub/ViewModels/HomeViewModel.cs index 54fb862..aa3110a 100644 --- a/src/MergeHub/ViewModels/HomeViewModel.cs +++ b/src/MergeHub/ViewModels/HomeViewModel.cs @@ -501,6 +501,36 @@ namespace MergeHub.ViewModels /// 확인 창에서 [취소] 를 눌렀을 때 돌아갈 자리. private string? _lastLoadedFile; + /// + /// 지금 화면에 실제로 실려 있는 파일. 고른 파일()과 다를 수 있다 - + /// 읽기가 늦게 끝나거나 여는 도중 선택이 바뀌면 그렇다. 저장은 이 이름과 맞을 때만 한다(8차 A-1). + /// + private string? _shownFile; + + /// 버린 읽기를 다시 하는 중. 되풀이를 막는다. + private bool _reloadingStale; + + /// + /// 늦게 끝난 읽기를 버렸을 때, 화면에 실린 파일이 지금 고른 파일과 다르면 한 번 더 읽는다(8차 A-1). + /// + /// 버리기만 하면 화면이 빈 채로 남는다 - 충돌 목록에서 [3분할로 열기] 로 파일 비교 탭을 처음 열면 + /// 목록 세우기와 겹쳐 읽기가 버려지고, 아무것도 안 보였다. + /// + private void ReloadWhenShownIsStale() + { + if (_reloadingStale) return; + if (string.Equals(_shownFile, SelectedFile, StringComparison.Ordinal)) return; + + _reloadingStale = true; + _ = ReloadShownAsync(); + } + + private async Task ReloadShownAsync() + { + try { await LoadFileAsync(); } + finally { _reloadingStale = false; } + } + /// 되돌리는 중. 그 사이에는 다시 묻지도, 다시 읽지도 않는다. private bool _revertingFile; @@ -773,6 +803,14 @@ namespace MergeHub.ViewModels var path = SelectedFile; if (string.IsNullOrEmpty(path)) return; + // 화면에 실린 파일과 저장할 파일이 다르면 아무것도 쓰지 않는다(8차 A-1). + var mismatch = MergeSavePlan.ShownFileMismatch(_shownFile, path); + if (mismatch is not null) + { + MessageUtil.ShowWarningToast(mismatch); + return; + } + var content = BuildLocalText(); var message = CommitMessage; var encoding = SaveEncoding; @@ -864,6 +902,14 @@ namespace MergeHub.ViewModels var path = SelectedFile; if (string.IsNullOrEmpty(path)) return; + // 화면에 실린 파일과 저장할 파일이 다르면 아무것도 쓰지 않는다(8차 A-1). + var mismatch = MergeSavePlan.ShownFileMismatch(_shownFile, path); + if (mismatch is not null) + { + MessageUtil.ShowWarningToast(mismatch); + return; + } + var content = BuildLocalText(); var encoding = SaveEncoding; var newLine = SaveNewLine; @@ -947,6 +993,14 @@ namespace MergeHub.ViewModels var path = SelectedFile; if (string.IsNullOrEmpty(path)) return; + // 화면에 실린 파일과 저장할 파일이 다르면 아무것도 쓰지 않는다(8차 A-1). + var mismatch = MergeSavePlan.ShownFileMismatch(_shownFile, path); + if (mismatch is not null) + { + MessageUtil.ShowWarningToast(mismatch); + return; + } + // 남기되 고친 것이 있으면 고친 대로 남긴다 - 해결로 저장과 같은 일이다. if (keep && HasChanges) { @@ -1270,6 +1324,13 @@ namespace MergeHub.ViewModels if (!loaded) return; + // 행을 만들기 전에 한 번 본다 - 읽는 동안 선택이 바뀌었으면 여기서 그만둔다(8차 A-1). + if (MergeSavePlan.IsStaleLoad(path, commit.Sha, SelectedFile, SelectedCommit?.Sha)) + { + ReloadWhenShownIsStale(); + return; + } + // 행을 만들기 전에 언어를 정해야 한다 - 블록 주석 이어붙이기가 이 값을 본다. RefreshLanguage(); @@ -1290,6 +1351,17 @@ namespace MergeHub.ViewModels // 옛 파일인 상태가 된다 - 데모 전환에서 실제로 겪었다. if (!IsGitMode) return; + // 읽기 시작한 뒤 다른 파일 · 다른 커밋을 골랐으면 버린다(8차 A-1). + // 그대로 덮으면 고른 파일은 새 파일인데 화면 내용은 옛 파일이 되고, 그 화면을 저장하면 남의 내용이 써진다. + if (MergeSavePlan.IsStaleLoad(path, commit.Sha, SelectedFile, SelectedCommit?.Sha)) + { + ReloadWhenShownIsStale(); + return; + } + + // 여기서부터 화면에 실리는 것은 이 파일의 내용이다. 저장은 이 이름으로만 쓴다. + _shownFile = path; + // 행을 싣기 전에 틀부터 바꾼다. 세 칸 행을 두 칸 틀에 실으면 REMOTE 칸이 안 보인다. _theirsSideName = isConflict ? theirsName ?? "THEIRS" : null; _mineSideName = isConflict ? OperationLabels.MineLogName(operation, _workspace.CurrentBranch) : null; @@ -1388,6 +1460,7 @@ namespace MergeHub.ViewModels EditSummary = "-"; HasChanges = false; _loadedAsConflict = false; + _shownFile = null; SetConflictThreeWay(false); SetDeletion(default, null); }