diff --git a/doc/history/2026-09-14-7차.md b/doc/history/2026-09-14-7차.md index 4fb6c43..754b4d7 100644 --- a/doc/history/2026-09-14-7차.md +++ b/doc/history/2026-09-14-7차.md @@ -170,6 +170,97 @@ MergeSaveTest 88건 통과. 빌드 경고 0 · 오류 0. 설정 파일은 바이 MergeSaveTest 93건 통과. 빌드 경고 0 · 오류 0. 설정 파일은 바이트 그대로 되돌림. +## C-2. rebase · cherry-pick 충돌을 네 칸으로 끝까지 (2026-09-15, 완료) + +### 준비 + +시험 저장소(`C1Repo`)를 그대로 썼다. `feature/mine`(내 쪽 작업) · `feature/theirs`(상대 쪽 작업)가 같은 바탕에서 커밋 하나씩이라 +**rebase**(`feature/mine` 을 `feature/theirs` 위로)도 **cherry-pick**(상대 쪽 작업을 `feature/mine` 에)도 병합과 같은 26개 충돌을 낸다. +지시서는 생성기에 갈래를 더하라고 했지만 필요 없어서 바꾸지 않았다. + +### 고치기 전에 다시 났다 + +**rebase** (Git 탭 [선택 브랜치 위로 rebase]) + +| # | 무엇 | 근거 | +|---|---|---| +| R1 | 네 칸 제목이 두 쪽을 말하지 않는다. rebase 에서는 LOCAL 이 **올릴 자리(`feature/theirs`)**, REMOTE 가 **다시 올리는 내 커밋**인데 | LOCAL `feature/theirs` · REMOTE **`REMOTE · THEIRS`** · 머리 글 **`병합 충돌 · REMOTE = THEIRS`** | +| R2 | Take Theirs 가 내 커밋 줄을 가져오는데 화면은 그 사정을 모른다 | `Common/Util/Unit15.cs` Take Theirs → `feature/mine` 에만 있는 줄 1/1 들어감 | +| R3 | 로그가 코드 이름을 적는다 | `rebase 1/1 단계에서 충돌 26건 - 해결 후 RebaseContinue() 를 호출하세요.` | +| R4 | 머지 주석 자동 풀기가 안 걸린다 | 충돌 26(병합은 24) | +| - | 끝내는 길은 됐다 | 다 풀자 [계속] 켜짐 · [병합 커밋] 꺼짐, [계속] → `rebase 완료 (1/1 단계)`, 이력 `내 쪽 작업` 이 `상대 쪽 작업` 위에 | + +**cherry-pick** + +| # | 무엇 | 근거 | +|---|---|---| +| C1 | **다른 브랜치의 커밋을 고를 수 없다.** Git 탭 이력은 HEAD 에서 닿는 커밋만 보인다 | 이력 목록에 `상대 쪽 작업` 없음. `GetCommits` 가 시작점 없이 `CommitFilter` 를 써서 libgit2 기본(HEAD)만 | +| C2 | 이미 있는 커밋(HEAD)을 cherry-pick 하면 `CHERRY_PICK_HEAD` 가 남는데 **진행 표시도 [작업 취소] 도 없다** — 빠져나올 길이 없다 | 로그 `cherry-pick 적용됨 - 확인 후 Commit() 을 호출하세요.` · 머리 표시 없음 · [작업 취소] 꺼짐 | +| C3 | 충돌 중 머리가 **"병합 진행 중"**, REMOTE `REMOTE · THEIRS`, 머리 글 `병합 충돌 · REMOTE = THEIRS` | git 으로 cherry-pick 을 시작해 앱으로 연 화면 | +| C4 | 충돌을 다 풀면 **진행 표시 · [작업 취소] · [병합 커밋] 이 모두 사라진다.** 끝내려면 변경 · 커밋 탭에서 메시지를 직접 쳐야 한다(원래 메시지 안 채워짐) | 다 푼 뒤 머리 표시 없음 · 작업 취소 꺼짐 · 병합 커밋 꺼짐. 메시지를 쳐서 [커밋] 하면 `CHERRY_PICK_HEAD` 는 치워짐 | + +### 원인 + +- 화면이 충돌을 모두 "병합" 으로만 다뤘다. 진행 중인지는 `IsMergeInProgress`(병합 표시 **또는 충돌 있음**)로만 봐서, cherry-pick 은 충돌이 0 이 되면 진행 중이 아니게 됐다. +- 상대 이름은 MERGE_HEAD 로만 찾았다(`GetMergeTheirsName`) — rebase · cherry-pick 에는 MERGE_HEAD 가 없어 "THEIRS". +- 머지 주석 자동 풀기 · 알림은 [Merge] · pull 에만 걸려 있었다(7차 C-1). + +### 고친 것 + +| 파일 | 무엇 | +|---|---| +| `Models/GitModels.cs` | `GitOperationKind`(None · Merge · Rebase · CherryPick · Revert) · `GitOperationState`(두 쪽 이름 · 들어오는 커밋 · 단계) | +| `Services/OperationLabels.cs` (새 파일) | 작업별 글 — 진행 표시(`rebase 1/1` · `cherry-pick 진행 중`), LOCAL 제목(rebase 는 `올릴 자리 feature/theirs`), REMOTE 제목(`다시 올리는 내 커밋 …` · `가져오는 커밋 …` · `되돌리는 커밋 …`), 네 칸 머리 글, 로그 이름, 끝내기 단추(병합 커밋 · 계속 · 커밋), 진행 줄(병합 글은 예전 그대로) | +| `Services/GitService.cs` | `GetOperationState` — git 작업 표시로 판단: rebase 는 `rebase-merge/onto` · `head-name` · 지금 단계 커밋, cherry-pick · revert 는 `CHERRY_PICK_HEAD` · `REVERT_HEAD`. `GetPendingCommitMessage`(원래 메시지, # 줄 뺌). `CommitPendingOperation` — cherry-pick 은 git 처럼 **원래 작성자**, 커밋한 사람은 지금 사용자. `GetCommits(allBranches)`. 로그의 `Commit()` · `RebaseContinue()` 문구를 [커밋] · [계속] 으로 | +| `Services/GitWorkspace.cs` | `OperationState` · `IsOperationInProgress`(충돌 수와 따로) · `ShowAllBranchesInHistory`. 진행 표시 · 진행 줄이 작업을 따른다 | +| `ViewModels/GitViewModel.cs` | 뒷일(`AfterConflicts`)을 병합 · pull · rebase 시작 · rebase 계속 · cherry-pick · revert 가 함께 쓴다 — 머지 주석 자동 풀기 · 알림("다 풀면 [계속] 으로 …") · cherry-pick · revert 메시지 채우기. `FinishOperationCommand` — 병합 커밋 / 계속 / 커밋. [작업 취소] 가 진행 중인 작업이면 켜짐 | +| `Views/GitView.xaml` | 충돌 탭 끝내기 단추 글자 · 명령이 작업을 따른다. 진행 줄은 `IsOperationInProgress` 로 보인다. 이력 탭에 **[모든 브랜치]** 체크(사용자 결정, 기본 꺼짐) | +| `ViewModels/HomeViewModel.cs` | 충돌 파일을 열 때 작업 상태로 LOCAL · REMOTE 제목 · 머리 글 · Take 로그 이름을 정한다. 병합 커밋 메시지 자동 채우기는 병합일 때만 | +| 하네스 `MergeSaveTest` | 작업별 글 검사 32건(진행 표시 · 칸 제목 · 머리 글 · 로그 이름 · 끝내기 단추 · 진행 줄, 병합 글이 예전과 같은지) | + +### 확인한 것 (모두 저장소를 새로 만들어 MergeHub 만으로) + +**① rebase** + +| 때 | 결과 | +|---|---| +| [선택 브랜치 위로 rebase] | 알림 **`충돌 24건 (머지 주석만 다른 2건은 자동으로 풀림) - … 다 풀면 [계속] 으로 rebase 를 이어 갑니다.`** · 로그 `… 충돌 26건 - 풀고 나서 [계속] 을 누르세요.` + 자동 해결 두 줄 | +| 충돌 중 | 머리 **`rebase 1/1`** · [작업 취소] 켜짐 · 진행 줄 `남은 충돌 24개` · 끝내기 단추 **[계속]**(꺼짐) | +| 네 칸 (`Unit15.cs`) | LOCAL **`올릴 자리 feature/theirs`** · REMOTE **`REMOTE · 다시 올리는 내 커밋 a0fead2 내 쪽 작업`** · 머리 글 `rebase 충돌 · LOCAL = 올릴 자리 feature/theirs · REMOTE = 다시 올리는 내 커밋 a0fead2` · "3-Way 안 씀" 없음 | +| Take Theirs → Save | 로그 **`10행 Theirs(커밋 a0fead2) 반영`** | +| 23개 파일 단위 Accept Theirs · `Io/LineEnding.cs` 는 네 칸에서 Take Theirs → Save | 진행 줄 **`충돌 정리 끝 - [계속] 으로 rebase 를 이어 갑니다`** · [계속] 켜짐 | +| [계속] | `rebase 완료 (1/1 단계)` · 진행 표시 · [작업 취소] 사라짐. 이력 `c1296a7 내 쪽 작업` ← `1bdc05b 상대 쪽 작업` ← 바탕 | +| 결과 커밋 (부모와 바이트로) | 바뀐 파일 32 · **줄끝 · BOM · 인코딩이 부모와 다른 파일 0** · 충돌 표식 0 | + +**② cherry-pick** + +| 때 | 결과 | +|---|---| +| 이력 [모든 브랜치] | 끄면 `상대 쪽 작업` 없음 → 켜면 보임, 골라 [Cherry-pick] | +| 시작 | 알림 `충돌 24건 (…) - … 다 풀면 [커밋] 으로 cherry-pick 을 끝냅니다.` · 로그 `cherry-pick 충돌 26건 - 풀고 나서 충돌 탭의 [커밋] 으로 끝냅니다.` + 자동 해결 | +| 충돌 중 | 머리 **`cherry-pick 진행 중`** · [작업 취소] 켜짐 · 끝내기 단추 **[커밋]**(꺼짐) | +| 네 칸 | LOCAL `feature/mine` · REMOTE **`REMOTE · 가져오는 커밋 0862180 상대 쪽 작업`** · 머리 글 `cherry-pick 충돌 · REMOTE = 가져오는 커밋 …` · 로그 `10행 Theirs(커밋 …) 반영` | +| 23개 Accept Theirs(24 → 1) · `LineEnding.cs` 네 칸 → Save | 진행 줄 **`충돌 정리 끝 - [커밋] 으로 cherry-pick 을 끝냅니다`** · [커밋] 켜짐 · 진행 표시 그대로 | +| [커밋] (메시지 칸 비움) | `cherry-pick 커밋: ebe2a64 상대 쪽 작업` — **원래 메시지**, 부모 하나, `CHERRY_PICK_HEAD` 없어짐, 진행 표시 · [작업 취소] 사라짐 | +| 결과 커밋 | 바뀐 파일 32 · **줄끝 · BOM · 인코딩이 부모와 다른 파일 0** · 충돌 표식 0 · `LineEnding.cs` BOM · CRLF 그대로 | + +**③ 바뀌는 것 없는 cherry-pick (HEAD 커밋을 다시)** + +| 때 | 결과 | +|---|---| +| [Cherry-pick] | `CHERRY_PICK_HEAD` 생김 · 머리 **`cherry-pick 진행 중`** · **[작업 취소] 켜짐** (예전에는 둘 다 없었다) | +| [작업 취소] | `CHERRY_PICK_HEAD` 없어짐 · 표시 사라짐 | + +MergeSaveTest 125건 · EncTest 48 · FolderTest 62 · ReportTest 31 · SearchTest 63 통과(`GitModels.cs` 를 함께 컴파일). 빌드 경고 0 · 오류 0. 설정 파일은 바이트 그대로 되돌림. + +### 확인하며 알게 된 것 · 남긴 것 + +- **파일 단위 Accept 는 고른 쪽 파일을 바이트째 쓴다.** 상대가 줄끝을 CRLF → LF 로 바꾼 `Io/LineEnding.cs` 를 파일 단위로 Accept Theirs 하면 그 줄끝이 그대로 들어온다(처음 확인에서 1개로 잡힘). 결함이 아니라 고른 대로다 — 6차 D-1 처럼 이런 파일은 네 칸에서 풀면 내 쪽(rebase 는 올릴 자리) 줄끝을 따른다. +- **원래 작성자 남기기는 이 저장소로 구별되지 않았다.** 시험 저장소의 모든 커밋 작성자가 같다(`시험`). 코드로만 확인했다. +- **③ 에서 [커밋] 도 켜진다.** 바뀐 것이 없어 누르면 빈 커밋 오류가 날 것으로 보인다. 로그 `적용됨 - 확인한 뒤 [커밋] 으로 끝냅니다` 도 이 경우엔 맞지 않다. 고치지 않고 적어 둔다(D-1 에서 다시 본다). +- revert 충돌은 같은 길(`AfterConflicts` · `CommitPendingOperation`)을 쓰지만 화면으로 끝까지는 안 봤다. +- 자동 조작 쪽: cherry-pick 첫 확인에서 [모든 브랜치] 를 켠 직후 목록 선택이 앱에 안 닿아 Accept 가 한 개만 풀렸다. 기다림을 넣고 다시 해 24 → 1 로 확인했다. + ## E-2. 폴더 비교 탭에 폴더를 하나씩 끌어다 놓으면 첫 칸 → 둘째 칸 차례로 (2026-09-14, 추가 요청, 완료) 사용자: "폴더비교 탭 첫번째 드래그 첫번째 폴더 경로에 들어가고 두번째는 드래그앤드롭은 두번째로 들어가게해줘 경로텍스트 박스에 안넣어도 그냥 화면에다 넣어둬 기능 동작하도록". diff --git a/doc/history/changelog.md b/doc/history/changelog.md index 3a882c7..50feeb6 100644 --- a/doc/history/changelog.md +++ b/doc/history/changelog.md @@ -8,6 +8,7 @@ | 날짜 | 항목 | 커밋 | |---|---|---| +| 2026-09-15 | **7차 C-2** — rebase · cherry-pick 충돌을 화면이 모두 "병합" 으로 불렀다(REMOTE · THEIRS, cherry-pick 에 "병합 진행 중", 다 풀면 끝낼 단추가 없음, 로그에 `RebaseContinue()` · `Commit()`, 다른 브랜치 커밋을 cherry-pick 할 수 없음). 작업 상태(`GitOperationState`)로 칸 제목 · 머리 글 · 진행 표시 · 끝내기 단추(병합 커밋 · 계속 · 커밋)를 정하고, cherry-pick · revert 는 원래 메시지 · 작성자로 커밋. 머지 주석 자동 풀기도 함께. 이력 탭 [모든 브랜치]. 두 길 모두 MergeHub 만으로 끝까지, 줄끝 바뀐 파일 0 | 아래 | | 2026-09-14 | **7차 지시서에 E-4 추가** — 사용자 요청 "창 크기를 마음대로 조절". 지금은 셸이 최대화 전용(테두리 없음 · 크기 고정 · 끌면 다시 최대화). 걸리는 곳(크기 조절 테두리 · 배율 다른 모니터 · 크기 기억 · MergeHub 고정 폭과 네 칸 정렬 · 모든 소비 프로그램)과 판정 기준을 적음. 코드 변경 없음 | 아래 | | 2026-09-14 | **7차 C-1** — pull 로 받은 충돌은 머지 주석만 다른 파일까지 사람에게 물었고(26 · 0/26), 알림도 커밋 메시지도 없었다. [Merge] 의 뒷일을 `AfterMerge` 로 빼 pull 도 같게(24 · 2/26 · 같은 알림). pull 병합 커밋 메시지는 git 과 같은 "Merge remote-tracking branch 'origin/feature/mine' into feature/mine" | 아래 | | 2026-09-14 | **7차 B-1** — 병합 때 적는 처음 충돌 목록(`.git/MERGEHUB_CONFLICTS`)이 병합 커밋 · 작업 취소 · 터미널에서 끝낸 병합 뒤에도 남았다. MERGE_HEAD 가 없거나 다른 병합의 것이면 병합 커밋 · 작업 취소 직후와 상태를 다시 읽을 때마다 지운다. 병합 중에는 그대로라 "푼 것 M개" 는 이어진다 | 아래 | diff --git a/doc/진행상황.md b/doc/진행상황.md index 9fc531a..a013429 100644 --- a/doc/진행상황.md +++ b/doc/진행상황.md @@ -164,7 +164,7 @@ | 상태 | 항목 | |---|---| | **완료** | C-1 pull 이 낸 충돌에는 머지 주석 자동 풀기가 안 걸린다 — pull 도 [Merge] 와 같은 뒷일(주석 자동 풀기 · 알림 · 진행 줄), 커밋 메시지 "Merge remote-tracking branch 'origin/…' into …" | -| 대기 | C-2 rebase · cherry-pick 충돌을 네 칸으로 끝까지 | +| **완료** | C-2 rebase · cherry-pick 충돌을 네 칸으로 끝까지 — 작업별 칸 제목(올릴 자리 · 다시 올리는 내 커밋 · 가져오는 커밋) · 진행 표시 · 끝내기 단추(병합 커밋 · 계속 · 커밋) · 원래 메시지, 이력 [모든 브랜치]. 두 길 모두 줄끝 바뀐 파일 0 | ### D. 확인 diff --git a/src/MergeHub/Models/GitModels.cs b/src/MergeHub/Models/GitModels.cs index 62e4bd0..a9084b0 100644 --- a/src/MergeHub/Models/GitModels.cs +++ b/src/MergeHub/Models/GitModels.cs @@ -114,6 +114,42 @@ namespace MergeHub.Models Both } + /// 지금 저장소에서 진행 중인 git 작업의 종류(7차 C-2). + public enum GitOperationKind + { + None, + Merge, + Rebase, + CherryPick, + Revert + } + + /// + /// 진행 중인 작업과 두 쪽의 이름(7차 C-2). 충돌을 풀 때 "누가 LOCAL 이고 누가 REMOTE 인지" 가 작업마다 다르다. + /// + /// 병합 · cherry-pick · revert: ours = 내 브랜치, theirs = 들어오는 브랜치 · 커밋 + /// rebase: ours = 올릴 자리(onto), theirs = 다시 올리는 내 커밋 — 병합과 거꾸로다 + /// + /// + /// 병합 · cherry-pick · revert 는 내 브랜치, rebase 는 올릴 자리 브랜치 이름. + /// 병합은 상대 브랜치, rebase 는 다시 올리는 브랜치 이름. + /// 병합은 MERGE_HEAD, rebase 는 지금 올리는 커밋, cherry-pick · revert 는 그 커밋. + /// 그 커밋의 한 줄 메시지. + public sealed record GitOperationState( + GitOperationKind Kind, + string? OursName, + string? TheirsName, + string? TheirsSha, + string? TheirsMessage, + int Step, + int TotalSteps) + { + public static readonly GitOperationState None = + new GitOperationState(GitOperationKind.None, null, null, null, null, 0, 0); + + public bool IsInProgress => Kind != GitOperationKind.None; + } + public enum MergeOutcome { /// 이미 최신 - 병합할 것이 없음. diff --git a/src/MergeHub/Services/GitService.cs b/src/MergeHub/Services/GitService.cs index ff41215..76f13e5 100644 --- a/src/MergeHub/Services/GitService.cs +++ b/src/MergeHub/Services/GitService.cs @@ -287,7 +287,7 @@ namespace MergeHub.Services // ================= 히스토리 ================= - public IReadOnlyList GetCommits(string? committish = null, int maxCount = 200) + public IReadOnlyList GetCommits(string? committish = null, int maxCount = 200, bool allBranches = false) { var refsByTarget = new Dictionary>(StringComparer.Ordinal); foreach (var branch in Repo.Branches) @@ -318,6 +318,14 @@ namespace MergeHub.Services ?? throw new ArgumentException($"커밋/브랜치를 찾을 수 없습니다: {committish}"); filter.IncludeReachableFrom = start; } + else if (allBranches) + { + // 모든 브랜치(원격 추적 포함)와 HEAD 에서 닿는 커밋. 다른 브랜치의 커밋을 골라 cherry-pick 하려면 필요하다(7차 C-2). + // 넣지 않으면 libgit2 는 HEAD 에서 닿는 것만 준다. + var tips = Repo.Branches.Select(b => b.Tip).Where(t => t is not null).ToList(); + if (Repo.Head?.Tip is { } headTip) tips.Add(headTip); + if (tips.Count > 0) filter.IncludeReachableFrom = tips; + } var result = new List(Math.Min(maxCount, 256)); foreach (var c in Repo.Commits.QueryBy(filter)) @@ -639,10 +647,11 @@ namespace MergeHub.Services var message = outcome switch { + // 사람이 읽는 로그다 - 예전에는 "Commit() 을 호출하세요" 처럼 코드 이름을 적었다(7차 C-2). PatchApplyOutcome.Applied => commitOnSuccess ? $"{operation} 완료 ({Shorten(sha)})" - : $"{operation} 적용됨 - 확인 후 Commit() 을 호출하세요.", - PatchApplyOutcome.Conflicts => $"{operation} 충돌 {conflicts.Count}건 - 해결이 필요합니다.", + : $"{operation} 적용됨 - 확인한 뒤 충돌 탭의 [커밋] 으로 끝냅니다.", + PatchApplyOutcome.Conflicts => $"{operation} 충돌 {conflicts.Count}건 - 풀고 나서 충돌 탭의 [커밋] 으로 끝냅니다.", PatchApplyOutcome.NothingToDo => $"{operation} 할 변경이 없습니다.", _ => $"{operation} 실패" }; @@ -729,7 +738,7 @@ namespace MergeHub.Services RebaseOutcome.NothingToDo => "재배치할 커밋이 없습니다.", RebaseOutcome.Conflicts => $"rebase {result.CompletedStepCount + 1}/{result.TotalStepCount} 단계에서 충돌 {conflicts.Count}건 " + - "- 해결 후 RebaseContinue() 를 호출하세요.", + "- 풀고 나서 [계속] 을 누르세요.", RebaseOutcome.Stopped => $"rebase 정지 ({result.CompletedStepCount}/{result.TotalStepCount} 단계)", _ => "rebase 실패" }; @@ -1066,6 +1075,124 @@ namespace MergeHub.Services return null; } + public GitOperationState GetOperationState() + { + var head = Repo.Head; + + GitOperationState Picked(GitOperationKind kind, string headFile) + { + var sha = ReadFirstShaLine(Path.Combine(Repo.Info.Path, headFile)); + var commit = sha is null ? null : Repo.Lookup(sha); + return new GitOperationState(kind, head?.FriendlyName, null, commit?.Sha ?? sha, commit?.MessageShort, 0, 0); + } + + switch (Repo.Info.CurrentOperation) + { + case CurrentOperation.Rebase: + case CurrentOperation.RebaseInteractive: + case CurrentOperation.RebaseMerge: + { + // rebase 는 병합과 두 쪽이 거꾸로다: ours = 올릴 자리(onto), theirs = 다시 올리는 내 커밋. + var dir = Path.Combine(Repo.Info.Path, "rebase-merge"); + var onto = ReadFirstShaLine(Path.Combine(dir, "onto")); + var headName = File.Exists(Path.Combine(dir, "head-name")) + ? File.ReadAllText(Path.Combine(dir, "head-name")).Trim() + : null; + var step = Repo.Rebase.GetCurrentStepInfo(); + return new GitOperationState( + GitOperationKind.Rebase, + OursName: BranchNameAt(onto) ?? (onto is null ? null : Shorten(onto)), + TheirsName: headName is not null && headName.StartsWith("refs/heads/", StringComparison.Ordinal) + ? headName.Substring("refs/heads/".Length) + : headName, + TheirsSha: step?.Commit?.Sha, + TheirsMessage: step?.Commit?.MessageShort, + Step: (int)(Repo.Rebase.GetCurrentStepIndex() + 1), + TotalSteps: (int)Repo.Rebase.GetTotalStepCount()); + } + case CurrentOperation.CherryPick: + case CurrentOperation.CherryPickSequence: + return Picked(GitOperationKind.CherryPick, "CHERRY_PICK_HEAD"); + case CurrentOperation.Revert: + case CurrentOperation.RevertSequence: + return Picked(GitOperationKind.Revert, "REVERT_HEAD"); + case CurrentOperation.Merge: + return new GitOperationState(GitOperationKind.Merge, head?.FriendlyName, GetMergeTheirsName(), + ReadMergeHeadSha(), null, 0, 0); + default: + // 작업 표시 없이 충돌만 남은 경우(stash 적용 등)는 지금까지처럼 병합으로 본다. + return Repo.Index.Conflicts.Any() + ? new GitOperationState(GitOperationKind.Merge, head?.FriendlyName, null, null, null, 0, 0) + : GitOperationState.None; + } + } + + /// 파일 첫 줄의 40자 해시. 없으면 null. + private static string? ReadFirstShaLine(string path) + { + if (!File.Exists(path)) return null; + foreach (var line in File.ReadAllLines(path)) + { + var sha = line.Trim(); + if (sha.Length >= 40) return sha.Substring(0, 40); + } + return null; + } + + /// 그 커밋을 끝으로 가리키는 브랜치 이름. 로컬을 먼저 본다. + private string? BranchNameAt(string? sha) + { + if (sha is null) return null; + return Repo.Branches.Where(b => !b.IsRemote && b.Tip?.Sha == sha).Select(b => b.FriendlyName).FirstOrDefault() + ?? Repo.Branches.Where(b => b.IsRemote && b.Tip?.Sha == sha).Select(b => b.FriendlyName).FirstOrDefault(); + } + + public string? GetPendingCommitMessage() + { + var state = GetOperationState(); + if (state.Kind is not (GitOperationKind.CherryPick or GitOperationKind.Revert)) return null; + + // libgit2 가 남긴 MERGE_MSG(원래 메시지)가 있으면 # 줄을 빼고 쓴다. 없으면 그 커밋에서 만든다. + var raw = ReadMergeMessage(); + if (raw is not null) + { + var cleaned = string.Join("\n", raw.Replace("\r\n", "\n").Split('\n') + .Where(l => !l.TrimStart().StartsWith("#", StringComparison.Ordinal))).Trim(); + if (cleaned.Length > 0) return cleaned; + } + + var commit = state.TheirsSha is null ? null : Repo.Lookup(state.TheirsSha); + if (commit is null) return null; + return state.Kind == GitOperationKind.CherryPick + ? commit.Message.TrimEnd() + : $"Revert \"{commit.MessageShort}\"\n\nThis reverts commit {commit.Sha}."; + } + + public string CommitPendingOperation(string? message = null) + { + var state = GetOperationState(); + if (state.Kind is not (GitOperationKind.CherryPick or GitOperationKind.Revert)) + return CommitMerge(message); + + var remaining = GetConflicts(); + if (remaining.Count > 0) + throw new InvalidOperationException($"아직 해결되지 않은 충돌이 {remaining.Count}건 있습니다."); + + if (string.IsNullOrWhiteSpace(message)) + message = GetPendingCommitMessage() ?? (state.Kind == GitOperationKind.CherryPick ? "cherry-pick" : "revert"); + + // cherry-pick 은 git 처럼 원래 작성자를 남기고, 커밋한 사람은 지금 사용자로 둔다. + var committer = BuildSignature(); + var author = committer; + if (state.Kind == GitOperationKind.CherryPick && state.TheirsSha is { } sha && Repo.Lookup(sha) is { } picked) + author = new Signature(picked.Author.Name, picked.Author.Email, picked.Author.When); + + var commit = Repo.Commit(message, author, committer); + ForgetStaleMergeConflicts(); + Report($"{(state.Kind == GitOperationKind.CherryPick ? "cherry-pick" : "revert")} 커밋: {Shorten(commit.Sha)} {commit.MessageShort}"); + return commit.Sha; + } + public string? GetMergeTheirsName() { if (Repo.Info.CurrentOperation != CurrentOperation.Merge && !Repo.Index.Conflicts.Any()) return null; diff --git a/src/MergeHub/Services/GitWorkspace.cs b/src/MergeHub/Services/GitWorkspace.cs index 5e28b2b..56f4679 100644 --- a/src/MergeHub/Services/GitWorkspace.cs +++ b/src/MergeHub/Services/GitWorkspace.cs @@ -187,21 +187,28 @@ namespace MergeHub.Services /// 처음 충돌 수는 병합할 때 .git 에 적어 두므로 앱을 다시 켜도 이어진다. /// MergeHub 밖에서 시작한 병합이라 처음 수를 모르면 남은 수만 적는다. /// - public string ConflictProgressText + public string ConflictProgressText => + // 작업마다 다 푼 뒤 할 일이 다르다 - 병합 커밋 · [계속] · [커밋](7차 C-2). 병합의 글은 예전과 같다. + OperationLabels.ProgressText(OperationState.Kind, Conflicts.Count, MergeConflictTotal); + + /// 진행 중인 git 작업과 두 쪽 이름(7차 C-2). 상태를 다시 읽을 때마다 갱신한다. + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsOperationInProgress))] + [NotifyPropertyChangedFor(nameof(ConflictProgressText))] + private GitOperationState _operationState = GitOperationState.None; + + /// + /// 병합 · rebase · cherry-pick · revert 중 무엇이든 진행 중인지. 충돌 수와 따로 본다 - + /// 예전에는 충돌이 0 이 되면 cherry-pick 이 진행 중이라는 표시도 [작업 취소] 도 사라졌다(7차 C-2). + /// + public bool IsOperationInProgress => OperationState.IsInProgress; + + /// 이력에 모든 브랜치의 커밋을 보일지(7차 C-2). 다른 브랜치 커밋을 골라 cherry-pick 할 때 켠다. + [ObservableProperty] private bool _showAllBranchesInHistory; + + partial void OnShowAllBranchesInHistoryChanged(bool value) { - get - { - if (!IsMergeInProgress) return string.Empty; - - var left = Conflicts.Count; - if (MergeConflictTotal is not int total || total < left) - return left == 0 ? "충돌 정리 끝 - 병합 커밋을 할 수 있습니다" : $"남은 충돌 {left}개"; - - var done = total - left; - return left == 0 - ? $"충돌 정리 끝 · 푼 것 {done}개 - 병합 커밋을 할 수 있습니다" - : $"남은 충돌 {left}개 · 푼 것 {done}개 ({done}/{total})"; - } + if (IsRepositoryOpen) _ = RefreshAsync(); } [ObservableProperty] private bool _isRebaseInProgress; [ObservableProperty] private string _pendingOperationText = string.Empty; @@ -340,7 +347,9 @@ namespace MergeHub.Services var info = Git.GetRepositoryInfo(); var branches = Git.GetBranches(); - var commits = Git.GetCommits(maxCount: Settings.CommitPageSize <= 0 ? 200 : Settings.CommitPageSize); + var commits = Git.GetCommits(maxCount: Settings.CommitPageSize <= 0 ? 200 : Settings.CommitPageSize, + allBranches: ShowAllBranchesInHistory); + var operation = Git.GetOperationState(); var nodes = CommitGraphBuilder.Build(commits); var status = Git.GetStatus(); var conflicts = Git.GetConflicts(); @@ -373,9 +382,9 @@ namespace MergeHub.Services IsMergeInProgress = mergeInProgress; IsRebaseInProgress = rebase.IsInProgress; - PendingOperationText = - rebase.IsInProgress ? $"rebase {rebase.CurrentStep}/{rebase.TotalSteps}" : - mergeInProgress ? "병합 진행 중" : string.Empty; + OperationState = operation; + // cherry-pick · revert 도 "병합 진행 중" 이라고 했다. 작업 이름으로 적는다(7차 C-2). + PendingOperationText = OperationLabels.PendingText(operation); IsWorkingTreeClean = status.IsClean; WorkingTreeSummary = status.IsClean ? "Clean" : $"{status.Files.Count} files"; diff --git a/src/MergeHub/Services/IGitService.cs b/src/MergeHub/Services/IGitService.cs index fa93730..62f3359 100644 --- a/src/MergeHub/Services/IGitService.cs +++ b/src/MergeHub/Services/IGitService.cs @@ -49,7 +49,8 @@ namespace MergeHub.Services void Push(string? branchName = null, string remoteName = "origin"); // ---- 히스토리 / 조회 ---------------------------------------------- - IReadOnlyList GetCommits(string? committish = null, int maxCount = 200); + /// committish 가 없을 때 모든 브랜치에서 닿는 커밋까지(7차 C-2). 아니면 HEAD 에서 닿는 것만. + IReadOnlyList GetCommits(string? committish = null, int maxCount = 200, bool allBranches = false); CommitInfo? GetCommit(string committish); string? GetMergeBaseSha(string committishA, string committishB); @@ -94,6 +95,15 @@ namespace MergeHub.Services /// string? GetMergeTheirsName(); + /// 진행 중인 작업(병합 · rebase · cherry-pick · revert)과 두 쪽 이름(7차 C-2). + GitOperationState GetOperationState(); + + /// cherry-pick · revert 를 끝낼 커밋의 기본 메시지(원래 메시지). 그 작업이 아니면 null(7차 C-2). + string? GetPendingCommitMessage(); + + /// 충돌을 다 푼 cherry-pick · revert 를 커밋해 끝낸다. 병합이면 와 같다(7차 C-2). + string CommitPendingOperation(string? message = null); + /// Accept Mine / Theirs / Both 버튼 처리. 파일에 기록하고 인덱스에서 충돌 해제. void ResolveConflict(string path, ConflictResolution resolution); diff --git a/src/MergeHub/Services/OperationLabels.cs b/src/MergeHub/Services/OperationLabels.cs new file mode 100644 index 0000000..b4aaf1b --- /dev/null +++ b/src/MergeHub/Services/OperationLabels.cs @@ -0,0 +1,114 @@ +using MergeHub.Models; + +namespace MergeHub.Services +{ + /// + /// 진행 중인 git 작업(병합 · rebase · cherry-pick · revert)을 화면이 사람 말로 부르는 글(7차 C-2). + /// + /// 예전에는 모든 충돌을 "병합" 으로만 말했다. rebase 에서는 LOCAL 이 올릴 자리(상대 브랜치)이고 REMOTE 가 + /// 내 커밋인데도 "REMOTE · THEIRS" 라고 했고, cherry-pick 은 머리에 "병합 진행 중" 이 떴다. + /// 충돌을 다 푼 뒤 할 일도 작업마다 다르다 — 병합은 [병합 커밋], rebase 는 [계속], cherry-pick · revert 는 [커밋]. + /// 저장소 없이 검사하도록 글만 여기 모았다. + /// + public static class OperationLabels + { + private static string Short(string? sha) + { + if (string.IsNullOrWhiteSpace(sha)) return string.Empty; + var t = sha!.Trim(); + return t.Length > 7 ? t.Substring(0, 7) : t; + } + + private static string CommitRef(GitOperationState s) + { + var sha = Short(s.TheirsSha); + var msg = string.IsNullOrWhiteSpace(s.TheirsMessage) ? string.Empty : s.TheirsMessage!.Trim(); + if (sha.Length == 0) return msg; + return msg.Length == 0 ? sha : $"{sha} {msg}"; + } + + private static string Onto(GitOperationState s) => + string.IsNullOrWhiteSpace(s.OursName) ? "(올릴 자리)" : s.OursName!.Trim(); + + /// 머리의 진행 표시. 진행 중이 아니면 빈 글. + public static string PendingText(GitOperationState s) => s.Kind switch + { + GitOperationKind.Merge => "병합 진행 중", + GitOperationKind.Rebase => s.TotalSteps > 0 ? $"rebase {s.Step}/{s.TotalSteps}" : "rebase 진행 중", + GitOperationKind.CherryPick => "cherry-pick 진행 중", + GitOperationKind.Revert => "revert 진행 중", + _ => string.Empty + }; + + /// 네 칸의 LOCAL 칸 제목. rebase 는 올릴 자리, 그 밖은 내 브랜치. + public static string OursTitle(GitOperationState s, string? currentBranch) => + s.Kind == GitOperationKind.Rebase + ? $"올릴 자리 {Onto(s)}" + : !string.IsNullOrWhiteSpace(currentBranch) ? currentBranch!.Trim() : s.OursName ?? "LOCAL"; + + /// 네 칸의 REMOTE 칸 제목("REMOTE · " 뒤). + public static string TheirsTitle(GitOperationState s) => s.Kind switch + { + GitOperationKind.Rebase => $"다시 올리는 내 커밋 {CommitRef(s)}".TrimEnd(), + GitOperationKind.CherryPick => $"가져오는 커밋 {CommitRef(s)}".TrimEnd(), + GitOperationKind.Revert => $"되돌리는 커밋 {CommitRef(s)}".TrimEnd(), + _ => string.IsNullOrWhiteSpace(s.TheirsName) ? "THEIRS" : s.TheirsName!.Trim() + }; + + /// 충돌 네 칸일 때 3-Way 콤보 자리에 보이는 글(7차 A-2 의 "병합 충돌 · REMOTE = …" 을 작업별로). + public static string HeaderText(GitOperationState s) => s.Kind switch + { + GitOperationKind.Rebase => $"rebase 충돌 · LOCAL = 올릴 자리 {Onto(s)} · REMOTE = 다시 올리는 내 커밋 {Short(s.TheirsSha)}".TrimEnd(), + GitOperationKind.CherryPick => $"cherry-pick 충돌 · REMOTE = 가져오는 커밋 {Short(s.TheirsSha)}".TrimEnd(), + GitOperationKind.Revert => $"revert 충돌 · REMOTE = 되돌리는 커밋 {Short(s.TheirsSha)}".TrimEnd(), + _ => $"병합 충돌 · REMOTE = {(string.IsNullOrWhiteSpace(s.TheirsName) ? "THEIRS" : s.TheirsName!.Trim())}" + }; + + /// Take Theirs 로그의 괄호 안 이름. 병합은 브랜치, 그 밖은 "커밋 해시". + public static string? TheirsLogName(GitOperationState s) + { + if (s.Kind is GitOperationKind.Rebase or GitOperationKind.CherryPick or GitOperationKind.Revert) + return Short(s.TheirsSha) is { Length: > 0 } sha ? $"커밋 {sha}" : null; + return string.IsNullOrWhiteSpace(s.TheirsName) ? null : s.TheirsName!.Trim(); + } + + /// Take Mine 로그의 괄호 안 이름. rebase 는 올릴 자리, 그 밖은 내 브랜치. + public static string? MineLogName(GitOperationState s, string? currentBranch) => + s.Kind == GitOperationKind.Rebase ? s.OursName : currentBranch; + + /// 충돌을 다 푼 뒤 할 일. + public static string FinishHint(GitOperationKind kind) => kind switch + { + GitOperationKind.Rebase => "[계속] 으로 rebase 를 이어 갑니다", + GitOperationKind.CherryPick => "[커밋] 으로 cherry-pick 을 끝냅니다", + GitOperationKind.Revert => "[커밋] 으로 revert 를 끝냅니다", + _ => "병합 커밋을 할 수 있습니다" + }; + + /// 충돌 탭의 끝내기 단추 글자. + public static string FinishButtonLabel(GitOperationKind kind) => kind switch + { + GitOperationKind.Rebase => "계속", + GitOperationKind.CherryPick or GitOperationKind.Revert => "커밋", + _ => "병합 커밋" + }; + + /// + /// 충돌 탭 목록 위의 진행 한 줄(6차 C-1 을 작업별로). 처음 충돌 수를 모르면 남은 수만 적는다. + /// 병합의 글은 예전과 같다. + /// + public static string ProgressText(GitOperationKind kind, int left, int? total) + { + if (kind == GitOperationKind.None) return string.Empty; + + var finish = FinishHint(kind); + if (total is not int t || t < left) + return left == 0 ? $"충돌 정리 끝 - {finish}" : $"남은 충돌 {left}개"; + + var done = t - left; + return left == 0 + ? $"충돌 정리 끝 · 푼 것 {done}개 - {finish}" + : $"남은 충돌 {left}개 · 푼 것 {done}개 ({done}/{t})"; + } + } +} diff --git a/src/MergeHub/ViewModels/GitViewModel.cs b/src/MergeHub/ViewModels/GitViewModel.cs index b3d8d25..ea54e94 100644 --- a/src/MergeHub/ViewModels/GitViewModel.cs +++ b/src/MergeHub/ViewModels/GitViewModel.cs @@ -245,43 +245,83 @@ namespace MergeHub.ViewModels /// 병합(또는 pull) 결과를 받은 뒤 할 일. 작업 스레드에서 부른다(7차 C-1 에서 [Merge] 와 pull 이 함께 쓰게 뺐다). /// /// 커밋 메시지 칸이 비어 있고 병합 커밋이 남았을 때 채울 메시지. - private void AfterMerge(MergeResultInfo result, string defaultMessage) + private void AfterMerge(MergeResultInfo result, string defaultMessage) => + // 병합 커밋이 남는 경우에만 메시지를 채운다 - 이미 최신이거나 앞으로 감기면 커밋할 병합이 없다. + AfterConflicts(result.HasConflicts, result.Conflicts.Count, result.Message, + result.HasConflicts || result.Outcome == MergeOutcome.Merged ? defaultMessage : null, + GitOperationKind.Merge); + + /// + /// 충돌이 날 수 있는 작업(병합 · pull · rebase · cherry-pick · revert) 뒤에 할 일. 작업 스레드에서 부른다. + /// 7차 C-2 에서 rebase · cherry-pick · revert 도 같은 길을 쓰게 했다 - 머지 주석 자동 풀기 · 알림 · 커밋 메시지. + /// + /// 커밋 메시지 칸이 비어 있으면 채울 글. 없으면 null. + private void AfterConflicts(bool hasConflicts, int conflictCount, string resultMessage, string? fillMessage, GitOperationKind kind) { // 머지 주석만 다른 충돌은 사람에게 묻지 않고 푼다. 양쪽 주석을 다 남긴다(6차 B-5). - IReadOnlyList noteOnly = result.HasConflicts && _workspace.Settings.IgnoreMergeNotes + IReadOnlyList noteOnly = hasConflicts && _workspace.Settings.IgnoreMergeNotes ? _workspace.Git.ResolveNoteOnlyConflicts() : Array.Empty(); - var remaining = noteOnly.Count > 0 ? _workspace.Git.GetConflicts().Count : result.Conflicts.Count; + var remaining = noteOnly.Count > 0 ? _workspace.Git.GetConflicts().Count : conflictCount; + var finish = OperationLabels.FinishHint(kind); _workspace.OnUi(() => { - // result.Message("충돌 N건 - 해결이 필요합니다.")는 GitService 가 이미 로그에 적었다(Progress). - // 여기서 또 적으면 두 번 찍히고, 자동 해결 줄 뒤에 다시 나와 순서도 헷갈렸다(6차 C-1). + // 결과 문장은 GitService 가 이미 로그에 적었다(Progress). 여기서 또 적으면 두 번 찍힌다(6차 C-1). if (noteOnly.Count > 0) _workspace.AppendLog($"머지 주석만 다른 충돌 {noteOnly.Count}개 파일은 자동으로 풀었습니다. 남은 충돌 {remaining}건."); - // 병합 커밋이 남는 경우에만 채운다 - 이미 최신이거나 앞으로 감기면 커밋할 병합이 없다. - if (string.IsNullOrWhiteSpace(CommitMessage) && - (result.HasConflicts || result.Outcome == MergeOutcome.Merged)) - CommitMessage = defaultMessage; + if (string.IsNullOrWhiteSpace(CommitMessage) && !string.IsNullOrWhiteSpace(fillMessage)) + CommitMessage = fillMessage!; var autoNote = noteOnly.Count > 0 ? $" (머지 주석만 다른 {noteOnly.Count}건은 자동으로 풀림)" : string.Empty; + var then = kind == GitOperationKind.Merge ? string.Empty : $" 다 풀면 {finish}."; - if (result.HasConflicts && remaining > 0) - MessageUtil.ShowWarningToast($"충돌 {remaining}건{autoNote} - 충돌 탭에서 파일을 두 번 누르면 파일 비교 탭에서 세 칸으로 풉니다."); - else if (result.HasConflicts) - MessageUtil.ShowSuccessToast($"머지 주석만 다른 충돌 {noteOnly.Count}건을 자동으로 풀었습니다 - 병합 커밋을 할 수 있습니다."); + if (hasConflicts && remaining > 0) + MessageUtil.ShowWarningToast($"충돌 {remaining}건{autoNote} - 충돌 탭에서 파일을 두 번 누르면 파일 비교 탭에서 세 칸으로 풉니다.{then}"); + else if (hasConflicts) + MessageUtil.ShowSuccessToast($"머지 주석만 다른 충돌 {noteOnly.Count}건을 자동으로 풀었습니다 - {finish}."); else - MessageUtil.ShowToast(result.Message); + MessageUtil.ShowToast(resultMessage); }); } - private bool CanAbort() => _workspace.IsMergeInProgress || _workspace.IsRebaseInProgress; + private bool CanAbort() => + _workspace.IsOperationInProgress || _workspace.IsMergeInProgress || _workspace.IsRebaseInProgress; [RelayCommand(CanExecute = nameof(CanAbort))] private Task AbortAsync() => _workspace.RunAndRefreshAsync("작업 취소", () => _workspace.Git.AbortOperation()); + /// 충돌 탭의 끝내기 단추 글자 — 병합 커밋 · 계속 · 커밋(7차 C-2). + public string FinishOperationLabel => OperationLabels.FinishButtonLabel(_workspace.OperationState.Kind); + + private bool CanFinishOperation() => _workspace.OperationState.IsInProgress && _workspace.Conflicts.Count == 0; + + /// + /// 충돌을 다 푼 작업을 끝낸다(7차 C-2). 병합은 병합 커밋, rebase 는 계속, cherry-pick · revert 는 원래 메시지로 커밋. + /// 예전에는 [병합 커밋] 하나뿐이라 cherry-pick 은 충돌이 0 이 되면 끝낼 단추가 없었다. + /// + [RelayCommand(CanExecute = nameof(CanFinishOperation))] + private Task FinishOperationAsync() + { + var kind = _workspace.OperationState.Kind; + if (kind == GitOperationKind.Rebase) return RebaseContinueAsync(); + if (kind == GitOperationKind.Merge) return CommitMergeAsync(); + + var name = kind == GitOperationKind.CherryPick ? "cherry-pick" : "revert"; + return _workspace.RunAndRefreshAsync($"{name} 커밋", () => + { + var message = string.IsNullOrWhiteSpace(CommitMessage) ? null : CommitMessage; + var sha = _workspace.Git.CommitPendingOperation(message); + _workspace.OnUi(() => + { + CommitMessage = string.Empty; + MessageUtil.ShowSuccessToast($"{name} 커밋 완료 ({sha.Substring(0, 7)})"); + }); + }); + } + private bool CanCommitMerge() => _workspace.IsMergeInProgress && _workspace.Conflicts.Count == 0; [RelayCommand(CanExecute = nameof(CanCommitMerge))] @@ -414,15 +454,19 @@ namespace MergeHub.ViewModels [RelayCommand(CanExecute = nameof(HasCommit))] private Task RevertAsync() => _workspace.RunAndRefreshAsync("revert", () => { - // 결과 한 줄은 GitService 가 로그에 적는다. 여기서 또 적으면 두 번 찍힌다(6차 C-1). - _workspace.Git.Revert(SelectedCommit!.Sha); + // 결과 한 줄은 GitService 가 로그에 적는다. 여기서 또 적으면 두 번 찍힌다(6차 C-1). 뒷일은 병합과 같은 길로(7차 C-2). + var result = _workspace.Git.Revert(SelectedCommit!.Sha); + AfterConflicts(result.HasConflicts, result.Conflicts.Count, result.Message, + _workspace.Git.GetPendingCommitMessage(), GitOperationKind.Revert); }); [RelayCommand(CanExecute = nameof(HasCommit))] private Task CherryPickAsync() => _workspace.RunAndRefreshAsync("cherry-pick", () => { - // 결과 한 줄은 GitService 가 로그에 적는다(6차 C-1). - _workspace.Git.CherryPick(SelectedCommit!.Sha); + // 결과 한 줄은 GitService 가 로그에 적는다(6차 C-1). 뒷일은 병합과 같은 길로(7차 C-2). + var result = _workspace.Git.CherryPick(SelectedCommit!.Sha); + AfterConflicts(result.HasConflicts, result.Conflicts.Count, result.Message, + _workspace.Git.GetPendingCommitMessage(), GitOperationKind.CherryPick); }); [RelayCommand(CanExecute = nameof(HasCommit))] @@ -435,8 +479,10 @@ namespace MergeHub.ViewModels [RelayCommand(CanExecute = nameof(CanRebase))] private Task RebaseAsync() => _workspace.RunAndRefreshAsync("rebase", () => { - // 결과 한 줄은 GitService 가 로그에 적는다(6차 C-1). - _workspace.Git.RebaseStart(SelectedBranch!.FriendlyName); + // 결과 한 줄은 GitService 가 로그에 적는다(6차 C-1). 뒷일은 병합과 같은 길로(7차 C-2). + var result = _workspace.Git.RebaseStart(SelectedBranch!.FriendlyName); + AfterConflicts(result.Outcome == RebaseOutcome.Conflicts, result.Conflicts.Count, result.Message, + null, GitOperationKind.Rebase); }); private bool IsRebasing() => _workspace.IsRebaseInProgress; @@ -444,8 +490,10 @@ namespace MergeHub.ViewModels [RelayCommand(CanExecute = nameof(IsRebasing))] private Task RebaseContinueAsync() => _workspace.RunAndRefreshAsync("rebase continue", () => { - // 결과 한 줄은 GitService 가 로그에 적는다(6차 C-1). - _workspace.Git.RebaseContinue(); + // 결과 한 줄은 GitService 가 로그에 적는다(6차 C-1). 다음 단계에서 또 충돌하면 병합과 같은 뒷일(7차 C-2). + var result = _workspace.Git.RebaseContinue(); + AfterConflicts(result.Outcome == RebaseOutcome.Conflicts, result.Conflicts.Count, result.Message, + null, GitOperationKind.Rebase); }); [RelayCommand(CanExecute = nameof(IsRebasing))] @@ -744,6 +792,8 @@ namespace MergeHub.ViewModels MergeCommand.NotifyCanExecuteChanged(); AbortCommand.NotifyCanExecuteChanged(); CommitMergeCommand.NotifyCanExecuteChanged(); + FinishOperationCommand.NotifyCanExecuteChanged(); + OnPropertyChanged(nameof(FinishOperationLabel)); AcceptMineCommand.NotifyCanExecuteChanged(); AcceptTheirsCommand.NotifyCanExecuteChanged(); AcceptBothCommand.NotifyCanExecuteChanged(); diff --git a/src/MergeHub/ViewModels/HomeViewModel.cs b/src/MergeHub/ViewModels/HomeViewModel.cs index c06dcb1..54fb862 100644 --- a/src/MergeHub/ViewModels/HomeViewModel.cs +++ b/src/MergeHub/ViewModels/HomeViewModel.cs @@ -1204,6 +1204,7 @@ namespace MergeHub.ViewModels var branch = RemoteBranch; var baseOrigin = string.Empty; string? theirsName = null; + var operation = GitOperationState.None; var deletion = default(DeletionConflict); // 다른 작업이 게이트를 쥐고 있으면 RunAsync 는 아무것도 하지 않고 false 를 준다. @@ -1220,7 +1221,9 @@ namespace MergeHub.ViewModels remoteText = three.TheirsText; isConflict = true; baseOrigin = "merge base"; - theirsName = _workspace.Git.GetMergeTheirsName(); + // 병합 · rebase · cherry-pick · revert 마다 두 쪽이 다르다. rebase 는 LOCAL 이 올릴 자리, REMOTE 가 내 커밋(7차 C-2). + operation = _workspace.Git.GetOperationState(); + theirsName = OperationLabels.TheirsLogName(operation); // 한쪽이 파일을 지운 충돌(6차 A-4). 정할 것은 "남길까 지울까" 하나라 줄 단위 3분할로 끌고 가지 않는다. // 끌고 가면 지운 쪽 칸이 통째로 비고, 범례는 남은 쪽에만 있는 줄을 거꾸로 센다("Theirs only 89"). @@ -1289,7 +1292,8 @@ namespace MergeHub.ViewModels // 행을 싣기 전에 틀부터 바꾼다. 세 칸 행을 두 칸 틀에 실으면 REMOTE 칸이 안 보인다. _theirsSideName = isConflict ? theirsName ?? "THEIRS" : null; - _mineSideName = null; + _mineSideName = isConflict ? OperationLabels.MineLogName(operation, _workspace.CurrentBranch) : null; + _conflictHeaderText = OperationLabels.HeaderText(operation); SetConflictThreeWay(isConflict && !deletion.IsDeletion); SetDeletion(deletion, theirsName); OnPropertyChanged(nameof(ConflictSideText)); @@ -1312,9 +1316,11 @@ namespace MergeHub.ViewModels : _workspace.IsMergeInProgress && commit.ShortSha == _workspace.HeadSha ? MergeSavePlan.MergeBaseHeader(commit.ShortSha, path!) : $"{commit.ShortSha} · {path}"; - LocalHeader = _workspace.CurrentBranch; + LocalHeader = isConflict + ? OperationLabels.OursTitle(operation, _workspace.CurrentBranch) + : _workspace.CurrentBranch; RemoteHeader = isConflict - ? $"REMOTE · {theirsName ?? "THEIRS"}" + ? $"REMOTE · {OperationLabels.TheirsTitle(operation)}" : $"REMOTE · {branch}"; if (deletion.IsDeletion) @@ -1346,7 +1352,8 @@ namespace MergeHub.ViewModels // 병합 커밋 메시지 기본값은 병합 전체의 이름이다(6차 B-3). 예전에는 "Merge: 첫 충돌 파일 충돌 해결" 을 // 채우고, 다른 파일로 옮겨도 그 이름이 굳어 남았다. 사람이 고쳐 적은 글은 건드리지 않는다. - if (isConflict && (string.IsNullOrWhiteSpace(CommitMessage) || CommitMessage == _autoCommitMessage)) + if (isConflict && operation.Kind == GitOperationKind.Merge && + (string.IsNullOrWhiteSpace(CommitMessage) || CommitMessage == _autoCommitMessage)) { _autoCommitMessage = MergeNames.BranchMergeMessage(theirsName, _workspace.CurrentBranch); CommitMessage = _autoCommitMessage; @@ -1701,7 +1708,10 @@ namespace MergeHub.ViewModels private string? TheirsSideName => TakesMergeSides ? _theirsSideName : RemoteBranch; - private string? MineSideName => _toolThreeWay ? _mineSideName : _workspace.CurrentBranch; + private string? MineSideName => _mineSideName ?? _workspace.CurrentBranch; + + /// 충돌 네 칸의 머리 글. 작업(병합 · rebase · cherry-pick · revert)마다 다르다(7차 C-2). + private string _conflictHeaderText = string.Empty; /// 병합 충돌 파일을 네 칸으로 보는 중. 머리에서 3-Way 콤보 대신 상대 이름을 보인다. public bool IsConflictThreeWay => IsGitMode && _conflictThreeWay; @@ -1710,7 +1720,9 @@ namespace MergeHub.ViewModels public bool ShowBranchCombo => !IsConflictThreeWay; /// 충돌 네 칸일 때 콤보 자리에 보이는 글. - public string ConflictSideText => $"병합 충돌 · REMOTE = {_theirsSideName ?? "THEIRS"}"; + public string ConflictSideText => _conflictHeaderText.Length > 0 + ? _conflictHeaderText + : $"병합 충돌 · REMOTE = {_theirsSideName ?? "THEIRS"}"; private void SetConflictThreeWay(bool on) { diff --git a/src/MergeHub/Views/GitView.xaml b/src/MergeHub/Views/GitView.xaml index c5dfa13..a299e43 100644 --- a/src/MergeHub/Views/GitView.xaml +++ b/src/MergeHub/Views/GitView.xaml @@ -242,7 +242,7 @@ + Visibility="{Binding Workspace.IsOperationInProgress, Converter={StaticResource BoolToVis}}"/> @@ -298,7 +298,9 @@ ToolTip="내 파일 다음에 상대 파일을 통째로 이어 붙입니다. 누르면 먼저 묻습니다" Background="{DynamicResource Ubisam.Brush.Warning}" Foreground="{DynamicResource Ubisam.Brush.Accent.OnFill}"/> -