5차 A-2: Git 작업을 시작하는 자리에서 묻는다

커밋·체크아웃 뒤에 저장 안 한 편집이 말없이 사라지던 것을 막았다.

막는 자리를 옮겼다. 목록을 다시 세우는 자리(RefreshCommits →
LoadCommitAsync)가 아니라 Git 작업이 시작되는 문(GitWorkspace.
RunAndRefreshAsync)에서 묻는다. 그 순간에는 아직 아무것도 바뀌지
않았으므로 취소가 뜻을 가진다.

작업을 가려서 묻는 쪽은 접었다. fetch·태그·스테이징처럼 파일을 안
건드리는 열두 가지는 안 물으려 했는데, 해 보니 그런 작업 뒤에도
편집이 사라졌다. 파일이 그대로여도 저장소를 다시 읽으면서 본문을
새로 그리기 때문이다. 다시 그리지 않게 막는 쪽도 해 봤지만 고른
파일까지 바뀌는 경우가 있어 조건을 세울 수 없었다. 잃지 않는 쪽으로
갔다 - 확인 창은 고치던 것이 있을 때만 뜨고, 틀려도 한 번 더 묻는
것으로 끝난다. 알아낸 열두 이름은 KnownSafeCandidates 에 남겼다.

덤으로 4차 A-1 이 새던 자리 셋을 막았다. 목록을 다시 세우면 화면이
값을 되밀어 넣는데, 그것을 사람이 고른 것으로 보고 확인 창을 띄우고
있었다. OnSelectedNodeChanged(null·같은 커밋), OnRemoteBranchChanged
(_syncingBranches), Files.Clear()(_suspendGuard 안으로).

화면 시험 10건 통과(데모 저장소). GitOpTest 36건. 전체 623건 통과
(하네스 19벌). 빌드 경고 0 · 오류 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
김승준 2026-09-11 13:21:33 +09:00
parent 8e28a16fc2
commit 238910634c
6 changed files with 202 additions and 28 deletions

View File

@ -57,3 +57,99 @@ BOM · BOM 없는 UTF-8 · CP949 · ASCII 를 만들어 놓고 읽기 · 왕복
전체 **587건 통과**(하네스 18벌) · 빌드 경고 0 · 오류 0.
**실제 저장소는 건드리지 않았다** - 작업 전후 모두 변경 1건(7월부터 있던 것)으로 같다.
---
## A-2. 커밋·체크아웃 뒤에 저장 안 한 편집이 조용히 사라진다 (2026-09-11, 완료)
### 어디서 사라지고 있었나
4차 A-1 에서 편집이 말없이 사라지던 길 여덟 군데를 막았지만, **Git 탭은 그대로 두었다.**
`RefreshCommits` → `LoadCommitAsync` 는 프로그램이 목록을 다시 세우는 한가운데라
거기서 확인 창을 띄우면 무엇에 답하는 창인지 알 수 없고, 취소해도 목록은 이미 새로 세워진 뒤였다.
그래서 **막는 자리를 옮겼다.** 목록을 다시 세우는 자리가 아니라 **Git 작업을 시작하는 자리**에서 묻는다.
그 순간에는 아직 아무것도 바뀌지 않았으므로 취소가 뜻을 가진다.
`GitWorkspace.RunAndRefreshAsync` 는 Git 탭의 작업 서른 가지가 모두 지나가는 문이다.
여기에 `ConfirmDiscardEdits` 갈고리를 달고, 파일 비교 화면이 그 갈고리를 채운다.
```csharp
public async Task<bool> 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`) 사본으로만 했다.

View File

@ -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 스물세 항목. 항목마다 원인과 판단을 적었다 |

View File

@ -64,7 +64,7 @@
| 상태 | 항목 |
|---|---|
| **완료** | A-1 CP949 파일 67개를 잘못 읽으면 한글이 깨진다 |
| 대기 | A-2 커밋·체크아웃 뒤에 저장 안 한 편집이 조용히 사라진다 |
| **완료** | A-2 커밋·체크아웃 뒤에 저장 안 한 편집이 조용히 사라진다 |
### B. 실제 소스에 맞추기

View File

@ -0,0 +1,41 @@
using System;
using System.Collections.Generic;
namespace MergeHub.Services
{
/// <summary>
/// Git 작업 하나가 <b>파일 비교 화면의 고치던 내용을 잃게 할 수 있는지</b> 가른다.
///
/// <b>지금은 전부 "그렇다" 로 본다.</b> 처음에는 fetch · 태그 · 스테이징처럼 파일 내용을
/// 안 건드리는 것은 묻지 않게 했는데, 실제로 해 보니 <b>그런 작업 뒤에도 편집이 사라졌다.</b>
/// 파일이 안 바뀌어도 저장소 상태를 다시 읽으면서 본문을 새로 그리기 때문이다.
///
/// 다시 그리지 않게 막는 쪽으로도 해 봤지만, 새로 읽는 김에 <b>고른 파일까지 바뀌는</b> 경우가 있어
/// 조건을 정확히 세우기 어려웠다. 그래서 <b>잃지 않는 쪽</b>을 골랐다 -
/// 고치던 것이 있을 때만 뜨는 창이라 평소에는 보이지 않고,
/// 틀리더라도 한 번 더 묻는 것으로 끝나지 작업을 잃지는 않는다.
///
/// 나중에 "다시 그려도 편집이 살아남는" 구조가 되면 이 표를 되살린다.
/// 그때 무엇이 안전한지는 <see cref="KnownSafeCandidates"/> 에 적어 두었다.
/// </summary>
public static class GitOperationKinds
{
/// <summary>
/// 파일 <b>내용</b>은 안 바꾸는 작업들. 지금은 쓰지 않는다 - 다시 그리는 것 자체가 편집을 지운다.
/// 구조가 바뀌면 여기서부터 다시 시작한다.
/// </summary>
public static IReadOnlyCollection<string> KnownSafeCandidates { get; } = new HashSet<string>(StringComparer.Ordinal)
{
"fetch", "push", "원격 추가", "원격 삭제",
"브랜치 생성", "브랜치 삭제", "태그 생성", "태그 삭제",
"전체 스테이징", "스테이징", "언스테이징",
"stash 삭제",
};
/// <summary>
/// 이 작업 뒤에 고치던 것을 잃을 수 있는가. <b>지금은 언제나 그렇다.</b>
/// 까닭은 이 클래스 설명에 적었다.
/// </summary>
public static bool TouchesFiles(string? operation) => true;
}
}

View File

@ -222,14 +222,34 @@ namespace MergeHub.Services
}
}
/// <summary>작업 하나를 실행하고 성공하면 저장소 상태를 다시 읽는다.</summary>
/// <summary>
/// 저장하지 않은 편집이 있으면 물어봐 달라고 파일 비교 화면이 앉혀 두는 자리.
///
/// 두 화면이 서로를 모르는 채로 이 판단을 나눠야 한다 - Git 탭은 "무슨 작업을 하려는지" 를 알고,
/// 파일 비교 탭은 "고치던 것이 있는지" 를 안다. 이 객체가 그 사이 통로다.
/// 앉은 사람이 없으면(파일 비교 탭을 아직 안 열었으면) 늘 계속한다.
/// </summary>
public Func<string, bool>? ConfirmDiscardEdits { get; set; }
/// <summary>
/// 작업 하나를 실행하고 성공하면 저장소 상태를 다시 읽는다.
///
/// <b>다시 읽으면 파일 비교 화면의 본문도 새로 읽힌다.</b> 고치던 것이 있으면 그때 사라진다.
/// 그래서 <b>작업을 시작하기 전에</b> 묻는다 - 이 자리에서는 아직 아무것도 바뀌지 않았으므로
/// 취소가 뜻이 있다. 다 끝난 뒤 목록을 다시 세우는 자리에서 물으면 되돌릴 데가 없다
/// (4차 A-1 에서 그 자리를 일부러 비워 뒀던 까닭이고, 5차 A-2 가 그 구멍을 메운다).
/// </summary>
public async Task<bool> 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;
}
/// <summary>
/// 기본 경로가 Git 저장소로 보이는지. 워크트리는 .git 이 파일이라 둘 다 본다.
///

View File

@ -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 가 선택을 <b>잠깐 놓친다</b> - 항목 객체가 통째로 새로 만들어지므로
// null 이 한 번 들어온다. 이건 사람이 고른 것이 아니다.
// 그런데도 물으면 <b>스테이징만 해도 "다른 커밋을 봅니다" 창이 뜬다</b>(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();
// 목록을 프로그램이 다시 세우는 중이다. 사용자가 고른 것이 아니므로 묻지 않는다 -
// 무엇에 답하는 창인지 알 수 없고, 취소해도 목록은 이미 새로 세워진 뒤다.
//
// <b>Clear() 까지 감싸야 한다.</b> 목록을 비우면 거기 묶인 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
}
/// <summary>워크스페이스가 읽어 온 브랜치 목록을 그대로 옮긴다. 고른 값은 남아 있으면 지킨다.</summary>
/// <summary>
/// 브랜치 목록을 다시 세우는 중. 그 사이 콤보가 값을 흘리는 것은 사람이 고른 것이 아니다.
/// 묻지 않게 막는다 - 안 막으면 <b>스테이징만 해도 "비교 방식을 바꿉니다" 창이 뜬다</b>
/// (5차 A-2 에서 실제로 그랬다).
/// </summary>
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;