7차 C-2: rebase · cherry-pick 충돌을 작업에 맞게 부르고 MergeHub 만으로 끝까지

화면이 모든 충돌을 "병합" 으로 불렀다 - rebase REMOTE 가 "THEIRS", cherry-pick 에 "병합 진행 중",
다 풀면 cherry-pick 을 끝낼 단추가 없고, 로그에 RebaseContinue() · Commit(), 다른 브랜치 커밋은 고를 수 없었다.
GitOperationState 로 칸 제목(올릴 자리 · 다시 올리는 내 커밋 · 가져오는 커밋) · 머리 글 · 진행 표시 ·
끝내기 단추(병합 커밋 · 계속 · 커밋)를 정한다. cherry-pick · revert 는 원래 메시지 · 작성자로 커밋.
머지 주석 자동 풀기 · 알림도 같은 뒷일. 이력 탭 [모든 브랜치].
rebase · cherry-pick 모두 줄끝 바뀐 파일 0, MergeSaveTest 125건 통과.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
김승준 2026-09-15 16:03:42 +09:00
parent 165b5ea295
commit aebfd6b089
11 changed files with 512 additions and 56 deletions

View File

@ -170,6 +170,97 @@ MergeSaveTest 88건 통과. 빌드 경고 0 · 오류 0. 설정 파일은 바이
MergeSaveTest 93건 통과. 빌드 경고 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, 추가 요청, 완료) ## E-2. 폴더 비교 탭에 폴더를 하나씩 끌어다 놓으면 첫 칸 → 둘째 칸 차례로 (2026-09-14, 추가 요청, 완료)
사용자: "폴더비교 탭 첫번째 드래그 첫번째 폴더 경로에 들어가고 두번째는 드래그앤드롭은 두번째로 들어가게해줘 경로텍스트 박스에 안넣어도 그냥 화면에다 넣어둬 기능 동작하도록". 사용자: "폴더비교 탭 첫번째 드래그 첫번째 폴더 경로에 들어가고 두번째는 드래그앤드롭은 두번째로 들어가게해줘 경로텍스트 박스에 안넣어도 그냥 화면에다 넣어둬 기능 동작하도록".

View File

@ -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차 지시서에 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차 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개" 는 이어진다 | 아래 | | 2026-09-14 | **7차 B-1** — 병합 때 적는 처음 충돌 목록(`.git/MERGEHUB_CONFLICTS`)이 병합 커밋 · 작업 취소 · 터미널에서 끝낸 병합 뒤에도 남았다. MERGE_HEAD 가 없거나 다른 병합의 것이면 병합 커밋 · 작업 취소 직후와 상태를 다시 읽을 때마다 지운다. 병합 중에는 그대로라 "푼 것 M개" 는 이어진다 | 아래 |

View File

@ -164,7 +164,7 @@
| 상태 | 항목 | | 상태 | 항목 |
|---|---| |---|---|
| **완료** | C-1 pull 이 낸 충돌에는 머지 주석 자동 풀기가 안 걸린다 — pull 도 [Merge] 와 같은 뒷일(주석 자동 풀기 · 알림 · 진행 줄), 커밋 메시지 "Merge remote-tracking branch 'origin/…' into …" | | **완료** | C-1 pull 이 낸 충돌에는 머지 주석 자동 풀기가 안 걸린다 — pull 도 [Merge] 와 같은 뒷일(주석 자동 풀기 · 알림 · 진행 줄), 커밋 메시지 "Merge remote-tracking branch 'origin/…' into …" |
| 대기 | C-2 rebase · cherry-pick 충돌을 네 칸으로 끝까지 | | **완료** | C-2 rebase · cherry-pick 충돌을 네 칸으로 끝까지 — 작업별 칸 제목(올릴 자리 · 다시 올리는 내 커밋 · 가져오는 커밋) · 진행 표시 · 끝내기 단추(병합 커밋 · 계속 · 커밋) · 원래 메시지, 이력 [모든 브랜치]. 두 길 모두 줄끝 바뀐 파일 0 |
### D. 확인 ### D. 확인

View File

@ -114,6 +114,42 @@ namespace MergeHub.Models
Both Both
} }
/// <summary>지금 저장소에서 진행 중인 git 작업의 종류(7차 C-2).</summary>
public enum GitOperationKind
{
None,
Merge,
Rebase,
CherryPick,
Revert
}
/// <summary>
/// 진행 중인 작업과 두 쪽의 이름(7차 C-2). 충돌을 풀 때 "누가 LOCAL 이고 누가 REMOTE 인지" 가 작업마다 다르다.
/// <list type="bullet">
/// <item>병합 · cherry-pick · revert: ours = 내 브랜치, theirs = 들어오는 브랜치 · 커밋</item>
/// <item>rebase: ours = <b>올릴 자리(onto)</b>, theirs = <b>다시 올리는 내 커밋</b> — 병합과 거꾸로다</item>
/// </list>
/// </summary>
/// <param name="OursName">병합 · cherry-pick · revert 는 내 브랜치, rebase 는 올릴 자리 브랜치 이름.</param>
/// <param name="TheirsName">병합은 상대 브랜치, rebase 는 다시 올리는 브랜치 이름.</param>
/// <param name="TheirsSha">병합은 MERGE_HEAD, rebase 는 지금 올리는 커밋, cherry-pick · revert 는 그 커밋.</param>
/// <param name="TheirsMessage">그 커밋의 한 줄 메시지.</param>
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 public enum MergeOutcome
{ {
/// <summary>이미 최신 - 병합할 것이 없음.</summary> /// <summary>이미 최신 - 병합할 것이 없음.</summary>

View File

@ -287,7 +287,7 @@ namespace MergeHub.Services
// ================= 히스토리 ================= // ================= 히스토리 =================
public IReadOnlyList<CommitInfo> GetCommits(string? committish = null, int maxCount = 200) public IReadOnlyList<CommitInfo> GetCommits(string? committish = null, int maxCount = 200, bool allBranches = false)
{ {
var refsByTarget = new Dictionary<string, List<string>>(StringComparer.Ordinal); var refsByTarget = new Dictionary<string, List<string>>(StringComparer.Ordinal);
foreach (var branch in Repo.Branches) foreach (var branch in Repo.Branches)
@ -318,6 +318,14 @@ namespace MergeHub.Services
?? throw new ArgumentException($"커밋/브랜치를 찾을 수 없습니다: {committish}"); ?? throw new ArgumentException($"커밋/브랜치를 찾을 수 없습니다: {committish}");
filter.IncludeReachableFrom = start; 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<CommitInfo>(Math.Min(maxCount, 256)); var result = new List<CommitInfo>(Math.Min(maxCount, 256));
foreach (var c in Repo.Commits.QueryBy(filter)) foreach (var c in Repo.Commits.QueryBy(filter))
@ -639,10 +647,11 @@ namespace MergeHub.Services
var message = outcome switch var message = outcome switch
{ {
// 사람이 읽는 로그다 - 예전에는 "Commit() 을 호출하세요" 처럼 코드 이름을 적었다(7차 C-2).
PatchApplyOutcome.Applied => commitOnSuccess PatchApplyOutcome.Applied => commitOnSuccess
? $"{operation} 완료 ({Shorten(sha)})" ? $"{operation} 완료 ({Shorten(sha)})"
: $"{operation} 적용됨 - 확인 후 Commit() 을 호출하세요.", : $"{operation} 적용됨 - 확인한 뒤 충돌 탭의 [커밋] 으로 끝냅니다.",
PatchApplyOutcome.Conflicts => $"{operation} 충돌 {conflicts.Count}건 - 해결이 필요합니다.", PatchApplyOutcome.Conflicts => $"{operation} 충돌 {conflicts.Count}건 - 풀고 나서 충돌 탭의 [커밋] 으로 끝냅니다.",
PatchApplyOutcome.NothingToDo => $"{operation} 할 변경이 없습니다.", PatchApplyOutcome.NothingToDo => $"{operation} 할 변경이 없습니다.",
_ => $"{operation} 실패" _ => $"{operation} 실패"
}; };
@ -729,7 +738,7 @@ namespace MergeHub.Services
RebaseOutcome.NothingToDo => "재배치할 커밋이 없습니다.", RebaseOutcome.NothingToDo => "재배치할 커밋이 없습니다.",
RebaseOutcome.Conflicts => RebaseOutcome.Conflicts =>
$"rebase {result.CompletedStepCount + 1}/{result.TotalStepCount} 단계에서 충돌 {conflicts.Count}건 " + $"rebase {result.CompletedStepCount + 1}/{result.TotalStepCount} 단계에서 충돌 {conflicts.Count}건 " +
"- 해결 후 RebaseContinue() 를 호출하세요.", "- 풀고 나서 [계속] 을 누르세요.",
RebaseOutcome.Stopped => $"rebase 정지 ({result.CompletedStepCount}/{result.TotalStepCount} 단계)", RebaseOutcome.Stopped => $"rebase 정지 ({result.CompletedStepCount}/{result.TotalStepCount} 단계)",
_ => "rebase 실패" _ => "rebase 실패"
}; };
@ -1066,6 +1075,124 @@ namespace MergeHub.Services
return null; 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<Commit>(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;
}
}
/// <summary>파일 첫 줄의 40자 해시. 없으면 null.</summary>
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;
}
/// <summary>그 커밋을 끝으로 가리키는 브랜치 이름. 로컬을 먼저 본다.</summary>
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<Commit>(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<Commit>(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() public string? GetMergeTheirsName()
{ {
if (Repo.Info.CurrentOperation != CurrentOperation.Merge && !Repo.Index.Conflicts.Any()) return null; if (Repo.Info.CurrentOperation != CurrentOperation.Merge && !Repo.Index.Conflicts.Any()) return null;

View File

@ -187,21 +187,28 @@ namespace MergeHub.Services
/// 처음 충돌 수는 병합할 때 .git 에 적어 두므로 앱을 다시 켜도 이어진다. /// 처음 충돌 수는 병합할 때 .git 에 적어 두므로 앱을 다시 켜도 이어진다.
/// MergeHub 밖에서 시작한 병합이라 처음 수를 모르면 남은 수만 적는다. /// MergeHub 밖에서 시작한 병합이라 처음 수를 모르면 남은 수만 적는다.
/// </summary> /// </summary>
public string ConflictProgressText public string ConflictProgressText =>
// 작업마다 다 푼 뒤 할 일이 다르다 - 병합 커밋 · [계속] · [커밋](7차 C-2). 병합의 글은 예전과 같다.
OperationLabels.ProgressText(OperationState.Kind, Conflicts.Count, MergeConflictTotal);
/// <summary>진행 중인 git 작업과 두 쪽 이름(7차 C-2). 상태를 다시 읽을 때마다 갱신한다.</summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsOperationInProgress))]
[NotifyPropertyChangedFor(nameof(ConflictProgressText))]
private GitOperationState _operationState = GitOperationState.None;
/// <summary>
/// 병합 · rebase · cherry-pick · revert 중 무엇이든 진행 중인지. 충돌 수와 따로 본다 -
/// 예전에는 충돌이 0 이 되면 cherry-pick 이 진행 중이라는 표시도 [작업 취소] 도 사라졌다(7차 C-2).
/// </summary>
public bool IsOperationInProgress => OperationState.IsInProgress;
/// <summary>이력에 모든 브랜치의 커밋을 보일지(7차 C-2). 다른 브랜치 커밋을 골라 cherry-pick 할 때 켠다.</summary>
[ObservableProperty] private bool _showAllBranchesInHistory;
partial void OnShowAllBranchesInHistoryChanged(bool value)
{ {
get if (IsRepositoryOpen) _ = RefreshAsync();
{
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})";
}
} }
[ObservableProperty] private bool _isRebaseInProgress; [ObservableProperty] private bool _isRebaseInProgress;
[ObservableProperty] private string _pendingOperationText = string.Empty; [ObservableProperty] private string _pendingOperationText = string.Empty;
@ -340,7 +347,9 @@ namespace MergeHub.Services
var info = Git.GetRepositoryInfo(); var info = Git.GetRepositoryInfo();
var branches = Git.GetBranches(); 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 nodes = CommitGraphBuilder.Build(commits);
var status = Git.GetStatus(); var status = Git.GetStatus();
var conflicts = Git.GetConflicts(); var conflicts = Git.GetConflicts();
@ -373,9 +382,9 @@ namespace MergeHub.Services
IsMergeInProgress = mergeInProgress; IsMergeInProgress = mergeInProgress;
IsRebaseInProgress = rebase.IsInProgress; IsRebaseInProgress = rebase.IsInProgress;
PendingOperationText = OperationState = operation;
rebase.IsInProgress ? $"rebase {rebase.CurrentStep}/{rebase.TotalSteps}" : // cherry-pick · revert 도 "병합 진행 중" 이라고 했다. 작업 이름으로 적는다(7차 C-2).
mergeInProgress ? "병합 진행 중" : string.Empty; PendingOperationText = OperationLabels.PendingText(operation);
IsWorkingTreeClean = status.IsClean; IsWorkingTreeClean = status.IsClean;
WorkingTreeSummary = status.IsClean ? "Clean" : $"{status.Files.Count} files"; WorkingTreeSummary = status.IsClean ? "Clean" : $"{status.Files.Count} files";

View File

@ -49,7 +49,8 @@ namespace MergeHub.Services
void Push(string? branchName = null, string remoteName = "origin"); void Push(string? branchName = null, string remoteName = "origin");
// ---- 히스토리 / 조회 ---------------------------------------------- // ---- 히스토리 / 조회 ----------------------------------------------
IReadOnlyList<CommitInfo> GetCommits(string? committish = null, int maxCount = 200); /// <param name="allBranches">committish 가 없을 때 모든 브랜치에서 닿는 커밋까지(7차 C-2). 아니면 HEAD 에서 닿는 것만.</param>
IReadOnlyList<CommitInfo> GetCommits(string? committish = null, int maxCount = 200, bool allBranches = false);
CommitInfo? GetCommit(string committish); CommitInfo? GetCommit(string committish);
string? GetMergeBaseSha(string committishA, string committishB); string? GetMergeBaseSha(string committishA, string committishB);
@ -94,6 +95,15 @@ namespace MergeHub.Services
/// </summary> /// </summary>
string? GetMergeTheirsName(); string? GetMergeTheirsName();
/// <summary>진행 중인 작업(병합 · rebase · cherry-pick · revert)과 두 쪽 이름(7차 C-2).</summary>
GitOperationState GetOperationState();
/// <summary>cherry-pick · revert 를 끝낼 커밋의 기본 메시지(원래 메시지). 그 작업이 아니면 null(7차 C-2).</summary>
string? GetPendingCommitMessage();
/// <summary>충돌을 다 푼 cherry-pick · revert 를 커밋해 끝낸다. 병합이면 <see cref="CommitMerge"/> 와 같다(7차 C-2).</summary>
string CommitPendingOperation(string? message = null);
/// <summary>Accept Mine / Theirs / Both 버튼 처리. 파일에 기록하고 인덱스에서 충돌 해제.</summary> /// <summary>Accept Mine / Theirs / Both 버튼 처리. 파일에 기록하고 인덱스에서 충돌 해제.</summary>
void ResolveConflict(string path, ConflictResolution resolution); void ResolveConflict(string path, ConflictResolution resolution);

View File

@ -0,0 +1,114 @@
using MergeHub.Models;
namespace MergeHub.Services
{
/// <summary>
/// 진행 중인 git 작업(병합 · rebase · cherry-pick · revert)을 화면이 사람 말로 부르는 글(7차 C-2).
///
/// 예전에는 모든 충돌을 "병합" 으로만 말했다. rebase 에서는 LOCAL 이 올릴 자리(상대 브랜치)이고 REMOTE 가
/// <b>내 커밋</b>인데도 "REMOTE · THEIRS" 라고 했고, cherry-pick 은 머리에 "병합 진행 중" 이 떴다.
/// 충돌을 다 푼 뒤 할 일도 작업마다 다르다 — 병합은 [병합 커밋], rebase 는 [계속], cherry-pick · revert 는 [커밋].
/// 저장소 없이 검사하도록 글만 여기 모았다.
/// </summary>
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();
/// <summary>머리의 진행 표시. 진행 중이 아니면 빈 글.</summary>
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
};
/// <summary>네 칸의 LOCAL 칸 제목. rebase 는 올릴 자리, 그 밖은 내 브랜치.</summary>
public static string OursTitle(GitOperationState s, string? currentBranch) =>
s.Kind == GitOperationKind.Rebase
? $"올릴 자리 {Onto(s)}"
: !string.IsNullOrWhiteSpace(currentBranch) ? currentBranch!.Trim() : s.OursName ?? "LOCAL";
/// <summary>네 칸의 REMOTE 칸 제목("REMOTE · " 뒤).</summary>
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()
};
/// <summary>충돌 네 칸일 때 3-Way 콤보 자리에 보이는 글(7차 A-2 의 "병합 충돌 · REMOTE = …" 을 작업별로).</summary>
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())}"
};
/// <summary>Take Theirs 로그의 괄호 안 이름. 병합은 브랜치, 그 밖은 "커밋 해시".</summary>
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();
}
/// <summary>Take Mine 로그의 괄호 안 이름. rebase 는 올릴 자리, 그 밖은 내 브랜치.</summary>
public static string? MineLogName(GitOperationState s, string? currentBranch) =>
s.Kind == GitOperationKind.Rebase ? s.OursName : currentBranch;
/// <summary>충돌을 다 푼 뒤 할 일.</summary>
public static string FinishHint(GitOperationKind kind) => kind switch
{
GitOperationKind.Rebase => "[계속] 으로 rebase 를 이어 갑니다",
GitOperationKind.CherryPick => "[커밋] 으로 cherry-pick 을 끝냅니다",
GitOperationKind.Revert => "[커밋] 으로 revert 를 끝냅니다",
_ => "병합 커밋을 할 수 있습니다"
};
/// <summary>충돌 탭의 끝내기 단추 글자.</summary>
public static string FinishButtonLabel(GitOperationKind kind) => kind switch
{
GitOperationKind.Rebase => "계속",
GitOperationKind.CherryPick or GitOperationKind.Revert => "커밋",
_ => "병합 커밋"
};
/// <summary>
/// 충돌 탭 목록 위의 진행 한 줄(6차 C-1 을 작업별로). 처음 충돌 수를 모르면 남은 수만 적는다.
/// 병합의 글은 예전과 같다.
/// </summary>
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})";
}
}
}

View File

@ -245,43 +245,83 @@ namespace MergeHub.ViewModels
/// 병합(또는 pull) 결과를 받은 뒤 할 일. 작업 스레드에서 부른다(7차 C-1 에서 [Merge] 와 pull 이 함께 쓰게 뺐다). /// 병합(또는 pull) 결과를 받은 뒤 할 일. 작업 스레드에서 부른다(7차 C-1 에서 [Merge] 와 pull 이 함께 쓰게 뺐다).
/// </summary> /// </summary>
/// <param name="defaultMessage">커밋 메시지 칸이 비어 있고 병합 커밋이 남았을 때 채울 메시지.</param> /// <param name="defaultMessage">커밋 메시지 칸이 비어 있고 병합 커밋이 남았을 때 채울 메시지.</param>
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);
/// <summary>
/// 충돌이 날 수 있는 작업(병합 · pull · rebase · cherry-pick · revert) 뒤에 할 일. 작업 스레드에서 부른다.
/// 7차 C-2 에서 rebase · cherry-pick · revert 도 같은 길을 쓰게 했다 - 머지 주석 자동 풀기 · 알림 · 커밋 메시지.
/// </summary>
/// <param name="fillMessage">커밋 메시지 칸이 비어 있으면 채울 글. 없으면 null.</param>
private void AfterConflicts(bool hasConflicts, int conflictCount, string resultMessage, string? fillMessage, GitOperationKind kind)
{ {
// 머지 주석만 다른 충돌은 사람에게 묻지 않고 푼다. 양쪽 주석을 다 남긴다(6차 B-5). // 머지 주석만 다른 충돌은 사람에게 묻지 않고 푼다. 양쪽 주석을 다 남긴다(6차 B-5).
IReadOnlyList<string> noteOnly = result.HasConflicts && _workspace.Settings.IgnoreMergeNotes IReadOnlyList<string> noteOnly = hasConflicts && _workspace.Settings.IgnoreMergeNotes
? _workspace.Git.ResolveNoteOnlyConflicts() ? _workspace.Git.ResolveNoteOnlyConflicts()
: Array.Empty<string>(); : Array.Empty<string>();
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(() => _workspace.OnUi(() =>
{ {
// result.Message("충돌 N건 - 해결이 필요합니다.")는 GitService 가 이미 로그에 적었다(Progress). // 결과 문장은 GitService 가 이미 로그에 적었다(Progress). 여기서 또 적으면 두 번 찍힌다(6차 C-1).
// 여기서 또 적으면 두 번 찍히고, 자동 해결 줄 뒤에 다시 나와 순서도 헷갈렸다(6차 C-1).
if (noteOnly.Count > 0) if (noteOnly.Count > 0)
_workspace.AppendLog($"머지 주석만 다른 충돌 {noteOnly.Count}개 파일은 자동으로 풀었습니다. 남은 충돌 {remaining}건."); _workspace.AppendLog($"머지 주석만 다른 충돌 {noteOnly.Count}개 파일은 자동으로 풀었습니다. 남은 충돌 {remaining}건.");
// 병합 커밋이 남는 경우에만 채운다 - 이미 최신이거나 앞으로 감기면 커밋할 병합이 없다. if (string.IsNullOrWhiteSpace(CommitMessage) && !string.IsNullOrWhiteSpace(fillMessage))
if (string.IsNullOrWhiteSpace(CommitMessage) && CommitMessage = fillMessage!;
(result.HasConflicts || result.Outcome == MergeOutcome.Merged))
CommitMessage = defaultMessage;
var autoNote = noteOnly.Count > 0 ? $" (머지 주석만 다른 {noteOnly.Count}건은 자동으로 풀림)" : string.Empty; var autoNote = noteOnly.Count > 0 ? $" (머지 주석만 다른 {noteOnly.Count}건은 자동으로 풀림)" : string.Empty;
var then = kind == GitOperationKind.Merge ? string.Empty : $" 다 풀면 {finish}.";
if (result.HasConflicts && remaining > 0) if (hasConflicts && remaining > 0)
MessageUtil.ShowWarningToast($"충돌 {remaining}건{autoNote} - 충돌 탭에서 파일을 두 번 누르면 파일 비교 탭에서 세 칸으로 풉니다."); MessageUtil.ShowWarningToast($"충돌 {remaining}건{autoNote} - 충돌 탭에서 파일을 두 번 누르면 파일 비교 탭에서 세 칸으로 풉니다.{then}");
else if (result.HasConflicts) else if (hasConflicts)
MessageUtil.ShowSuccessToast($"머지 주석만 다른 충돌 {noteOnly.Count}건을 자동으로 풀었습니다 - 병합 커밋을 할 수 있습니다."); MessageUtil.ShowSuccessToast($"머지 주석만 다른 충돌 {noteOnly.Count}건을 자동으로 풀었습니다 - {finish}.");
else 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))] [RelayCommand(CanExecute = nameof(CanAbort))]
private Task AbortAsync() => _workspace.RunAndRefreshAsync("작업 취소", private Task AbortAsync() => _workspace.RunAndRefreshAsync("작업 취소",
() => _workspace.Git.AbortOperation()); () => _workspace.Git.AbortOperation());
/// <summary>충돌 탭의 끝내기 단추 글자 — 병합 커밋 · 계속 · 커밋(7차 C-2).</summary>
public string FinishOperationLabel => OperationLabels.FinishButtonLabel(_workspace.OperationState.Kind);
private bool CanFinishOperation() => _workspace.OperationState.IsInProgress && _workspace.Conflicts.Count == 0;
/// <summary>
/// 충돌을 다 푼 작업을 끝낸다(7차 C-2). 병합은 병합 커밋, rebase 는 계속, cherry-pick · revert 는 원래 메시지로 커밋.
/// 예전에는 [병합 커밋] 하나뿐이라 cherry-pick 은 충돌이 0 이 되면 끝낼 단추가 없었다.
/// </summary>
[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; private bool CanCommitMerge() => _workspace.IsMergeInProgress && _workspace.Conflicts.Count == 0;
[RelayCommand(CanExecute = nameof(CanCommitMerge))] [RelayCommand(CanExecute = nameof(CanCommitMerge))]
@ -414,15 +454,19 @@ namespace MergeHub.ViewModels
[RelayCommand(CanExecute = nameof(HasCommit))] [RelayCommand(CanExecute = nameof(HasCommit))]
private Task RevertAsync() => _workspace.RunAndRefreshAsync("revert", () => private Task RevertAsync() => _workspace.RunAndRefreshAsync("revert", () =>
{ {
// 결과 한 줄은 GitService 가 로그에 적는다. 여기서 또 적으면 두 번 찍힌다(6차 C-1). // 결과 한 줄은 GitService 가 로그에 적는다. 여기서 또 적으면 두 번 찍힌다(6차 C-1). 뒷일은 병합과 같은 길로(7차 C-2).
_workspace.Git.Revert(SelectedCommit!.Sha); var result = _workspace.Git.Revert(SelectedCommit!.Sha);
AfterConflicts(result.HasConflicts, result.Conflicts.Count, result.Message,
_workspace.Git.GetPendingCommitMessage(), GitOperationKind.Revert);
}); });
[RelayCommand(CanExecute = nameof(HasCommit))] [RelayCommand(CanExecute = nameof(HasCommit))]
private Task CherryPickAsync() => _workspace.RunAndRefreshAsync("cherry-pick", () => private Task CherryPickAsync() => _workspace.RunAndRefreshAsync("cherry-pick", () =>
{ {
// 결과 한 줄은 GitService 가 로그에 적는다(6차 C-1). // 결과 한 줄은 GitService 가 로그에 적는다(6차 C-1). 뒷일은 병합과 같은 길로(7차 C-2).
_workspace.Git.CherryPick(SelectedCommit!.Sha); var result = _workspace.Git.CherryPick(SelectedCommit!.Sha);
AfterConflicts(result.HasConflicts, result.Conflicts.Count, result.Message,
_workspace.Git.GetPendingCommitMessage(), GitOperationKind.CherryPick);
}); });
[RelayCommand(CanExecute = nameof(HasCommit))] [RelayCommand(CanExecute = nameof(HasCommit))]
@ -435,8 +479,10 @@ namespace MergeHub.ViewModels
[RelayCommand(CanExecute = nameof(CanRebase))] [RelayCommand(CanExecute = nameof(CanRebase))]
private Task RebaseAsync() => _workspace.RunAndRefreshAsync("rebase", () => private Task RebaseAsync() => _workspace.RunAndRefreshAsync("rebase", () =>
{ {
// 결과 한 줄은 GitService 가 로그에 적는다(6차 C-1). // 결과 한 줄은 GitService 가 로그에 적는다(6차 C-1). 뒷일은 병합과 같은 길로(7차 C-2).
_workspace.Git.RebaseStart(SelectedBranch!.FriendlyName); 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; private bool IsRebasing() => _workspace.IsRebaseInProgress;
@ -444,8 +490,10 @@ namespace MergeHub.ViewModels
[RelayCommand(CanExecute = nameof(IsRebasing))] [RelayCommand(CanExecute = nameof(IsRebasing))]
private Task RebaseContinueAsync() => _workspace.RunAndRefreshAsync("rebase continue", () => private Task RebaseContinueAsync() => _workspace.RunAndRefreshAsync("rebase continue", () =>
{ {
// 결과 한 줄은 GitService 가 로그에 적는다(6차 C-1). // 결과 한 줄은 GitService 가 로그에 적는다(6차 C-1). 다음 단계에서 또 충돌하면 병합과 같은 뒷일(7차 C-2).
_workspace.Git.RebaseContinue(); var result = _workspace.Git.RebaseContinue();
AfterConflicts(result.Outcome == RebaseOutcome.Conflicts, result.Conflicts.Count, result.Message,
null, GitOperationKind.Rebase);
}); });
[RelayCommand(CanExecute = nameof(IsRebasing))] [RelayCommand(CanExecute = nameof(IsRebasing))]
@ -744,6 +792,8 @@ namespace MergeHub.ViewModels
MergeCommand.NotifyCanExecuteChanged(); MergeCommand.NotifyCanExecuteChanged();
AbortCommand.NotifyCanExecuteChanged(); AbortCommand.NotifyCanExecuteChanged();
CommitMergeCommand.NotifyCanExecuteChanged(); CommitMergeCommand.NotifyCanExecuteChanged();
FinishOperationCommand.NotifyCanExecuteChanged();
OnPropertyChanged(nameof(FinishOperationLabel));
AcceptMineCommand.NotifyCanExecuteChanged(); AcceptMineCommand.NotifyCanExecuteChanged();
AcceptTheirsCommand.NotifyCanExecuteChanged(); AcceptTheirsCommand.NotifyCanExecuteChanged();
AcceptBothCommand.NotifyCanExecuteChanged(); AcceptBothCommand.NotifyCanExecuteChanged();

View File

@ -1204,6 +1204,7 @@ namespace MergeHub.ViewModels
var branch = RemoteBranch; var branch = RemoteBranch;
var baseOrigin = string.Empty; var baseOrigin = string.Empty;
string? theirsName = null; string? theirsName = null;
var operation = GitOperationState.None;
var deletion = default(DeletionConflict); var deletion = default(DeletionConflict);
// 다른 작업이 게이트를 쥐고 있으면 RunAsync 는 아무것도 하지 않고 false 를 준다. // 다른 작업이 게이트를 쥐고 있으면 RunAsync 는 아무것도 하지 않고 false 를 준다.
@ -1220,7 +1221,9 @@ namespace MergeHub.ViewModels
remoteText = three.TheirsText; remoteText = three.TheirsText;
isConflict = true; isConflict = true;
baseOrigin = "merge base"; 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분할로 끌고 가지 않는다. // 한쪽이 파일을 지운 충돌(6차 A-4). 정할 것은 "남길까 지울까" 하나라 줄 단위 3분할로 끌고 가지 않는다.
// 끌고 가면 지운 쪽 칸이 통째로 비고, 범례는 남은 쪽에만 있는 줄을 거꾸로 센다("Theirs only 89"). // 끌고 가면 지운 쪽 칸이 통째로 비고, 범례는 남은 쪽에만 있는 줄을 거꾸로 센다("Theirs only 89").
@ -1289,7 +1292,8 @@ namespace MergeHub.ViewModels
// 행을 싣기 전에 틀부터 바꾼다. 세 칸 행을 두 칸 틀에 실으면 REMOTE 칸이 안 보인다. // 행을 싣기 전에 틀부터 바꾼다. 세 칸 행을 두 칸 틀에 실으면 REMOTE 칸이 안 보인다.
_theirsSideName = isConflict ? theirsName ?? "THEIRS" : null; _theirsSideName = isConflict ? theirsName ?? "THEIRS" : null;
_mineSideName = null; _mineSideName = isConflict ? OperationLabels.MineLogName(operation, _workspace.CurrentBranch) : null;
_conflictHeaderText = OperationLabels.HeaderText(operation);
SetConflictThreeWay(isConflict && !deletion.IsDeletion); SetConflictThreeWay(isConflict && !deletion.IsDeletion);
SetDeletion(deletion, theirsName); SetDeletion(deletion, theirsName);
OnPropertyChanged(nameof(ConflictSideText)); OnPropertyChanged(nameof(ConflictSideText));
@ -1312,9 +1316,11 @@ namespace MergeHub.ViewModels
: _workspace.IsMergeInProgress && commit.ShortSha == _workspace.HeadSha : _workspace.IsMergeInProgress && commit.ShortSha == _workspace.HeadSha
? MergeSavePlan.MergeBaseHeader(commit.ShortSha, path!) ? MergeSavePlan.MergeBaseHeader(commit.ShortSha, path!)
: $"{commit.ShortSha} · {path}"; : $"{commit.ShortSha} · {path}";
LocalHeader = _workspace.CurrentBranch; LocalHeader = isConflict
? OperationLabels.OursTitle(operation, _workspace.CurrentBranch)
: _workspace.CurrentBranch;
RemoteHeader = isConflict RemoteHeader = isConflict
? $"REMOTE · {theirsName ?? "THEIRS"}" ? $"REMOTE · {OperationLabels.TheirsTitle(operation)}"
: $"REMOTE · {branch}"; : $"REMOTE · {branch}";
if (deletion.IsDeletion) if (deletion.IsDeletion)
@ -1346,7 +1352,8 @@ namespace MergeHub.ViewModels
// 병합 커밋 메시지 기본값은 <b>병합 전체의 이름</b>이다(6차 B-3). 예전에는 "Merge: 첫 충돌 파일 충돌 해결" 을 // 병합 커밋 메시지 기본값은 <b>병합 전체의 이름</b>이다(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); _autoCommitMessage = MergeNames.BranchMergeMessage(theirsName, _workspace.CurrentBranch);
CommitMessage = _autoCommitMessage; CommitMessage = _autoCommitMessage;
@ -1701,7 +1708,10 @@ namespace MergeHub.ViewModels
private string? TheirsSideName => TakesMergeSides ? _theirsSideName : RemoteBranch; private string? TheirsSideName => TakesMergeSides ? _theirsSideName : RemoteBranch;
private string? MineSideName => _toolThreeWay ? _mineSideName : _workspace.CurrentBranch; private string? MineSideName => _mineSideName ?? _workspace.CurrentBranch;
/// <summary>충돌 네 칸의 머리 글. 작업(병합 · rebase · cherry-pick · revert)마다 다르다(7차 C-2).</summary>
private string _conflictHeaderText = string.Empty;
/// <summary>병합 충돌 파일을 네 칸으로 보는 중. 머리에서 3-Way 콤보 대신 상대 이름을 보인다.</summary> /// <summary>병합 충돌 파일을 네 칸으로 보는 중. 머리에서 3-Way 콤보 대신 상대 이름을 보인다.</summary>
public bool IsConflictThreeWay => IsGitMode && _conflictThreeWay; public bool IsConflictThreeWay => IsGitMode && _conflictThreeWay;
@ -1710,7 +1720,9 @@ namespace MergeHub.ViewModels
public bool ShowBranchCombo => !IsConflictThreeWay; public bool ShowBranchCombo => !IsConflictThreeWay;
/// <summary>충돌 네 칸일 때 콤보 자리에 보이는 글.</summary> /// <summary>충돌 네 칸일 때 콤보 자리에 보이는 글.</summary>
public string ConflictSideText => $"병합 충돌 · REMOTE = {_theirsSideName ?? "THEIRS"}"; public string ConflictSideText => _conflictHeaderText.Length > 0
? _conflictHeaderText
: $"병합 충돌 · REMOTE = {_theirsSideName ?? "THEIRS"}";
private void SetConflictThreeWay(bool on) private void SetConflictThreeWay(bool on)
{ {

View File

@ -242,7 +242,7 @@
<!-- 진행 한 줄(6차 C-1): "남은 충돌 N개 · 푼 것 M개 (M/전체)". 병합이 아니면 비어 접힌다. --> <!-- 진행 한 줄(6차 C-1): "남은 충돌 N개 · 푼 것 M개 (M/전체)". 병합이 아니면 비어 접힌다. -->
<StackPanel Grid.Row="0" Margin="0,0,0,6"> <StackPanel Grid.Row="0" Margin="0,0,0,6">
<TextBlock Text="{Binding Workspace.ConflictProgressText}" FontWeight="Bold" Margin="0,0,0,2" <TextBlock Text="{Binding Workspace.ConflictProgressText}" FontWeight="Bold" Margin="0,0,0,2"
Visibility="{Binding Workspace.IsMergeInProgress, Converter={StaticResource BoolToVis}}"/> Visibility="{Binding Workspace.IsOperationInProgress, Converter={StaticResource BoolToVis}}"/>
<TextBlock Style="{StaticResource Muted}" TextWrapping="Wrap" <TextBlock Style="{StaticResource Muted}" TextWrapping="Wrap"
Text="파일 전체를 한쪽으로 채택하려면 아래 버튼을 쓰고, 줄마다 고르려면 목록을 두 번 누르거나 [3분할로 열기] 를 누르세요. 파일 비교 탭에서 세 칸으로 열립니다. Ctrl · Shift 로 여러 파일을 고르면 Accept 가 한 번에 적용됩니다(Ctrl+A 전부)."/> Text="파일 전체를 한쪽으로 채택하려면 아래 버튼을 쓰고, 줄마다 고르려면 목록을 두 번 누르거나 [3분할로 열기] 를 누르세요. 파일 비교 탭에서 세 칸으로 열립니다. Ctrl · Shift 로 여러 파일을 고르면 Accept 가 한 번에 적용됩니다(Ctrl+A 전부)."/>
</StackPanel> </StackPanel>
@ -298,7 +298,9 @@
ToolTip="내 파일 다음에 상대 파일을 통째로 이어 붙입니다. 누르면 먼저 묻습니다" ToolTip="내 파일 다음에 상대 파일을 통째로 이어 붙입니다. 누르면 먼저 묻습니다"
Background="{DynamicResource Ubisam.Brush.Warning}" Background="{DynamicResource Ubisam.Brush.Warning}"
Foreground="{DynamicResource Ubisam.Brush.Accent.OnFill}"/> Foreground="{DynamicResource Ubisam.Brush.Accent.OnFill}"/>
<Button Content="병합 커밋" Command="{Binding CommitMergeCommand}" MinWidth="100"/> <!-- 작업마다 끝내는 법이 다르다 — 병합 커밋 · 계속(rebase) · 커밋(cherry-pick · revert)(7차 C-2). -->
<Button Content="{Binding FinishOperationLabel}" Command="{Binding FinishOperationCommand}" MinWidth="100"
ToolTip="충돌을 다 푼 작업을 끝냅니다. 병합은 병합 커밋, rebase 는 다음 단계로, cherry-pick · revert 는 원래 메시지로 커밋"/>
<TextBlock Text="{Binding ConflictSelectionText}" Style="{StaticResource Muted}" <TextBlock Text="{Binding ConflictSelectionText}" Style="{StaticResource Muted}"
VerticalAlignment="Center" Margin="8,0,0,0"/> VerticalAlignment="Center" Margin="8,0,0,0"/>
</WrapPanel> </WrapPanel>
@ -432,6 +434,10 @@
Text="{Binding DiffText, Mode=OneWay}"/> Text="{Binding DiffText, Mode=OneWay}"/>
<WrapPanel Grid.Row="1" Grid.ColumnSpan="2" Margin="0,6,0,0"> <WrapPanel Grid.Row="1" Grid.ColumnSpan="2" Margin="0,6,0,0">
<!-- 끄면 지금 브랜치에서 닿는 커밋만. 켜면 다른 브랜치 커밋도 골라 cherry-pick 할 수 있다(7차 C-2). -->
<CheckBox Content="모든 브랜치" IsChecked="{Binding Workspace.ShowAllBranchesInHistory}"
VerticalAlignment="Center" Margin="0,0,14,0"
ToolTip="다른 브랜치의 커밋까지 이력에 보입니다. cherry-pick 할 커밋을 고를 때 켭니다"/>
<TextBlock Text="선택 커밋 →" Style="{StaticResource Muted}" Margin="0,0,6,0"/> <TextBlock Text="선택 커밋 →" Style="{StaticResource Muted}" Margin="0,0,6,0"/>
<Button Content="Revert" Command="{Binding RevertCommand}" <Button Content="Revert" Command="{Binding RevertCommand}"
ToolTip="선택 커밋의 변경을 되돌리는 커밋 생성"/> ToolTip="선택 커밋의 변경을 되돌리는 커밋 생성"/>