diff --git a/doc/history/2026-09-11-5차.md b/doc/history/2026-09-11-5차.md index 563fdf6..72e801f 100644 --- a/doc/history/2026-09-11-5차.md +++ b/doc/history/2026-09-11-5차.md @@ -56,4 +56,100 @@ BOM · BOM 없는 UTF-8 · CP949 · ASCII 를 만들어 놓고 읽기 · 왕복 규칙을 여기 못박아 두면 다음에 누가 고쳐도 바로 걸린다. 전체 **587건 통과**(하네스 18벌) · 빌드 경고 0 · 오류 0. -**실제 저장소는 건드리지 않았다** - 작업 전후 모두 변경 1건(7월부터 있던 것)으로 같다. \ No newline at end of file +**실제 저장소는 건드리지 않았다** - 작업 전후 모두 변경 1건(7월부터 있던 것)으로 같다. + +--- + +## A-2. 커밋·체크아웃 뒤에 저장 안 한 편집이 조용히 사라진다 (2026-09-11, 완료) + +### 어디서 사라지고 있었나 + +4차 A-1 에서 편집이 말없이 사라지던 길 여덟 군데를 막았지만, **Git 탭은 그대로 두었다.** +`RefreshCommits` → `LoadCommitAsync` 는 프로그램이 목록을 다시 세우는 한가운데라 +거기서 확인 창을 띄우면 무엇에 답하는 창인지 알 수 없고, 취소해도 목록은 이미 새로 세워진 뒤였다. + +그래서 **막는 자리를 옮겼다.** 목록을 다시 세우는 자리가 아니라 **Git 작업을 시작하는 자리**에서 묻는다. +그 순간에는 아직 아무것도 바뀌지 않았으므로 취소가 뜻을 가진다. + +`GitWorkspace.RunAndRefreshAsync` 는 Git 탭의 작업 서른 가지가 모두 지나가는 문이다. +여기에 `ConfirmDiscardEdits` 갈고리를 달고, 파일 비교 화면이 그 갈고리를 채운다. + +```csharp +public async Task RunAndRefreshAsync(string operation, Action action) +{ + if (GitOperationKinds.TouchesFiles(operation) && ConfirmDiscardEdits is { } ask && !ask(operation)) return false; + var ok = await RunAsync(operation, action); + if (ok) await RefreshAsync(); + return ok; +} +``` + +### 처음 고른 답이 틀렸다 — 가려서 묻기 + +처음에는 **작업을 가려서** 물으려 했다. fetch · 태그 생성 · 전체 스테이징처럼 +**파일 내용을 안 건드리는 열두 가지**는 묻지 않는 쪽이 낫다고 봤다. +쓸데없이 뜨는 창만큼 사람을 지치게 하는 것이 없기 때문이다. + +첫 시도는 호출 자리마다 `touchesFiles: false` 를 붙이는 것이었는데, +줄을 훑어 고치는 스크립트가 **엉뚱한 닫는 괄호**(예: `throw new InvalidOperationException(...)`)에 붙였다. +되돌리고, 판단을 **이름표 하나**(`GitOperationKinds`)로 모았다. 부르는 자리는 손대지 않는다. + +그런데 **화면에서 해 보니 안 묻고 지나간 뒤에도 편집이 사라졌다.** +파일은 그대로인데도 저장소 상태를 다시 읽으면서 본문을 새로 그리기 때문이다. + +다시 그리지 않게 막는 쪽으로도 해 봤다. + +```csharp +if (HasChanges && !threeWay && string.Equals(localText, _originalLocalText, StringComparison.Ordinal)) + return; +``` + +**이것도 안 들었다.** 다시 읽는 김에 고른 파일까지 바뀌는 경우가 있어 +"안 바뀌었다" 를 정확히 가릴 조건을 세울 수가 없었다. 이 코드는 지웠다. + +### 골라잡은 답 — 전부 묻는다 + +**잃지 않는 쪽으로 갔다.** `TouchesFiles` 는 이제 언제나 `true` 다. + +- 확인 창은 **고치던 것이 있을 때만** 뜬다. 평소에는 한 번도 보이지 않는다. +- 틀리더라도 **한 번 더 묻는 것**으로 끝나지, 작업을 잃지는 않는다. + 가려서 묻다 틀리면 사람이 고친 것을 잃는다. 두 실수의 값이 다르다. + +다만 **무엇이 안전한지 알아낸 것은 버리지 않았다.** 열두 가지 이름을 +`GitOperationKinds.KnownSafeCandidates` 에 남겨 두고, 왜 지금 쓰지 않는지를 클래스 주석에 적었다. +"다시 그려도 편집이 살아남는" 구조가 되면 거기서부터 다시 시작한다. + +### 덤으로 잡은 것 — 4차 A-1 이 새던 자리 셋 + +전부 묻게 만들고 나니 **Git 탭을 그냥 열기만 해도 확인 창이 떴다.** +사람이 한 일이 아닌데 4차 A-1 의 확인 창이 불려 나오고 있었다. 셋을 잡았다. + +| 새던 자리 | 왜 떴나 | 어떻게 막았나 | +|---|---|---| +| `OnSelectedNodeChanged` | 커밋 목록을 다시 세우면 잠깐 `null` 이 밀려 들어온다 | `value is not null && !sameCommit` 일 때만 묻는다 | +| `OnRemoteBranchChanged` | `RefreshRemoteBranches` 가 목록을 갈아 끼우며 값을 바꾼다 | `_syncingBranches` 동안은 묻지 않는다 | +| `OnSelectedFileChanged` | `Files.Clear()` 가 가드 밖에 있었다 | `_suspendGuard` 안으로 옮겼다 | + +**목록을 다시 세우면 화면이 값을 되밀어 넣는다.** 그것은 사람이 고른 것이 아니다. +4차 A-1 을 넣을 때 놓친 것인데, A-2 를 화면에서 시험하다 드러났다. + +### 확인한 것 (실제 화면, 데모 저장소) + +| 확인 | 결과 | +|---|---| +| LOCAL 을 고친 줄이 들어간다 | OK | +| 전체 스테이징이 묻는다 | OK | +| 취소하면 스테이징도 안 한다 (`git diff --cached` 비었다) | OK | +| 취소하면 고친 줄이 그대로다 | OK | +| 체크아웃이 묻는다 | OK | +| 취소하면 브랜치가 그대로다 (`master`) | OK | +| 체크아웃을 취소해도 고친 줄이 그대로다 | OK | +| "저장하고 계속" 을 고르면 고친 내용이 파일에 남는다 | OK | +| 고친 것이 없으면 안 묻는다 | OK | + +**열 가지 전부 통과.** 화면 시험은 UI Automation 으로 몰았다(`a2screen.ps1`). +확인 창이 **따로 뜨는 창이 아니라 본 창 안에 덮어 그리는 판**이라, 바깥 창 목록을 뒤지면 못 찾는다. +처음 두 번은 이것 때문에 "안 묻는다" 고 잘못 읽었다 — **시험 도구가 틀려서 제품을 의심했다.** + +검사를 `GitOpTest` 36건으로 고쳤다. 전체 **623건 통과**(하네스 19벌) · 빌드 경고 0 · 오류 0. +**실제 저장소는 건드리지 않았다** — 데모 저장소(`mergehub-demo`) 사본으로만 했다. diff --git a/doc/history/changelog.md b/doc/history/changelog.md index 305b251..6fc28a7 100644 --- a/doc/history/changelog.md +++ b/doc/history/changelog.md @@ -8,6 +8,7 @@ | 날짜 | 항목 | 커밋 | |---|---|---| +| 2026-09-11 | **5차 A-2** — Git 작업을 시작하는 자리에서 묻게 함. 가려서 묻는 것은 접고 전부 묻는다. 덤으로 4차 A-1 이 새던 자리 셋을 막음 | 아래 | | 2026-09-11 | **5차 A-1** — 실제 저장소 2,091개로 인코딩을 확인(깨짐 0). 판별 코드가 두 군데 복사돼 있던 것을 한 곳으로 모으고, CP949 를 한 이름으로 부르게 함 | 아래 | | 2026-09-10 | **4차 C-5** — 한 번 정하는 것들을 한자리에 모아 접고, 차이 이동을 개수 옆으로 | 아래 | | 2026-09-10 | **4차 C-4** — 범례를 눌러 그 갈래로 이동. 0건은 흐리게 | 아래 | @@ -40,21 +41,6 @@ | 날짜 | 문서 | 내용 | |---|---|---| -| 2026-09-11 | **5차 A-1** — 실제 저장소 2,091개로 인코딩을 확인(깨짐 0). 판별 코드가 두 군데 복사돼 있던 것을 한 곳으로 모으고, CP949 를 한 이름으로 부르게 함 | 아래 | -| 2026-09-10 | **4차 C-5** — 한 번 정하는 것들을 한자리에 모아 접고, 차이 이동을 개수 옆으로 | 아래 | -| 2026-09-10 | **4차 C-4** — 범례를 눌러 그 갈래로 이동. 0건은 흐리게 | 아래 | -| 2026-09-10 | **4차 C-3** — Git 탭의 원격 칸을 접을 수 있게. 로컬 경로 한 줄만 남고 세로 128px 가 목록으로 간다 | 아래 | -| 2026-09-10 | **엔터 고침의 되돌이 문제** — TextChanged 안에서 편집기 글을 갈아 끼워 캐럿이 엉켰다. 한 차례 뒤로 물림 | 아래 | -| 2026-09-10 | **엔터 반응 지연** — 줄 수가 바뀌면 0.6초 타이머만 기다리느라 화면이 비어 있었다. 그 자리에서 다시 맞추게 고침 | 아래 | -| 2026-09-10 | **4차 C-2** — 아래쪽 세 군데 상태 정보를 창 아래 한 줄로. 두 패널 코드 높이가 저절로 맞는다 | 아래 | -| 2026-09-10 | **4차 C-1 다시** — 머리글을 파일 이름만으로 줄인 것이 잘못이었다. 두 경로에서 다른 부분만 남기게 고침 | 아래 | -| 2026-09-10 | **4차 C-1** — 파일 비교에서 같은 경로가 나오던 세 자리를 하나로. 코드가 두 줄 더 보인다 | 아래 | -| 2026-09-10 | **4차 B-3** — 폴더 비교 목록을 파일 비교 화면에서 이전/다음으로 딛는다 (Ctrl+PageUp/PageDown) | 아래 | -| 2026-09-10 | **4차 B-1** — 최근에 견준 파일 짝·폴더 짝을 기억해 위 줄에서 바로 연다. 없는 경로는 흐리게만 하고 지우지 않는다 | 아래 | -| 2026-09-10 | **4차 A-2** — 내보낸 보고서 머리글에 만든 사람을 넣음. 이름 판단을 머지 주석과 한 함수로 모았다 | 아래 | -| 2026-09-10 | **4차 A-1** — 저장하지 않은 편집이 말없이 사라지던 길 여덟 군데를 막음. 플랫폼에 세 갈래 확인 창을 새로 넣었다 | 아래 | -| 2026-09-10 | **4차 D-2** — 항목을 끝내면 상세·상태·한 줄을 각각 어디에 적을지 공통 규칙으로 못박음 | 아래 | -| 2026-09-10 | **4차 D-1** — 721줄짜리 진행상황을 `history/` 의 날짜별 파일로 나누고 지시서를 `작업지시/` 로 모음 | 아래 | | 2026-09-11 | [2026-09-11-5차.md](2026-09-11-5차.md) | 5차 지시서 진행 중 | | 2026-09-10 | [2026-09-10-4차.md](2026-09-10-4차.md) | 4차 지시서 열세 항목. 전부 끝 | | 2026-09-09 | [2026-09-09-3차.md](2026-09-09-3차.md) | 3차 지시서 A~E 스물세 항목. 항목마다 원인과 판단을 적었다 | diff --git a/doc/진행상황.md b/doc/진행상황.md index c269282..4f6159b 100644 --- a/doc/진행상황.md +++ b/doc/진행상황.md @@ -64,7 +64,7 @@ | 상태 | 항목 | |---|---| | **완료** | A-1 CP949 파일 67개를 잘못 읽으면 한글이 깨진다 | -| 대기 | A-2 커밋·체크아웃 뒤에 저장 안 한 편집이 조용히 사라진다 | +| **완료** | A-2 커밋·체크아웃 뒤에 저장 안 한 편집이 조용히 사라진다 | ### B. 실제 소스에 맞추기 diff --git a/src/MergeHub/Services/GitOperationKinds.cs b/src/MergeHub/Services/GitOperationKinds.cs new file mode 100644 index 0000000..5e7db7d --- /dev/null +++ b/src/MergeHub/Services/GitOperationKinds.cs @@ -0,0 +1,41 @@ +using System; +using System.Collections.Generic; + +namespace MergeHub.Services +{ + /// + /// Git 작업 하나가 파일 비교 화면의 고치던 내용을 잃게 할 수 있는지 가른다. + /// + /// 지금은 전부 "그렇다" 로 본다. 처음에는 fetch · 태그 · 스테이징처럼 파일 내용을 + /// 안 건드리는 것은 묻지 않게 했는데, 실제로 해 보니 그런 작업 뒤에도 편집이 사라졌다. + /// 파일이 안 바뀌어도 저장소 상태를 다시 읽으면서 본문을 새로 그리기 때문이다. + /// + /// 다시 그리지 않게 막는 쪽으로도 해 봤지만, 새로 읽는 김에 고른 파일까지 바뀌는 경우가 있어 + /// 조건을 정확히 세우기 어려웠다. 그래서 잃지 않는 쪽을 골랐다 - + /// 고치던 것이 있을 때만 뜨는 창이라 평소에는 보이지 않고, + /// 틀리더라도 한 번 더 묻는 것으로 끝나지 작업을 잃지는 않는다. + /// + /// 나중에 "다시 그려도 편집이 살아남는" 구조가 되면 이 표를 되살린다. + /// 그때 무엇이 안전한지는 에 적어 두었다. + /// + public static class GitOperationKinds + { + /// + /// 파일 내용은 안 바꾸는 작업들. 지금은 쓰지 않는다 - 다시 그리는 것 자체가 편집을 지운다. + /// 구조가 바뀌면 여기서부터 다시 시작한다. + /// + public static IReadOnlyCollection KnownSafeCandidates { get; } = new HashSet(StringComparer.Ordinal) + { + "fetch", "push", "원격 추가", "원격 삭제", + "브랜치 생성", "브랜치 삭제", "태그 생성", "태그 삭제", + "전체 스테이징", "스테이징", "언스테이징", + "stash 삭제", + }; + + /// + /// 이 작업 뒤에 고치던 것을 잃을 수 있는가. 지금은 언제나 그렇다. + /// 까닭은 이 클래스 설명에 적었다. + /// + public static bool TouchesFiles(string? operation) => true; + } +} \ No newline at end of file diff --git a/src/MergeHub/Services/GitWorkspace.cs b/src/MergeHub/Services/GitWorkspace.cs index d64cd7b..e25d76f 100644 --- a/src/MergeHub/Services/GitWorkspace.cs +++ b/src/MergeHub/Services/GitWorkspace.cs @@ -222,14 +222,34 @@ namespace MergeHub.Services } } - /// 작업 하나를 실행하고 성공하면 저장소 상태를 다시 읽는다. + /// + /// 저장하지 않은 편집이 있으면 물어봐 달라고 파일 비교 화면이 앉혀 두는 자리. + /// + /// 두 화면이 서로를 모르는 채로 이 판단을 나눠야 한다 - Git 탭은 "무슨 작업을 하려는지" 를 알고, + /// 파일 비교 탭은 "고치던 것이 있는지" 를 안다. 이 객체가 그 사이 통로다. + /// 앉은 사람이 없으면(파일 비교 탭을 아직 안 열었으면) 늘 계속한다. + /// + public Func? ConfirmDiscardEdits { get; set; } + + /// + /// 작업 하나를 실행하고 성공하면 저장소 상태를 다시 읽는다. + /// + /// 다시 읽으면 파일 비교 화면의 본문도 새로 읽힌다. 고치던 것이 있으면 그때 사라진다. + /// 그래서 작업을 시작하기 전에 묻는다 - 이 자리에서는 아직 아무것도 바뀌지 않았으므로 + /// 취소가 뜻이 있다. 다 끝난 뒤 목록을 다시 세우는 자리에서 물으면 되돌릴 데가 없다 + /// (4차 A-1 에서 그 자리를 일부러 비워 뒀던 까닭이고, 5차 A-2 가 그 구멍을 메운다). + /// public async Task RunAndRefreshAsync(string operation, Action action) { + if (GitOperationKinds.TouchesFiles(operation) && ConfirmDiscardEdits is { } ask && !ask(operation)) return false; + var ok = await RunAsync(operation, action); if (ok) await RefreshAsync(); return ok; } + + /// /// 기본 경로가 Git 저장소로 보이는지. 워크트리는 .git 이 파일이라 둘 다 본다. /// diff --git a/src/MergeHub/ViewModels/HomeViewModel.cs b/src/MergeHub/ViewModels/HomeViewModel.cs index 6415648..9e07c92 100644 --- a/src/MergeHub/ViewModels/HomeViewModel.cs +++ b/src/MergeHub/ViewModels/HomeViewModel.cs @@ -38,6 +38,9 @@ namespace MergeHub.ViewModels _workspace.PlainCompareRequested += OnPlainCompareRequested; _workspace.Recent.Changed += (_, _) => _workspace.OnUi(RefreshRecent); _workspace.QueueChanged += (_, _) => RefreshQueue(); + + // Git 작업이 시작되기 전에 우리에게 물어보게 한다(5차 A-2). + _workspace.ConfirmDiscardEdits = op => ConfirmDiscard($"Git 작업을 합니다: {op}"); _isRulesOpen = _workspace.Settings.ActionsRulesExpanded; _reflowTimer.Tick += (_, _) => Reflow(); @@ -425,8 +428,15 @@ namespace MergeHub.ViewModels { if (_syncingSelection || _revertingNode) { _lastLoadedNode = value; return; } - // 커밋을 바꾸면 BASE 뿐 아니라 LOCAL 까지 파일에서 다시 읽는다. 고치던 것이 있으면 사라진다. - if (!ConfirmDiscard("다른 커밋을 봅니다.")) + // 목록을 다시 세우면 ListBox 가 선택을 잠깐 놓친다 - 항목 객체가 통째로 새로 만들어지므로 + // null 이 한 번 들어온다. 이건 사람이 고른 것이 아니다. + // 그런데도 물으면 스테이징만 해도 "다른 커밋을 봅니다" 창이 뜬다(5차 A-2 에서 실제로 그랬다). + // + // 같은 커밋으로 다시 들어오는 경우도 마찬가지다 - 새로고침이 제자리를 찾아 준 것이지 + // 사람이 옮긴 것이 아니다. + var sameCommit = value?.Commit?.Sha is string sha && sha == SelectedCommit?.Sha; + + if (value is not null && !sameCommit && !ConfirmDiscard("다른 커밋을 봅니다.")) { var back = _lastLoadedNode; _revertingNode = true; @@ -709,18 +719,22 @@ namespace MergeHub.ViewModels _workspace.OnUi(() => { - Files.Clear(); - foreach (var f in files) Files.Add(f); - - var next = previous is not null && files.Contains(previous) - ? previous - : changed.FirstOrDefault(files.Contains) ?? files.FirstOrDefault(); - // 목록을 프로그램이 다시 세우는 중이다. 사용자가 고른 것이 아니므로 묻지 않는다 - // 무엇에 답하는 창인지 알 수 없고, 취소해도 목록은 이미 새로 세워진 뒤다. + // + // Clear() 까지 감싸야 한다. 목록을 비우면 거기 묶인 ListBox 가 선택을 놓치면서 + // SelectedFile 에 null 을 밀어 넣는다. 그것도 "사용자가 파일을 바꿨다" 로 읽혀 + // 스테이징만 해도 "다른 파일을 엽니다" 창이 떴다(5차 A-2). _suspendGuard = true; try { + Files.Clear(); + foreach (var f in files) Files.Add(f); + + var next = previous is not null && files.Contains(previous) + ? previous + : changed.FirstOrDefault(files.Contains) ?? files.FirstOrDefault(); + if (!string.Equals(SelectedFile, next, StringComparison.Ordinal)) SelectedFile = next; // setter 가 LoadFileAsync 를 부른다 else @@ -1167,7 +1181,8 @@ namespace MergeHub.ViewModels { // 3-Way 로 들고 나면 본문을 다시 읽는다. 고치던 것이 있으면 사라진다. // _suspendReload 가 걸린 길(데모 전환·파일 비교 전환)은 다시 읽지 않으므로 묻지 않는다. - if (!_revertingRemoteBranch && IsGitMode && !_suspendReload && !ConfirmDiscard("비교 방식을 바꿉니다.")) + if (!_revertingRemoteBranch && !_syncingBranches && IsGitMode && !_suspendReload && + !ConfirmDiscard("비교 방식을 바꿉니다.")) { var back = _lastLoadedRemoteBranch; _revertingRemoteBranch = true; @@ -1189,10 +1204,20 @@ namespace MergeHub.ViewModels } /// 워크스페이스가 읽어 온 브랜치 목록을 그대로 옮긴다. 고른 값은 남아 있으면 지킨다. + /// + /// 브랜치 목록을 다시 세우는 중. 그 사이 콤보가 값을 흘리는 것은 사람이 고른 것이 아니다. + /// 묻지 않게 막는다 - 안 막으면 스테이징만 해도 "비교 방식을 바꿉니다" 창이 뜬다 + /// (5차 A-2 에서 실제로 그랬다). + /// + private bool _syncingBranches; + private void RefreshRemoteBranches() { var keep = RemoteBranch; + _syncingBranches = true; + try + { RemoteBranches.Clear(); RemoteBranches.Add(NoRemoteBranch); @@ -1207,6 +1232,12 @@ namespace MergeHub.ViewModels // 고른 브랜치가 사라졌으면 조용히 2-Way 로 돌아간다. if (!RemoteBranches.Contains(keep)) RemoteBranch = NoRemoteBranch; + else RemoteBranch = keep; // 목록을 다시 세우며 흘린 값을 제자리로 + } + finally + { + _syncingBranches = false; + } } private bool CanTakeLocalLine(MergeLine? line) => line?.CanTakeLocal == true;