diff --git a/doc/history/2026-09-14-6차.md b/doc/history/2026-09-14-6차.md
index 2095b0f..386f2ca 100644
--- a/doc/history/2026-09-14-6차.md
+++ b/doc/history/2026-09-14-6차.md
@@ -207,3 +207,63 @@ A-1 검사를 넣고 하네스를 돌리다 **세 벌이 빌드가 안 된다**
A-2 에서 "건너뛴 목록 불러오기가 목록을 비우지 않게" 막았으므로 이제 **비지 않고 옛 목록이 남는다.**
새로고침 도중 게이트를 쥔 채로 목록 다시 세우기가 불리는 뿌리는 그대로라, B-1 에서 본다.
이번 확인에 쓴 `Io/Unit12.cs` 는 두 목록에 다 있어 결과에는 영향이 없다.
+
+
+---
+
+## A-4. 상대가 지운 파일을 3분할이 다룰 줄 모른다 (2026-09-14, 완료)
+
+### 고치기 전에 다시 났다
+
+시험 저장소 생성기에 **J. 내가 지웠는데 상대가 고침(DU)** 을 넣었다(지시서가 요구). 이제 충돌은 23개다.
+
+| 파일 | git | 화면 |
+|---|---|---|
+| `Recipe/ToBeDeleted.cs` (상대가 지움) | `UD` | 네 칸, 지운 쪽 칸이 빔, 범례 **`Theirs only 89`** (실은 내 쪽에만 있는 줄), 충돌 1개, 지웠다는 말 없음 |
+| `Recipe/ToBeDeletedByMe.cs` (내가 지움) | `DU` | 네 칸, `Theirs only 0`, 충돌 1개, 지웠다는 말 없음 |
+
+### 무엇이 원인이었나
+
+지운 쪽의 blob 이 없으면 `GetThreeWayContent` 는 그쪽 글을 빈 문자열로 준다. 화면은 그걸 "상대가 줄을 전부 지운 파일" 로 보고
+줄 단위 3분할을 만들었다. 사람이 정할 것은 **"이 파일을 남길까 지울까"** 하나인데, 그 물음이 화면 어디에도 없었다.
+파일 단위 해결(`ResolveConflict`)은 이미 "없는 쪽을 고르면 지운다" 로 바르게 동작했다 - 화면이 그 길로 안내하지 못했을 뿐이다.
+
+### 고친 것
+
+| 자리 | 무엇 |
+|---|---|
+| `Services/DeletionConflict.cs` (새 파일) | 어느 쪽이 지웠는지, **남기기 · 지우기가 각각 어느 쪽을 고르는 것인지**를 정한다. 지운 쪽에 따라 뒤바뀌므로(UD 는 남기기=내 것, DU 는 남기기=상대 것) 한 곳에 모았다 |
+| `LoadFileAsync` | 한쪽이 지운 충돌이면 줄 단위 3분할로 안 간다. **남아 있는 파일을 BASE 와 두 칸으로** 보여 준다 - 남은 쪽이 무엇을 고쳤는지는 보인다. 칸 제목이 `feature/mine · 내 쪽 (상대는 지움)` 처럼 누가 지웠는지 말한다 |
+| 범례 위 띠 | "상대(feature/theirs)가 이 파일을 지웠습니다. 내 쪽(feature/mine)은 고쳤습니다. 파일을 남길까요, 지울까요?" 와 [파일 남기기] · [파일 지우기] |
+| [파일 남기기] | 파일이 있는 쪽을 골라 스테이징. **고친 것이 있으면 고친 대로 남긴다**(해결로 저장과 같다) |
+| [파일 지우기] | 없는 쪽을 골라 지우고 스테이징. 고친 것이 있으면 한 번 묻는다 |
+| 뒷정리 | 해결로 저장 · 남기기 · 지우기가 같은 `ReloadAfterResolveAsync` 를 쓴다. 남은 충돌 첫 파일이 열린다 |
+
+### 확인한 것 (새로 만든 저장소, 충돌 23개에서 시작)
+
+| 확인 | 결과 |
+|---|---|
+| 보통 충돌(`Io/Unit12.cs`) | 띠 없음, 네 칸 그대로 |
+| 상대가 지운 파일을 연다 | 띠 "상대(feature/theirs)가 이 파일을 지웠습니다…", 단추 둘 켜짐, **두 칸**, 제목 `feature/mine · 내 쪽 (상대는 지움)`, 거꾸로 센 범례 없음, 콤보 그대로 |
+| 거기서 [파일 지우기] | "지움 · ToBeDeleted.cs · 남은 충돌 22건", git 충돌 22, 워킹트리에서 파일 없어짐, 스테이징 `D `, 다음 충돌 파일이 열림 |
+| 내가 지운 파일을 연다 | 띠 "내 쪽(feature/mine)에서 이 파일을 지웠습니다…", 제목 `feature/theirs · 상대 쪽 (나는 지움)`, 두 칸 |
+| 거기서 [파일 남기기] | "남김 · ToBeDeletedByMe.cs · 남은 충돌 21건", git 충돌 21, 상대가 고친 줄이 들어간 파일이 생김, CRLF 그대로(CR 70 = LF 70), 스테이징 `A ` |
+| Git 탭 충돌 목록 | 두 파일이 빠짐 |
+
+화면 확인 **23건 통과**. `MergeSaveTest` 에 지운 충돌 검사 16건을 넣었다(32 → 48).
+검사 **21벌 775건 통과**(전부 새로 빌드한 실행 파일) · 빌드 경고 0 · 오류 0.
+
+### 화면을 보다 하나 더 고쳤다 — BASE 칸 제목
+
+두 칸으로 보여 줄 때 BASE 칸 제목이 `a751a6b · Recipe/ToBeDeleted.cs` 로 나왔다. 지금 커밋과 견주는 것처럼 읽히지만
+실제로 보이는 것은 **공통 조상**이다. 제목을 정하는 조건이 "3분할인가" 만 봤기 때문이다.
+충돌 파일이면 두 칸이어도 `(merge base) 경로` 로 적게 했다.
+
+다시 확인 **5건 통과** — 보통 충돌은 네 칸, 두 지운 파일 모두 BASE 제목이 `(merge base) …`, 지우기 → 남은 충돌 22건, 남기기 → 21건.
+이 고침은 화면 코드(`HomeViewModel`)만 건드려 검사 하네스에는 걸리지 않는다. 앞의 775건은 마지막 서비스 코드 변경 뒤에 돌린 값이다.
+
+### 시험 저장소 생성기가 반쯤 지운 채 멈췄다
+
+다시 만들려는데 생성기가 `다른 프로세스가 사용 중` 으로 멈추고 저장소가 **빈 폴더**로 남았다. 앱을 끄는 명령과 생성기를
+동시에 돌렸고, 그 뒤에도 셸이 이 폴더를 작업 폴더로 잡고 있어 **뿌리 폴더를 지울 수 없었다.**
+생성기가 뿌리는 두고 **안만 비우게** 고쳤다. 제품 코드와는 상관없다.
diff --git a/doc/history/changelog.md b/doc/history/changelog.md
index 2185a4c..42bdc57 100644
--- a/doc/history/changelog.md
+++ b/doc/history/changelog.md
@@ -8,6 +8,7 @@
| 날짜 | 항목 | 커밋 |
|---|---|---|
+| 2026-09-14 | **6차 A-4** — 한쪽이 지운 충돌(UD·DU)은 줄 단위 3분할 대신 남은 파일을 두 칸으로 보이고 [파일 남기기] · [파일 지우기] 로 푼다. 거꾸로 세던 범례(Theirs only 89)가 없어짐 | 아래 |
| 2026-09-14 | **6차 A-3** — 병합 중 충돌 파일을 열면 콤보를 안 건드려도 네 칸이 뜬다. REMOTE 제목에 상대 브랜치 이름(MERGE_HEAD → 병합 메시지 → 짧은 해시) | 아래 |
| 2026-09-14 | **6차 A-1** — 충돌 해결 저장이 내 쪽(ours) 파일의 줄끝·인코딩을 따른다(3,363줄 파일 +3,367/−3,363 → +8/−4). 검사 모음이 옛 실행 파일로 돌던 것을 찾아 세 벌을 고침 | 아래 |
| 2026-09-14 | **6차 A-2** — 병합 중 [해결로 저장] 을 따로 둠(쓰고 스테이징만). Save & Commit 은 남은 충돌이 있으면 쓰기 전에 막는다. 건너뛴 목록 불러오기가 목록을 비우던 것도 막음 | 아래 |
diff --git a/doc/진행상황.md b/doc/진행상황.md
index d494423..a510ac4 100644
--- a/doc/진행상황.md
+++ b/doc/진행상황.md
@@ -96,7 +96,7 @@
| **완료** | A-1 3분할로 푼 파일을 저장하면 CRLF 가 LF 로 바뀐다 — 내 쪽(ours) 파일의 줄끝·인코딩을 따른다 |
| **완료** | A-2 Save & Commit 이 "실패" 라는데 파일은 이미 써지고 스테이징됐다 — [해결로 저장] 을 따로 둠 |
| **완료** | A-3 충돌 파일을 고르면 상대(Theirs) 칸이 안 나온다 — 콤보를 안 건드려도 네 칸, REMOTE 제목에 상대 브랜치 |
-| 대기 | A-4 상대가 지운 파일을 3분할이 다룰 줄 모른다 |
+| **완료** | A-4 상대가 지운 파일을 3분할이 다룰 줄 모른다 — 남은 파일을 두 칸으로, [파일 남기기] · [파일 지우기] 띠 |
| 대기 | A-5 양쪽 새 파일에 Accept Both 를 누르면 빌드가 깨진다 |
### B. 헷갈리거나 막히는 것
diff --git a/src/MergeHub/Services/DeletionConflict.cs b/src/MergeHub/Services/DeletionConflict.cs
new file mode 100644
index 0000000..b92cf76
--- /dev/null
+++ b/src/MergeHub/Services/DeletionConflict.cs
@@ -0,0 +1,75 @@
+using MergeHub.Models;
+
+namespace MergeHub.Services
+{
+ /// 병합 충돌에서 파일을 지운 쪽.
+ public enum DeletedSide
+ {
+ /// 양쪽 다 파일이 있다. 보통 충돌이다.
+ None,
+
+ /// 내가 지웠고 상대는 고쳤다 (git 상태 DU).
+ Ours,
+
+ /// 내가 고쳤고 상대는 지웠다 (git 상태 UD).
+ Theirs,
+ }
+
+ ///
+ /// 한쪽이 파일을 지운 충돌을 어떻게 풀지 가른다(6차 A-4).
+ ///
+ /// 이런 충돌에서 사람이 정할 것은 "이 파일을 남길까 지울까" 하나다. 예전에는 줄 단위 3분할로 끌고 가서
+ /// 지운 쪽 칸이 통째로 비고, 범례는 내 쪽에만 있는 줄 89개를 "Theirs only 89" 라고 거꾸로 말했다.
+ /// 충돌은 10번 줄 하나로만 잡혀 진짜 물음이 안 보였다.
+ ///
+ /// 풀기는 파일 단위 해결(ResolveConflict)에 맡긴다. 이미 "없는 쪽을 고르면 지운다" 로 동작한다.
+ /// 여기서는 남기기 · 지우기가 각각 어느 쪽을 고르는 것인지만 정한다 - 지운 쪽에 따라 뒤바뀌므로
+ /// 화면 코드에서 매번 따지면 틀리기 쉽다.
+ ///
+ public readonly struct DeletionConflict
+ {
+ private DeletionConflict(DeletedSide deleted)
+ {
+ Deleted = deleted;
+ }
+
+ public DeletedSide Deleted { get; }
+
+ public bool IsDeletion => Deleted != DeletedSide.None;
+
+ ///
+ /// git 인덱스의 충돌 항목에서 만든다. 한쪽만 없을 때만 지운 충돌이다.
+ /// 둘 다 없거나(양쪽이 지움 - 충돌이 아니다) 둘 다 있으면 보통 충돌로 본다.
+ ///
+ public static DeletionConflict From(bool hasOurs, bool hasTheirs)
+ {
+ if (hasOurs && !hasTheirs) return new DeletionConflict(DeletedSide.Theirs);
+ if (!hasOurs && hasTheirs) return new DeletionConflict(DeletedSide.Ours);
+ return default;
+ }
+
+ /// 남기려면 고를 쪽 - 파일이 있는 쪽.
+ public ConflictResolution KeepWith =>
+ Deleted == DeletedSide.Ours ? ConflictResolution.Theirs : ConflictResolution.Mine;
+
+ /// 지우려면 고를 쪽 - 파일이 없는 쪽. 없는 쪽을 고르면 파일 단위 해결이 지운다.
+ public ConflictResolution DeleteWith =>
+ Deleted == DeletedSide.Ours ? ConflictResolution.Mine : ConflictResolution.Theirs;
+
+ /// 화면 위 안내 한 줄. 누가 지웠고 누가 고쳤는지, 무엇을 골라야 하는지.
+ public string Message(string mine, string theirs) => Deleted switch
+ {
+ DeletedSide.Theirs => $"상대({theirs})가 이 파일을 지웠습니다. 내 쪽({mine})은 고쳤습니다. 파일을 남길까요, 지울까요?",
+ DeletedSide.Ours => $"내 쪽({mine})에서 이 파일을 지웠습니다. 상대({theirs})는 고쳤습니다. 파일을 남길까요, 지울까요?",
+ _ => string.Empty,
+ };
+
+ /// 남아 있는 파일을 보여 주는 칸의 제목.
+ public string SurvivingLabel(string mine, string theirs) => Deleted switch
+ {
+ DeletedSide.Theirs => $"{mine} · 내 쪽 (상대는 지움)",
+ DeletedSide.Ours => $"{theirs} · 상대 쪽 (나는 지움)",
+ _ => mine,
+ };
+ }
+}
diff --git a/src/MergeHub/ViewModels/HomeViewModel.cs b/src/MergeHub/ViewModels/HomeViewModel.cs
index 7a0dc70..a4d76f6 100644
--- a/src/MergeHub/ViewModels/HomeViewModel.cs
+++ b/src/MergeHub/ViewModels/HomeViewModel.cs
@@ -722,11 +722,93 @@ namespace MergeHub.ViewModels
: $"해결로 저장 · {Path.GetFileName(path)} · 남은 충돌 {remaining}건");
});
+ // 새로고침 직후엔 게이트가 잠깐 막혀 목록 세우기가 건너뛰어진다. 기다렸다 다시 세운다.
+ await ReloadAfterResolveAsync();
+ }
+
+ // ================= 한쪽이 지운 충돌 (6차 A-4) =================
+
+ private DeletionConflict _deletion;
+
+ /// 지금 열린 파일이 한쪽이 지운 충돌인지. 화면 위에 "남길까 지울까" 띠를 띄운다.
+ [ObservableProperty] private bool _isDeletionConflict;
+
+ [ObservableProperty] private string _deletionConflictText = string.Empty;
+
+ private void SetDeletion(DeletionConflict deletion, string? theirsName)
+ {
+ _deletion = deletion;
+ IsDeletionConflict = deletion.IsDeletion;
+ DeletionConflictText = deletion.IsDeletion
+ ? deletion.Message(_workspace.CurrentBranch, theirsName ?? "상대")
+ : string.Empty;
+ KeepFileCommand.NotifyCanExecuteChanged();
+ DeleteFileCommand.NotifyCanExecuteChanged();
+ }
+
+ private bool CanDecideDeletion() =>
+ IsGitMode && IsDeletionConflict && !string.IsNullOrEmpty(SelectedFile);
+
+ [RelayCommand(CanExecute = nameof(CanDecideDeletion))]
+ private Task KeepFileAsync() => DecideDeletionAsync(keep: true);
+
+ [RelayCommand(CanExecute = nameof(CanDecideDeletion))]
+ private Task DeleteFileAsync() => DecideDeletionAsync(keep: false);
+
+ ///
+ /// 한쪽이 지운 충돌을 파일 단위로 푼다. 남기기는 파일이 있는 쪽, 지우기는 없는 쪽을 고르는 것이다
+ /// (). 푼 뒤에는 해결로 저장과 같이 남은 충돌의 첫 파일로 간다.
+ ///
+ private async Task DecideDeletionAsync(bool keep)
+ {
+ var path = SelectedFile;
+ if (string.IsNullOrEmpty(path)) return;
+
+ // 남기되 고친 것이 있으면 고친 대로 남긴다 - 해결로 저장과 같은 일이다.
+ if (keep && HasChanges)
+ {
+ await SaveResolvedAsync();
+ return;
+ }
+
+ // 고친 것이 있는데 지우면 고친 것도 사라진다. 한 번 묻는다.
+ if (!keep && HasChanges && !ConfirmDiscard($"이 파일을 지웁니다: {path}")) return;
+
+ var resolution = keep ? _deletion.KeepWith : _deletion.DeleteWith;
+ var remaining = 0;
+
+ var ok = await _workspace.RunAsync(keep ? "파일 남기기" : "파일 지우기", () =>
+ {
+ _workspace.Git.ResolveConflict(path!, resolution);
+ remaining = MergeSavePlan.RemainingAfterSave(_workspace.Git.GetConflicts().Select(c => c.Path), path);
+ });
+
+ if (!ok) return;
+
+ _workspace.OnUi(() =>
+ {
+ _undo.Clear();
+ _redo.Clear();
+ HasChanges = false;
+ RaiseCanExecuteChanged();
+
+ var what = keep ? "남김" : "지움";
+ MessageUtil.ShowSuccessToast(remaining == 0
+ ? $"{what} · {Path.GetFileName(path)} · 남은 충돌 없음 - 이제 커밋할 수 있습니다"
+ : $"{what} · {Path.GetFileName(path)} · 남은 충돌 {remaining}건");
+ });
+
+ await ReloadAfterResolveAsync();
+ }
+
+ ///
+ /// 충돌 하나를 푼 뒤 저장소를 다시 읽고 파일 목록을 다시 세운다. 방금 푼 파일이 빠지므로 남은 충돌 첫 파일이 열린다.
+ /// 새로고침 직후엔 게이트가 잠깐 막혀 목록 세우기가 건너뛰어지므로 풀릴 때까지 기다렸다 한다(6차 A-2).
+ ///
+ private async Task ReloadAfterResolveAsync()
+ {
await _workspace.RefreshAsync();
- // 새로고침이 알아서 목록을 다시 세우지만, 그 신호가 게이트를 쥔 채로 오면 건너뛴다.
- // 새로고침 직후에도 다른 화면(Git 탭)이 게이트를 잠깐 쥐는 일이 있어, 곧바로 불러도 또 건너뛰었다.
- // 게이트가 풀릴 때까지 기다렸다가 세운다. 방금 푼 파일은 충돌 목록에서 빠지므로 남은 충돌의 첫 파일이 열린다.
for (var attempt = 0; attempt < 5; attempt++)
{
await _workspace.WhenIdleAsync();
@@ -757,6 +839,8 @@ namespace MergeHub.ViewModels
ReloadCommand.NotifyCanExecuteChanged();
SaveAndCommitCommand.NotifyCanExecuteChanged();
SaveResolvedCommand.NotifyCanExecuteChanged();
+ KeepFileCommand.NotifyCanExecuteChanged();
+ DeleteFileCommand.NotifyCanExecuteChanged();
SavePlainCommand.NotifyCanExecuteChanged();
ExportReportCommand.NotifyCanExecuteChanged();
@@ -878,6 +962,7 @@ namespace MergeHub.ViewModels
var branch = RemoteBranch;
var baseOrigin = string.Empty;
string? theirsName = null;
+ var deletion = default(DeletionConflict);
// 다른 작업이 게이트를 쥐고 있으면 RunAsync 는 아무것도 하지 않고 false 를 준다.
// 그대로 진행하면 빈 글로 화면을 갈아엎어 "차이 없음"이 뜬다(파일 비교 쪽에서 실제로 겪었다).
@@ -892,9 +977,23 @@ namespace MergeHub.ViewModels
localText = three.OursText;
remoteText = three.TheirsText;
isConflict = true;
- threeWay = true;
baseOrigin = "merge base";
theirsName = _workspace.Git.GetMergeTheirsName();
+
+ // 한쪽이 파일을 지운 충돌(6차 A-4). 정할 것은 "남길까 지울까" 하나라 줄 단위 3분할로 끌고 가지 않는다.
+ // 끌고 가면 지운 쪽 칸이 통째로 비고, 범례는 남은 쪽에만 있는 줄을 거꾸로 센다("Theirs only 89").
+ // 대신 남아 있는 파일을 BASE 와 두 칸으로 보여 준다 - 남은 쪽이 무엇을 고쳤는지는 보인다.
+ deletion = DeletionConflict.From(conflict.HasOurs, conflict.HasTheirs);
+ if (deletion.IsDeletion)
+ {
+ threeWay = false;
+ localText = deletion.Deleted == DeletedSide.Ours ? three.TheirsText : three.OursText;
+ remoteText = string.Empty;
+ }
+ else
+ {
+ threeWay = true;
+ }
}
else if (threeWay)
{
@@ -941,7 +1040,8 @@ namespace MergeHub.ViewModels
if (!IsGitMode) return;
// 행을 싣기 전에 틀부터 바꾼다. 세 칸 행을 두 칸 틀에 실으면 REMOTE 칸이 안 보인다.
- SetConflictThreeWay(isConflict);
+ SetConflictThreeWay(isConflict && !deletion.IsDeletion);
+ SetDeletion(deletion, theirsName);
Lines.Clear();
foreach (var row in rows) Lines.Add(row);
@@ -952,7 +1052,9 @@ namespace MergeHub.ViewModels
BaseHeaderLead = string.Empty;
LocalHeaderLead = string.Empty;
- BaseHeader = threeWay
+ // 충돌 파일이면 두 칸으로 보여도(한쪽이 지운 충돌, 6차 A-4) BASE 는 선택 커밋이 아니라 공통 조상이다.
+ // threeWay 만 보면 "a751a6b · 경로" 로 적혀 지금 커밋과 견주는 것처럼 읽혔다.
+ BaseHeader = threeWay || isConflict
? string.IsNullOrEmpty(baseText)
? $"({baseOrigin}) 이 파일 없음 - 양쪽에서 새로 만든 파일입니다"
: $"({baseOrigin}) {path}"
@@ -962,6 +1064,9 @@ namespace MergeHub.ViewModels
? $"REMOTE · {theirsName ?? "THEIRS"}"
: $"REMOTE · {branch}";
+ if (deletion.IsDeletion)
+ LocalHeader = deletion.SurvivingLabel(_workspace.CurrentBranch, theirsName ?? "상대");
+
_baseTextSnapshot = baseText;
_remoteTextSnapshot = threeWay ? remoteText : string.Empty;
_mineTextSnapshot = threeWay ? localText : string.Empty;
@@ -1011,6 +1116,7 @@ namespace MergeHub.ViewModels
HasChanges = false;
_loadedAsConflict = false;
SetConflictThreeWay(false);
+ SetDeletion(default, null);
}
///
diff --git a/src/MergeHub/Views/HomeView.xaml b/src/MergeHub/Views/HomeView.xaml
index 77cd4f5..b3f0aaf 100644
--- a/src/MergeHub/Views/HomeView.xaml
+++ b/src/MergeHub/Views/HomeView.xaml
@@ -552,7 +552,31 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -687,6 +711,7 @@
+