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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/MergeHub/Views/RewordWindow.xaml.cs b/src/MergeHub/Views/RewordWindow.xaml.cs
new file mode 100644
index 0000000..cd3447d
--- /dev/null
+++ b/src/MergeHub/Views/RewordWindow.xaml.cs
@@ -0,0 +1,58 @@
+using System.Windows;
+using System.Windows.Input;
+
+namespace MergeHub.Views
+{
+ ///
+ /// 고른 커밋의 새 메시지를 받는 창(11차 C-2).
+ ///
+ /// 커밋 메시지 칸(Git 탭 아래)은 한 줄이라 본문을 칠 수 없다(11차 C-1 의 8번). 여기서는 여러 줄을 받는다 -
+ /// 메시지를 고치러 들어온 자리에서까지 제목만 칠 수 있으면 고치는 뜻이 없다.
+ ///
+ /// [바꾸기] 는 기본 단추로 두지 않는다. Enter 는 줄바꿈이어야 하고, 실수로 눌러 이력을 다시 쓰면 안 된다.
+ ///
+ public partial class RewordWindow : Window
+ {
+ private RewordWindow(string target, string message, string countText)
+ {
+ InitializeComponent();
+
+ TargetText.Text = target;
+ CountText.Text = countText;
+ MessageBox.Text = message;
+
+ if (Application.Current?.MainWindow is { IsLoaded: true } main && !ReferenceEquals(main, this))
+ {
+ Owner = main;
+ WindowStartupLocation = WindowStartupLocation.CenterOwner;
+ }
+ else
+ {
+ WindowStartupLocation = WindowStartupLocation.CenterScreen;
+ }
+
+ // 고칠 글을 미리 넣어 두고 모두 골라 둔다 - 그대로 지우고 치거나, 한 글자만 고치거나 둘 다 편하게.
+ Loaded += (_, _) =>
+ {
+ MessageBox.Focus();
+ MessageBox.SelectAll();
+ };
+ }
+
+ /// 새 메시지를 받아 온다. [취소] 하거나 창을 닫으면 null.
+ /// 고친 커밋을 알아볼 한 줄(해시 · 작성자 · 날짜).
+ /// 지금 메시지 - 칸에 미리 넣어 둔다.
+ /// 몇 개가 다시 써지는지 아래에 늘 보이는 말.
+ public static string? Ask(string target, string currentMessage, string countText)
+ {
+ var window = new RewordWindow(target, currentMessage, countText);
+ return window.ShowDialog() == true ? window.MessageBox.Text : null;
+ }
+
+ private void TitleBar_MouseLeftButtonDown(object sender, MouseButtonEventArgs e) => DragMove();
+
+ private void Ok_Click(object sender, RoutedEventArgs e) => DialogResult = true;
+
+ private void Cancel_Click(object sender, RoutedEventArgs e) => DialogResult = false;
+ }
+}