From 588f3d75e939c9283bcf9d52061ffd9401cd6080 Mon Sep 17 00:00:00 2001 From: ksj09406 Date: Tue, 22 Sep 2026 10:34:58 +0900 Subject: [PATCH] =?UTF-8?q?11=EC=B0=A8=20C-2:=20=EC=9D=B4=EB=A0=A5=20?= =?UTF-8?q?=EC=A4=84=20=EC=9A=B0=ED=81=B4=EB=A6=AD=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=A7=88=EC=A7=80=EB=A7=89=EC=9D=B4=20=EC=95=84=EB=8B=8C=20?= =?UTF-8?q?=EC=BB=A4=EB=B0=8B=EC=9D=98=20=EB=A9=94=EC=8B=9C=EC=A7=80?= =?UTF-8?q?=EB=A5=BC=20=EB=B0=94=EA=BE=BC=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기획안 1차 범위 "커밋 우클릭 -> 이름 변경". 사용자 결정대로 좁은 범위에서만 한다. HEAD 가 아닌 커밋의 메시지를 바꾸면 그 커밋부터 HEAD 까지가 새 해시로 다시 써진다. 고른 커밋의 메시지만 갈고 뒤 커밋은 트리 · 작성자 · 작성 시각 · 부모 관계를 그대로 두어(LibGit2Sharp Refs.RewriteHistory) 해시만 바뀐다. 바꾸기 전 자리는 refs/original/날짜-시각/ 에 남긴다. 막는 자리(메뉴는 켜 두고 눌렀을 때 까닭과 푸는 길을 알림으로): 작업 중 · 커밋 안 한 변경 · 지금 브랜치 조상 아님 · 원격에 올라감 · 다른 로컬 브랜치도 품음 · 구간에 태그 · 사이에 병합 커밋. 파일 비교 탭의 저장 안 한 편집은 우클릭해 줄을 고를 때 기존 창으로 묻는다. - CommitReword (새): 막을 까닭 · 확인 글 · 완료 알림 · 새 메시지 다듬기 - GitService.InspectReword / RewordCommit, RewordInfo - RewordWindow (새): 여러 줄 입력, [바꾸기] 는 기본 단추가 아님 - GitView: MergeHub 첫 우클릭 메뉴, 우클릭한 줄을 먼저 고른다 - 선택이 고친 커밋의 새 해시를 따라간다 - 화면에서 찾아 고침: 새 메시지가 끝 줄바꿈 없이 저장됐다 -> git -m 처럼 다듬는다(뒤 커밋까지 다듬지 않게 새 메시지 하나만) 하네스 MergeSaveTest +40 (272건, 22벌 1,247건) · 빌드 경고 0 · 오류 0. 화면 확인: 되는 자리 1(트리 · 작성자 · 작성 시각 같음, 되돌리기 확인) · 막는 자리 8 모두 막힘. Co-Authored-By: Claude Opus 5 --- doc/history/2026-09-18-11차.md | 70 +++++++++++- doc/history/changelog.md | 1 + doc/진행상황.md | 2 +- src/MergeHub/Models/GitModels.cs | 20 ++++ src/MergeHub/Services/CommitReword.cs | 144 ++++++++++++++++++++++++ src/MergeHub/Services/GitService.cs | 103 +++++++++++++++++ src/MergeHub/Services/IGitService.cs | 4 + src/MergeHub/ViewModels/GitViewModel.cs | 69 ++++++++++++ src/MergeHub/Views/GitView.xaml | 8 ++ src/MergeHub/Views/GitView.xaml.cs | 12 ++ src/MergeHub/Views/RewordWindow.xaml | 65 +++++++++++ src/MergeHub/Views/RewordWindow.xaml.cs | 58 ++++++++++ 12 files changed, 553 insertions(+), 3 deletions(-) create mode 100644 src/MergeHub/Services/CommitReword.cs create mode 100644 src/MergeHub/Views/RewordWindow.xaml create mode 100644 src/MergeHub/Views/RewordWindow.xaml.cs diff --git a/doc/history/2026-09-18-11차.md b/doc/history/2026-09-18-11차.md index 224cf60..45b511d 100644 --- a/doc/history/2026-09-18-11차.md +++ b/doc/history/2026-09-18-11차.md @@ -193,8 +193,74 @@ A-1 에서 값으로 확인한 것 — 지운 파일은 "삭제되어 건너뜀 ### 남은 것 -- **8. 여러 줄 커밋 메시지** — 칸이 한 줄이라 본문을 칠 수 없다. 이번 범위 밖으로 적어 둔다(병합 커밋 본문은 앱이 저절로 붙인다). -- C-2(마지막이 아닌 커밋의 메시지 수정)는 C-1 을 쓰면서 더 필요한지 보고 정한다. +- **8. 여러 줄 커밋 메시지** — 칸이 한 줄이라 본문을 칠 수 없다. 이번 범위 밖으로 적어 둔다(병합 커밋 본문은 앱이 저절로 붙인다). C-2 의 [메시지 바꾸기] 창은 여러 줄을 받는다. +- C-2 는 아래(2026-09-22 완료). + +## C-2. 마지막이 아닌 커밋의 메시지 바꾸기 (2026-09-18 ~ 09-22, 완료 — 좁은 범위) + +사용자 결정(2026-09-18): **한다 — 좁은 범위로.** 화면은 기획안 문구대로 **이력 줄 우클릭 메뉴**. + +### 고치기 전 + +- 이력 줄을 우클릭해도 **메뉴가 없고**, 이력 탭 단추는 Revert · Cherry-pick · Reset · 여기부터 · 해제 · 변경분 추출뿐이었다(`e11-c2-before.ps1`). 고른 커밋의 메시지를 바꿀 길이 없었다. +- 메시지는 커밋 해시에 들어가므로, HEAD 가 아닌 커밋을 바꾸면 **그 커밋부터 HEAD 까지가 새 해시로 다시 써진다**. 아직 안 올린 커밋이면 브랜치가 새 줄기로 옮겨 갈 뿐이지만, 올린 뒤라면 남의 이력과 갈라진다(사용자에게 설명함). + +### 만든 것 + +| 자리 | 한 일 | +|---|---| +| `Services/CommitReword.cs` (새) | 막을 까닭 — 고른 커밋 없음 · 작업 중 · 커밋 안 한 변경 · 지금 브랜치 조상 아님 · 원격에 올라감 · 다른 로컬 브랜치도 품음 · 구간에 태그 · 사이에 병합 커밋. 먼저 풀어야 할 것부터 말하고 **푸는 길**까지 적는다. 확인 글 · 완료 알림 · 메뉴 설명. `Clean` — 새 메시지를 git `-m` 처럼 다듬는다(아래) | +| `Services/GitService.cs` · `IGitService.cs` · `Models/GitModels.cs` | `InspectReword` — 저장소에서 사실만 읽는다(다시 쓸 수 · 병합 수 · 올라간 원격 브랜치 · 다른 브랜치 · 태그). `RewordCommit` — LibGit2Sharp `Refs.RewriteHistory` 로 **고른 커밋의 메시지만** 갈고 트리 · 작성자 · 작성 시각 · 부모 관계는 그대로. 바꾸기 전 자리는 `refs/original/날짜-시각/` 에 남긴다(같은 자리에 두 번 쓰면 libgit2 가 막아서 시각을 붙임). 고친 커밋의 새 해시도 돌려준다 | +| `Views/RewordWindow.xaml(.cs)` (새 창) | 새 메시지를 받는 창. 고친 커밋 한 줄(해시 · 작성자 · 날짜) · 몇 개가 다시 써지는지를 늘 보이고, **여러 줄**을 받는다. 지금 메시지를 미리 넣어 전체를 골라 둔다. [바꾸기] 는 기본 단추가 아니다(Enter 는 줄바꿈) | +| `Views/GitView.xaml(.cs)` | 이력 목록의 우클릭 메뉴 **[메시지 바꾸기...]** — MergeHub 의 첫 우클릭 메뉴. 목록은 오른쪽 누름으로 선택이 안 바뀌므로 **우클릭한 줄을 먼저 고른다**(10차 B-1 처럼 저장 안 한 편집이 있으면 먼저 묻고 [취소] 면 메뉴도 안 연다) | +| `ViewModels/GitViewModel.cs` | 막힐 자리면 메뉴를 끄지 않고 **눌렀을 때 까닭을 알림으로** — 까닭은 저장소를 읽어 봐야 알고, 꺼 두면 왜 안 되는지 알 길이 없다. 되면 창 → 확인 창 → 실행 → 고른 커밋이 **새 해시**를 따라간다 | +| 하네스 `MergeSaveTest` | **+40건(232 → 272)** — 막는 여덟 가지와 보는 순서 · 확인 글 · 완료 알림 · 빈 메시지 · 안 바뀐 메시지 · 다듬기 아홉 | + +### 화면에서 찾아 고친 것 — 끝 줄바꿈 + +첫 회차에 새 메시지가 **줄바꿈 없이** 저장됐다(`셋` 으로 끝남). git 이 만든 커밋은 늘 `\n` 으로 끝나고, MergeHub 의 [커밋] · [커밋 수정]도 libgit2 가 다듬는다. 칸을 어떻게 고쳤느냐에 따라 끝 줄바꿈이 있다 없다 했다. +libgit2 의 다듬기(`PrettifyMessages`)를 켜면 **손대지 않은 뒤 커밋들 메시지까지** 다듬어지므로, 새 메시지 하나만 `CommitReword.Clean` 으로 다듬는다(줄 끝 공백 · 앞뒤 빈 줄 없앰, 빈 줄 여럿은 하나, 끝은 줄바꿈 하나, `#` 줄 · 들여쓰기는 둠). 검사 9건을 먼저 넣어 실패를 보고 고쳤다. + +같은 회차의 다른 둘은 **스크립트 탓**으로 가렸다(`e11-c2-probe.ps1`): 첫 우클릭이 안 먹은 것은 창을 앞으로 부르는 도우미가 다른 프로그램 창을 앞으로 가져와 클릭이 앱에 안 닿은 것, 한글이 한 글자만 들어간 것은 8ms 간격으로 몰아 친 것. 150ms 간격으로 치면 그대로 들어갔다. + +### 고친 뒤 화면에서 (`e11-c2-setup.sh` · `e11-c2a.ps1` · `e11-c2b.ps1` · `e11-c2c.ps1`) + +시험 저장소: `reword` 브랜치에 커밋 넷(작성자 박지훈 · 이수진, 셋째는 일부러 **"셋제 커밋"** 오타). 막힐 자리로 `pushed`(bare 원격에 올림) · `merged`(사이에 병합) · `shared` + `shared-copy` · `tagged`(v9.9). + +**되는 자리** — "셋제 커밋" 우클릭 → [메시지 바꾸기...] + +| 무엇 | 결과 | +|---|---| +| 우클릭 | 그 줄이 먼저 골라지고 메뉴 `메시지 바꾸기...` | +| 창 | `83ed0b2 · 박지훈 · 2026-09-16 11:00` · "이 커밋과 그 뒤 1개, 모두 2개가 새 해시로 다시 써집니다" · 칸에 `셋제 커밋` (`e11-c2-window.png`) | +| [취소] | HEAD `4c0263e` 그대로 | +| "셋째 커밋" 치고 [바꾸기] | 확인 창 "83ed0b2 "셋제 커밋" 의 메시지를 바꿉니다. 이 커밋과 그 뒤 1개, 모두 2개가 새 해시로 다시 써집니다. 내용 · 작성자 · 작성 시각은 그대로입니다. 바꾸기 전 HEAD 4c0263e 는 로그에 남겨…" (`e11-c2-confirm.png`) | +| [예] | 알림 `메시지 바꿈 완료 (옛 HEAD 4c0263e → 새 HEAD 1b11c03) · 커밋 2개 다시 씀` · 로그 `… · 되돌릴 자리 refs/original/20260922-102604/` | +| git 대조 | 넷째 · 셋째: **트리 해시 · 작성자 · 작성 시각 같음**, 해시만 새것. 제목은 셋째만 바뀜. 둘째 아래는 해시까지 그대로 · 커밋 수 같음 · 워킹트리 깨끗 | +| 메시지 끝 | `셋째 커밋\n` — 손 안 댄 넷째(`넷째 커밋\n`)와 같은 꼴 | +| 고른 커밋 | 새 해시 `b2d9880` 을 따라감 | +| 되돌리기 | `git reset --hard refs/original/20260922-102604/heads/reword` → `4c0263e … 셋제 커밋` 이 그대로 돌아옴 | + +**막히는 자리** — 모두 창이 안 뜨고 HEAD 그대로, 알림에 까닭 + +| 자리 | 알림 | +|---|---| +| 올린 커밋 | "이 커밋은 origin2/pushed 에 이미 올라가 있습니다. 올린 커밋을 다시 쓰면 남의 이력과 갈라져 막습니다." | +| 사이에 병합 | "고른 커밋과 HEAD 사이에 병합 커밋이 1개 있습니다. 병합 이력은 다시 쓰지 않습니다." | +| 다른 브랜치도 품음 | "이 커밋을 다른 브랜치도 품고 있습니다(shared-copy). 그 브랜치까지 다시 써야 해서 막습니다." | +| 구간에 태그 | "다시 쓸 구간에 태그가 있습니다(v9.9). 태그가 가리키는 커밋이 사라져 막습니다." | +| 작업 중(병합 충돌 26개) | "병합 · rebase · cherry-pick · revert 를 끝낸 뒤에 쓸 수 있습니다." | +| 커밋 안 한 변경 | "커밋하지 않은 변경이 있습니다. 먼저 커밋하거나 stash 한 뒤에 바꾸세요." | +| 다른 브랜치에만([모든 브랜치]) | "지금 브랜치에서 닿지 않는 커밋입니다. 그 커밋이 있는 브랜치로 체크아웃한 뒤에 바꾸세요." (`e11-c2-other.png`) | +| 파일 비교 탭에 저장 안 한 편집 | 우클릭하자 `저장하지 않은 편집` 창("Common/Util/Unit103.cs 에 저장하지 않은 편집이 있습니다. 다른 커밋을 봅니다.") → [취소] 면 메뉴도 안 열리고 선택 그대로 (`e11-c2-unsaved.png`) | + +- 하네스 22벌 **1,247건** 통과(1,207 → +40) · 빌드 경고 0 · 오류 0 · 설정 파일 해시 같음. + +### 알아 둘 것 + +- 판단은 **마지막 fetch 시점** 기준이다. fetch 전에 남이 올린 것까지는 모른다(C-1 과 같음). +- 되돌릴 자리 `refs/original/…` 은 이력 목록에 안 보인다(목록은 브랜치만 훑음). 지우는 단추는 두지 않았다 — 되돌릴 일이 없으면 그대로 두어도 된다. +- HEAD 커밋도 이 메뉴로 바꿀 수 있다. [커밋 수정] 과 달리 **스테이징한 파일이 들어가지 않는** 메시지만 고치는 길이다. ## F-12. C# 코드 스타일 맞춤의 기본값을 AURORA 로 (2026-09-18, 완료) diff --git a/doc/history/changelog.md b/doc/history/changelog.md index 8c1c3dd..29217f9 100644 --- a/doc/history/changelog.md +++ b/doc/history/changelog.md @@ -8,6 +8,7 @@ | 날짜 | 항목 | 커밋 | |---|---|---| +| 2026-09-22 | **11차 C-2** — 마지막이 아닌 커밋의 메시지를 바꾼다(기획안 "커밋 우클릭 → 이름 변경", 사용자 결정 좁은 범위). 이력 줄 우클릭 [메시지 바꾸기...] — MergeHub 첫 우클릭 메뉴이고, 우클릭한 줄을 먼저 고른다. 여러 줄 창과 확인 창("이 커밋과 그 뒤 N개가 새 해시로 다시 써집니다")을 거쳐, 고른 커밋 메시지만 갈고 뒤 커밋은 트리 · 작성자 · 작성 시각 그대로 다시 쓴다. 바꾸기 전 자리를 `refs/original/…` 에 남기고 알림 · 로그에 옛 → 새 HEAD. 올린 커밋 · 사이 병합 · 다른 브랜치도 품음 · 태그 · 작업 중 · 커밋 안 한 변경 · 다른 브랜치에만 있는 커밋이면 까닭을 알림으로 말하고 막는다. 화면에서 새 메시지가 끝 줄바꿈 없이 저장되는 것을 찾아 git `-m` 처럼 다듬게 함. 하네스 MergeSaveTest +40(272건) | 아래 | | 2026-09-18 | **11차 C-1 (마무리)** — [커밋 수정] 을 누르기 전에 **먼저 묻는다**(사용자 결정): 스테이징해 둔 파일이 있으면 "파일 N개도 이 커밋에 들어갑니다", 이미 올린 커밋이면 "origin/… 에 이미 올라가 있습니다 · 고치면 다음 Push 가 거부됩니다". 둘 다면 두 줄을 한 창에. [아니오] 면 커밋도 메시지 칸도 그대로 둔다. 막지 않는 까닭은 앞선 커밋 수가 마지막 fetch 시점 기준이라 확실치 않아서다. 하네스 MergeSaveTest +7(232건) | 아래 | | 2026-09-18 | **11차 C-1 (일부)** — [커밋 수정]의 여덟 가지를 화면에서 재현한 뒤 넷을 고침: [마지막 메시지]가 이력 맨 위(딴 브랜치일 수 있음) 대신 **HEAD 커밋** 메시지를 읽고, 고쳐 써도 **원래 작성자 · 작성 시각을 지키며**(git 과 같게), 병합 · rebase · cherry-pick · revert **진행 중에는 단추를 끄고**(예전엔 눌러서 실패), 알림에 **"옛 해시 → 새 해시 · 파일 N개 포함"**. 하네스 MergeSaveTest +15(225건). 스테이징 파일 처리(2)와 이미 올린 커밋(6)은 사용자 결정 대기 | 아래 | | 2026-09-18 | **11차 F-1 에 덧 (추가 요청)** — 지시서의 F-1(칸 이름 · 어느 파일인지)에 "머지 주석에도 어디서 가져왔는지(REMOTE/BASE · 브랜치/해시)를 남기고, 그에 맞춰 무시 조건도 고친다" 를 적음. 고칠 곳(`MergeNote.NoteBody` · `LooksLikeNote` · `TailStart` · `CompareRules` · `FolderComparer` · `NoteConflict` · `NoteRows`)과 옛 주석도 그대로 무시되어야 한다는 기준까지. 코드 변경 없음 | 아래 | diff --git a/doc/진행상황.md b/doc/진행상황.md index b5024f9..3b971a6 100644 --- a/doc/진행상황.md +++ b/doc/진행상황.md @@ -351,7 +351,7 @@ | 상태 | 항목 | |---|---| | **완료** | C-1 있는 [커밋 수정] 바로잡기 — 여덟 가지를 화면에서 재현한 뒤 **1 · 3 · 4 · 7 고침**([마지막 메시지]가 HEAD 것 · 작성자와 작성 시각 보존 · 작업 중 단추 끔 · 알림에 "옛 → 새 해시 · 파일 N개 포함"), 이어서 **2 · 6 은 "먼저 묻기"**(사용자 결정) — 스테이징해 둔 파일이 있거나 이미 올린 커밋이면 확인 창을 띄우고 [아니오] 면 아무것도 안 바뀐다. 하네스 MergeSaveTest +22(232건). 8(여러 줄 메시지 못 침)만 범위 밖. 옛 항목: — [마지막 메시지] 가 목록 맨 위(다른 브랜치일 수 있음) · 스테이징 파일이 말없이 들어감 · 원래 작성자 · 시각이 바뀜 · 작업 중에도 켜짐 · 병합 커밋 · 올린 커밋 · 옛 해시 · `#` 줄 | -| 대기 | C-2 마지막 커밋이 아닌 커밋의 메시지 수정 (기획안 "커밋 우클릭 → 이름 변경") — 할지 먼저 정함 | +| **완료** | C-2 마지막 커밋이 아닌 커밋의 메시지 수정 (기획안 "커밋 우클릭 → 이름 변경") — 사용자 결정 **좁은 범위**. 이력 줄 우클릭 [메시지 바꾸기...] → 여러 줄 창 → 확인 창 → 고른 커밋 메시지만 갈고 그 뒤를 같은 트리 · 작성자 · 작성 시각으로 다시 씀(`RewriteHistory`), 되돌릴 자리 `refs/original/…`, 선택이 새 해시를 따라감. 올린 커밋 · 사이에 병합 · 다른 브랜치도 품음 · 태그 · 작업 중 · 커밋 안 한 변경 · 다른 브랜치에만 · 저장 안 한 편집 여덟 자리가 화면에서 막힘. 새 메시지 끝 줄바꿈을 git 처럼 다듬음(화면에서 찾음). 하네스 MergeSaveTest +40(272건) | ### D. 시험 도구 정리 diff --git a/src/MergeHub/Models/GitModels.cs b/src/MergeHub/Models/GitModels.cs index a9084b0..94ec86a 100644 --- a/src/MergeHub/Models/GitModels.cs +++ b/src/MergeHub/Models/GitModels.cs @@ -56,6 +56,26 @@ namespace MergeHub.Models public bool IsStaged => StagedState is not (WorkingFileState.Unmodified or WorkingFileState.Untracked or WorkingFileState.Ignored); } + /// + /// 고른 커밋의 메시지를 바꿔도 되는지 재 본 것(11차 C-2). + /// 여기 담기는 것은 저장소에서 읽은 사실뿐이고, 되는지 안 되는지와 그 문장은 CommitReword 가 정한다. + /// + /// 그런 커밋이 있는가. + /// 지금 HEAD 에서 닿는가(HEAD 자신 포함). + /// 다시 써야 할 커밋 수(고른 커밋 포함). + /// 그 가운데 병합 커밋 수. + /// 이미 올라가 있는 원격 브랜치 이름. 없으면 null. + /// 그 커밋을 함께 품은 다른 로컬 브랜치들. + /// 다시 쓸 구간에 걸린 태그들. + public sealed record RewordInfo( + bool Exists, + bool IsAncestorOfHead, + int RewriteCount, + int MergeCommits, + string? PushedTo, + IReadOnlyList OtherBranches, + IReadOnlyList TagsInRange); + /// 저장소 전체 상태 요약. (상단 Working Tree 배지용) public sealed record RepositoryStatusInfo( bool IsClean, diff --git a/src/MergeHub/Services/CommitReword.cs b/src/MergeHub/Services/CommitReword.cs new file mode 100644 index 0000000..92d4b46 --- /dev/null +++ b/src/MergeHub/Services/CommitReword.cs @@ -0,0 +1,144 @@ +using System; +using System.Collections.Generic; + +namespace MergeHub.Services +{ + /// + /// 마지막이 아닌 커밋의 메시지를 바꿀 수 있는가, 바꾸기 전에 무엇을 보여야 하는가(11차 C-2). + /// + /// 왜 따로 정하나 - 메시지는 커밋 해시에 들어간다. 고른 커밋의 메시지를 바꾸면 그 커밋부터 + /// HEAD 까지가 모두 새 해시로 다시 써진다(트리 · 작성자 · 작성 시각은 그대로인 다른 커밋이 된다). + /// 그 커밋이 이미 원격에 올라갔거나 다른 브랜치도 품고 있으면 남의 이력과 갈라진다. + /// 그래서 2026-09-18 사용자 결정대로 좁은 범위에서만 켠다 - 지금 브랜치의 조상이고, + /// 원격에 아직 없고, 사이에 병합 커밋 · 태그가 없고, 작업 중이 아니고, 워킹트리가 깨끗할 때. + /// + /// 막지 않고 묻기만 하는 [커밋 수정](C-1)과 기준이 다른 까닭 - amend 는 한 칸만 바꾸지만 + /// 이것은 뒤따르는 커밋까지 모두 갈아 치운다. 되돌릴 자리를 남겨도(refs/original · reflog) + /// 남이 이미 받아 간 뒤에는 되돌릴 수 없다. + /// + public static class CommitReword + { + /// + /// 막을 까닭. 바꿔도 되면 null. + /// 화면에서 그대로 보여 줄 문장이므로 무엇을 하면 풀리는지까지 적는다. + /// + /// 이력에서 커밋을 골랐는가. + /// 병합 · rebase · cherry-pick · revert 가 도는 중인가. + /// 커밋하지 않은 변경이 있는가. + /// 고른 커밋이 지금 HEAD 에서 닿는가(HEAD 자신 포함). + /// 이미 올라간 원격 브랜치 이름. 안 올라갔으면 null. + /// 그 커밋을 함께 품은 다른 로컬 브랜치 이름들. + /// 다시 쓸 구간에 걸린 태그 이름들. + /// 다시 쓸 구간의 병합 커밋 수(고른 커밋 포함). + public static string? BlockReason(bool hasCommit, bool operationInProgress, bool workingTreeDirty, + bool isAncestorOfHead, string? pushedTo, + IReadOnlyList? otherBranches, IReadOnlyList? tagsInRange, + int mergeCommits) + { + if (!hasCommit) return "이력에서 커밋을 먼저 고르세요."; + + if (operationInProgress) + return "병합 · rebase · cherry-pick · revert 를 끝낸 뒤에 쓸 수 있습니다."; + + if (workingTreeDirty) + return "커밋하지 않은 변경이 있습니다. 먼저 커밋하거나 stash 한 뒤에 바꾸세요."; + + if (!isAncestorOfHead) + return "지금 브랜치에서 닿지 않는 커밋입니다. 그 커밋이 있는 브랜치로 체크아웃한 뒤에 바꾸세요."; + + if (!string.IsNullOrWhiteSpace(pushedTo)) + return $"이 커밋은 {pushedTo} 에 이미 올라가 있습니다. 올린 커밋을 다시 쓰면 남의 이력과 갈라져 막습니다."; + + if (otherBranches is { Count: > 0 }) + return $"이 커밋을 다른 브랜치도 품고 있습니다({Join(otherBranches)}). 그 브랜치까지 다시 써야 해서 막습니다."; + + if (tagsInRange is { Count: > 0 }) + return $"다시 쓸 구간에 태그가 있습니다({Join(tagsInRange)}). 태그가 가리키는 커밋이 사라져 막습니다."; + + if (mergeCommits > 0) + return $"고른 커밋과 HEAD 사이에 병합 커밋이 {mergeCommits}개 있습니다. 병합 이력은 다시 쓰지 않습니다."; + + return null; + } + + /// 바꾸기 전에 띄울 확인 글. 무엇이 몇 개 다시 써지는지와 되돌릴 자리를 함께 적는다. + /// 고른 커밋의 짧은 해시. + /// 고른 커밋의 지금 제목. + /// 다시 쓸 커밋 수(고른 커밋 포함). + /// 지금 HEAD 짧은 해시 - 되돌릴 자리. + public static string Confirm(string shortSha, string subject, int rewriteCount, string headShortSha) + { + var tail = rewriteCount <= 1 + ? "이 커밋이 새 해시로 다시 써집니다" + : $"이 커밋과 그 뒤 {rewriteCount - 1}개, 모두 {rewriteCount}개가 새 해시로 다시 써집니다"; + + return $"{shortSha} \"{Trim(subject)}\" 의 메시지를 바꿉니다.\n\n" + + $"{tail}. 내용 · 작성자 · 작성 시각은 그대로입니다.\n" + + $"바꾸기 전 HEAD {headShortSha} 는 로그에 남겨 되돌릴 수 있게 합니다.\n\n" + + "계속할까요?"; + } + + /// 바꾼 뒤 알릴 글. + public static string Done(string? oldHead, string? newHead, int rewriteCount) => + $"메시지 바꿈 완료 (옛 HEAD {Short(oldHead)} → 새 HEAD {Short(newHead)}) · 커밋 {rewriteCount}개 다시 씀"; + + /// + /// 새로 친 메시지를 git 이 -m 으로 받을 때처럼 다듬는다(cleanup=whitespace). + /// 줄 끝 공백 · 앞뒤 빈 줄을 없애고, 빈 줄 여럿은 하나로, 끝은 줄바꿈 하나. # 줄과 들여쓰기는 둔다. + /// + /// 왜 - 다듬지 않고 쓰니 칸을 어떻게 고쳤느냐에 따라 끝 줄바꿈이 있다 없다 했다 + /// (2026-09-22 화면: "셋" 으로 끝난 커밋. git 이 만든 커밋은 모두 줄바꿈으로 끝난다). + /// libgit2 의 다듬기(PrettifyMessages)를 켜면 손대지 않은 뒤 커밋들 메시지까지 다듬어지므로, + /// 새 메시지 하나만 여기서 다듬는다. + /// + /// 다듬은 글. 남는 것이 없으면 빈 글. + public static string Clean(string? message) + { + var lines = (message ?? string.Empty).Replace("\r\n", "\n").Replace('\r', '\n').Split('\n'); + var kept = new List(lines.Length); + var blank = false; + + foreach (var raw in lines) + { + var line = raw.TrimEnd(); + if (line.Length == 0) + { + blank = kept.Count > 0; // 앞 빈 줄은 버리고, 사이 빈 줄은 하나만 남긴다 + continue; + } + + if (blank) kept.Add(string.Empty); + blank = false; + kept.Add(line); + } + + return kept.Count == 0 ? string.Empty : string.Join("\n", kept) + "\n"; + } + + /// 메시지가 비면 바꾸지 않는다 - 빈 메시지 커밋은 만들지 않는다. + public static bool IsUsableMessage(string? message) => !string.IsNullOrWhiteSpace(message); + + /// 바뀐 것이 없으면 다시 쓸 까닭도 없다(해시만 바뀌는 헛일). + public static bool Changed(string? oldMessage, string? newMessage) => + Normalize(oldMessage) != Normalize(newMessage); + + /// 우클릭 메뉴에 걸어 둘 설명 - 왜 꺼졌는지는 눌러 보면 알려 준다. + public static string MenuHint => + "고른 커밋의 메시지를 바꿉니다. 그 커밋부터 HEAD 까지 새 해시로 다시 써집니다 (원격에 올리기 전에만)"; + + private static string Normalize(string? text) => + (text ?? string.Empty).Replace("\r\n", "\n").Trim(); + + private static string Join(IReadOnlyList names) => + names.Count <= 3 ? string.Join(" · ", names) : string.Join(" · ", names[0], names[1], names[2]) + $" 외 {names.Count - 3}개"; + + private static string Trim(string? subject) + { + var one = Normalize(subject).Replace("\n", " "); + return one.Length > 40 ? one.Substring(0, 40) + "…" : one; + } + + private static string Short(string? sha) => + string.IsNullOrEmpty(sha) ? "-" : (sha!.Length > 7 ? sha.Substring(0, 7) : sha); + } +} diff --git a/src/MergeHub/Services/GitService.cs b/src/MergeHub/Services/GitService.cs index 55dcbc9..58dfff4 100644 --- a/src/MergeHub/Services/GitService.cs +++ b/src/MergeHub/Services/GitService.cs @@ -495,6 +495,109 @@ namespace MergeHub.Services return commit.Sha; } + // ================= 마지막이 아닌 커밋의 메시지 바꾸기 (11차 C-2) ================= + + /// + /// 고른 커밋의 메시지를 바꿔도 되는지 재 본다(바꾸지는 않는다). + /// 판단 문장은 가 만든다 - 여기서는 저장소에서 읽을 수 있는 사실만 모은다. + /// + public RewordInfo InspectReword(string sha) + { + var target = ResolveCommit(sha); + var head = Repo.Head?.Tip; + if (target is null || head is null) + return new RewordInfo(false, false, 0, 0, null, Array.Empty(), Array.Empty()); + + // HEAD 에서 닿는가 - 합류점이 자기 자신이면 조상이다(자기 자신도 포함). + var isAncestor = string.Equals(Repo.ObjectDatabase.FindMergeBase(target, head)?.Sha, target.Sha, StringComparison.Ordinal); + if (!isAncestor) + return new RewordInfo(true, false, 0, 0, null, Array.Empty(), Array.Empty()); + + // 다시 쓸 커밋 = 고른 커밋 + HEAD 에서 닿지만 고른 커밋에서는 닿지 않는 것들. + var after = Repo.Commits.QueryBy(new CommitFilter + { + IncludeReachableFrom = head, + ExcludeReachableFrom = target, + SortBy = CommitSortStrategies.Topological + }).ToList(); + + var range = new List(after.Count + 1) { target }; + range.AddRange(after); + + var merges = range.Count(c => c.Parents.Count() > 1); + + // 이미 올라간 커밋인가 - 원격 추적 브랜치 아무 곳에나 들어 있으면 올라간 것으로 본다. + string? pushedTo = null; + foreach (var remote in Repo.Branches.Where(b => b.IsRemote)) + { + if (remote.Tip is null) continue; + if (!string.Equals(Repo.ObjectDatabase.FindMergeBase(target, remote.Tip)?.Sha, target.Sha, StringComparison.Ordinal)) continue; + pushedTo = remote.FriendlyName; + break; + } + + // 다른 로컬 브랜치도 품고 있으면 그 브랜치까지 다시 써야 한다 - 막는다. + var current = Repo.Head?.FriendlyName; + var others = Repo.Branches + .Where(b => !b.IsRemote && b.Tip is not null && !string.Equals(b.FriendlyName, current, StringComparison.Ordinal)) + .Where(b => string.Equals(Repo.ObjectDatabase.FindMergeBase(target, b.Tip)?.Sha, target.Sha, StringComparison.Ordinal)) + .Select(b => b.FriendlyName) + .ToList(); + + var shas = new HashSet(range.Select(c => c.Sha), StringComparer.Ordinal); + var tags = Repo.Tags + .Where(t => t.Target?.Sha is { } s && shas.Contains(s)) + .Select(t => t.FriendlyName) + .ToList(); + + return new RewordInfo(true, true, range.Count, merges, pushedTo, others, tags); + } + + /// + /// 고른 커밋의 메시지를 바꾸고 그 뒤 커밋을 같은 내용으로 다시 쓴다(`git rebase -i` 의 reword). + /// + /// 트리 · 작성자 · 작성 시각 · 부모 관계는 그대로 두고 메시지만 갈아 끼우므로, 남는 것은 해시뿐이다. + /// 바꾸기 전 자리는 refs/original/… 아래에 남겨 되돌릴 수 있게 한다 - 같은 자리에 두 번 쓰면 + /// libgit2 가 "이미 있다" 로 막으므로 시각을 붙여 새 자리에 남긴다. + /// + /// 바꾸기 전 HEAD · 바꾼 뒤 HEAD · 다시 쓴 커밋 수 · 되돌릴 자리(백업 ref 이름) · 고친 커밋의 새 해시. + public (string OldHead, string NewHead, int Rewritten, string BackupRef, string NewTarget) RewordCommit(string sha, string message) + { + // 새 메시지는 git 이 -m 으로 받을 때처럼 다듬는다(끝 줄바꿈 하나 등) - CommitReword.Clean 참고. + var cleaned = CommitReword.Clean(message); + if (cleaned.Length == 0) + throw new ArgumentException("커밋 메시지가 비어 있습니다.", nameof(message)); + + var target = ResolveCommit(sha) ?? throw new ArgumentException($"커밋을 찾을 수 없습니다: {sha}", nameof(sha)); + var oldHead = Repo.Head.Tip?.Sha ?? string.Empty; + var info = InspectReword(target.Sha); + var backup = $"refs/original/{DateTime.Now:yyyyMMdd-HHmmss}/"; + + Repo.Refs.RewriteHistory(new RewriteHistoryOptions + { + BackupRefsNamespace = backup, + // 켜면 손대지 않은 뒤 커밋들 메시지까지 다듬어진다. 새 메시지만 위에서 다듬었다. + PrettifyMessages = false, + CommitHeaderRewriter = c => string.Equals(c.Sha, target.Sha, StringComparison.Ordinal) + ? CommitRewriteInfo.From(c, message: cleaned) + : CommitRewriteInfo.From(c) + }, new[] { target }); + + var newHead = Repo.Head.Tip?.Sha ?? string.Empty; + + // 고친 커밋의 새 자리 - 구간에 병합 커밋이 없는 것을 이미 막아 두었으므로 첫 부모를 따라 + // 다시 쓴 개수만큼 내려가면 그 커밋이다. 두 탭의 고른 커밋이 새 해시를 따라가는 데 쓴다. + var newTarget = Repo.Head.Tip; + for (var i = 1; i < info.RewriteCount && newTarget is not null; i++) + newTarget = newTarget.Parents.FirstOrDefault(); + + Report($"커밋 메시지 바꿈: {Short(oldHead)} → {Short(newHead)} · 커밋 {info.RewriteCount}개 다시 씀 · 되돌릴 자리 {backup}"); + return (oldHead, newHead, info.RewriteCount, backup, newTarget?.Sha ?? newHead); + } + + private static string Short(string? sha) => + string.IsNullOrEmpty(sha) ? "-" : (sha!.Length > 7 ? sha.Substring(0, 7) : sha); + // ================= 병합 ================= public bool IsMergeInProgress => diff --git a/src/MergeHub/Services/IGitService.cs b/src/MergeHub/Services/IGitService.cs index 471f820..f7ff3af 100644 --- a/src/MergeHub/Services/IGitService.cs +++ b/src/MergeHub/Services/IGitService.cs @@ -66,6 +66,10 @@ namespace MergeHub.Services void Unstage(params string[] paths); string Commit(string message, bool amend = false); + // ---- 마지막이 아닌 커밋의 메시지 바꾸기 (11차 C-2) ---------------- + RewordInfo InspectReword(string sha); + (string OldHead, string NewHead, int Rewritten, string BackupRef, string NewTarget) RewordCommit(string sha, string message); + // ---- 병합 --------------------------------------------------------- /// /// 브랜치를 현재 HEAD 로 병합. 기획안 6p 비목표("사람 확인 없는 자동 커밋 금지")에 따라 diff --git a/src/MergeHub/ViewModels/GitViewModel.cs b/src/MergeHub/ViewModels/GitViewModel.cs index 5b92d4b..2a02b1d 100644 --- a/src/MergeHub/ViewModels/GitViewModel.cs +++ b/src/MergeHub/ViewModels/GitViewModel.cs @@ -9,6 +9,7 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using MergeHub.Models; using MergeHub.Services; +using MergeHub.Views; using UbisamBase.Core.Messaging; namespace MergeHub.ViewModels @@ -576,6 +577,73 @@ namespace MergeHub.ViewModels private Task ResetAsync() => _workspace.RunAndRefreshAsync($"reset --{SelectedResetKind}", () => _workspace.Git.Reset(SelectedResetKind, SelectedCommit!.Sha)); + /// 우클릭 메뉴 [메시지 바꾸기...] 에 걸어 둘 설명. + public string RewordHint => CommitReword.MenuHint; + + /// + /// 이력에서 고른 커밋의 메시지를 바꾼다(11차 C-2, `git rebase -i` 의 reword). + /// + /// HEAD 가 아니면 그 커밋부터 HEAD 까지가 새 해시로 다시 써진다. 그래서 좁은 범위에서만 한다 - + /// 막을 까닭은 가 정하고, 여기서는 눌렀을 때 왜 안 되는지 말해 준다. + /// 메뉴를 꺼 두지 않는 까닭: 꺼 두면 왜 안 되는지 알 길이 없고, 까닭은 저장소를 읽어 봐야 안다. + /// + /// 저장 안 한 편집은 가 지금까지의 Git 작업과 같은 창으로 묻는다. + /// + [RelayCommand(CanExecute = nameof(HasCommit))] + private async Task RewordAsync() + { + var commit = SelectedCommit!; + var info = _workspace.Git.InspectReword(commit.Sha); + + var blocked = CommitReword.BlockReason( + info.Exists, AmendBlockedByOperation, !_workspace.IsWorkingTreeClean, + info.IsAncestorOfHead, info.PushedTo, info.OtherBranches, info.TagsInRange, info.MergeCommits); + + if (blocked is not null) + { + MessageUtil.ShowWarningToast($"메시지를 바꿀 수 없습니다 - {blocked}"); + return; + } + + var target = $"{commit.ShortSha} · {commit.AuthorName} · {commit.When.LocalDateTime:yyyy-MM-dd HH:mm}"; + var typed = RewordWindow.Ask(target, commit.Message, + info.RewriteCount <= 1 + ? "이 커밋이 새 해시로 다시 써집니다" + : $"이 커밋과 그 뒤 {info.RewriteCount - 1}개, 모두 {info.RewriteCount}개가 새 해시로 다시 써집니다"); + + if (typed is null) return; + + if (!CommitReword.IsUsableMessage(typed)) + { + MessageUtil.ShowWarningToast("메시지가 비어 있어 바꾸지 않았습니다"); + return; + } + + if (!CommitReword.Changed(commit.Message, typed)) + { + MessageUtil.ShowToast("메시지가 그대로라 다시 쓰지 않았습니다"); + return; + } + + if (!MessageUtil.ShowYesNo("커밋 메시지 바꾸기", + CommitReword.Confirm(commit.ShortSha, commit.MessageShort, info.RewriteCount, _workspace.HeadSha))) + return; + + var newTarget = string.Empty; + var done = await _workspace.RunAndRefreshAsync("커밋 메시지 바꾸기", () => + { + var result = _workspace.Git.RewordCommit(commit.Sha, typed); + newTarget = result.NewTarget; + _workspace.OnUi(() => + MessageUtil.ShowSuccessToast(CommitReword.Done(result.OldHead, result.NewHead, result.Rewritten))); + }); + + // 고른 커밋을 새 해시로 따라간다. 그냥 두면 옛 해시가 사라져 선택이 HEAD 로 떨어진다(9차 A-1 · A-2). + if (!done || newTarget.Length == 0) return; + if (_workspace.Commits.FirstOrDefault(c => string.Equals(c.Sha, newTarget, StringComparison.Ordinal)) is { } moved) + SelectedCommit = moved; + } + private bool CanRebase() => SelectedBranch is not null && !SelectedBranch.IsCurrentHead && !_workspace.IsRebaseInProgress; @@ -1009,6 +1077,7 @@ namespace MergeHub.ViewModels RevertCommand.NotifyCanExecuteChanged(); CherryPickCommand.NotifyCanExecuteChanged(); ResetCommand.NotifyCanExecuteChanged(); + RewordCommand.NotifyCanExecuteChanged(); RebaseCommand.NotifyCanExecuteChanged(); RebaseContinueCommand.NotifyCanExecuteChanged(); RebaseAbortCommand.NotifyCanExecuteChanged(); diff --git a/src/MergeHub/Views/GitView.xaml b/src/MergeHub/Views/GitView.xaml index fcd17d3..50031e2 100644 --- a/src/MergeHub/Views/GitView.xaml +++ b/src/MergeHub/Views/GitView.xaml @@ -415,6 +415,14 @@ + + + + + + diff --git a/src/MergeHub/Views/GitView.xaml.cs b/src/MergeHub/Views/GitView.xaml.cs index 0524404..fc679ac 100644 --- a/src/MergeHub/Views/GitView.xaml.cs +++ b/src/MergeHub/Views/GitView.xaml.cs @@ -30,6 +30,18 @@ namespace MergeHub.Views e.Handled = true; // [취소] 거나, 창이 누름을 끊어 목록이 스스로 못 고른다 if (answer == true) ChooseHistoryRow(vm, HistoryList.Items.IndexOf(target)); }; + // 우클릭한 줄을 먼저 고른다(11차 C-2). 목록은 오른쪽 누름으로는 선택을 바꾸지 않아서, 그대로 두면 + // 가리킨 줄이 아닌 다른 커밋의 메시지를 바꾸게 된다 - 다시 쓴 이력은 되돌리기 번거롭다. + HistoryList.PreviewMouseRightButtonDown += (_, e) => + { + if (ViewModel is not { } vm) return; + if (ItemsControl.ContainerFromElement(HistoryList, e.OriginalSource as DependencyObject) is not ListBoxItem item) return; + if (item.DataContext is not MergeHub.Models.CommitInfo target) return; + + var answer = vm.ConfirmChooseCommit(target); + if (answer == false) { e.Handled = true; return; } // [취소] - 메뉴도 열지 않는다 + ChooseHistoryRow(vm, HistoryList.Items.IndexOf(target)); + }; HistoryList.PreviewKeyDown += (_, e) => { if (ViewModel is not { } vm) return; diff --git a/src/MergeHub/Views/RewordWindow.xaml b/src/MergeHub/Views/RewordWindow.xaml new file mode 100644 index 0000000..a6c5be3 --- /dev/null +++ b/src/MergeHub/Views/RewordWindow.xaml @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + +