11차 C-2: 이력 줄 우클릭으로 마지막이 아닌 커밋의 메시지를 바꾼다

기획안 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 <noreply@anthropic.com>
This commit is contained in:
김승준 2026-09-22 10:34:58 +09:00
parent 8f9ac13872
commit 588f3d75e9
12 changed files with 553 additions and 3 deletions

View File

@ -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, 완료)

View File

@ -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`)과 옛 주석도 그대로 무시되어야 한다는 기준까지. 코드 변경 없음 | 아래 |

View File

@ -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. 시험 도구 정리

View File

@ -56,6 +56,26 @@ namespace MergeHub.Models
public bool IsStaged => StagedState is not (WorkingFileState.Unmodified or WorkingFileState.Untracked or WorkingFileState.Ignored);
}
/// <summary>
/// 고른 커밋의 메시지를 바꿔도 되는지 재 본 것(11차 C-2).
/// 여기 담기는 것은 <b>저장소에서 읽은 사실</b>뿐이고, 되는지 안 되는지와 그 문장은 <c>CommitReword</c> 가 정한다.
/// </summary>
/// <param name="Exists">그런 커밋이 있는가.</param>
/// <param name="IsAncestorOfHead">지금 HEAD 에서 닿는가(HEAD 자신 포함).</param>
/// <param name="RewriteCount">다시 써야 할 커밋 수(고른 커밋 포함).</param>
/// <param name="MergeCommits">그 가운데 병합 커밋 수.</param>
/// <param name="PushedTo">이미 올라가 있는 원격 브랜치 이름. 없으면 null.</param>
/// <param name="OtherBranches">그 커밋을 함께 품은 다른 로컬 브랜치들.</param>
/// <param name="TagsInRange">다시 쓸 구간에 걸린 태그들.</param>
public sealed record RewordInfo(
bool Exists,
bool IsAncestorOfHead,
int RewriteCount,
int MergeCommits,
string? PushedTo,
IReadOnlyList<string> OtherBranches,
IReadOnlyList<string> TagsInRange);
/// <summary>저장소 전체 상태 요약. (상단 Working Tree 배지용)</summary>
public sealed record RepositoryStatusInfo(
bool IsClean,

View File

@ -0,0 +1,144 @@
using System;
using System.Collections.Generic;
namespace MergeHub.Services
{
/// <summary>
/// 마지막이 아닌 커밋의 메시지를 바꿀 수 있는가, 바꾸기 전에 무엇을 보여야 하는가(11차 C-2).
///
/// <b>왜 따로 정하나</b> - 메시지는 커밋 해시에 들어간다. 고른 커밋의 메시지를 바꾸면 그 커밋부터
/// HEAD 까지가 <b>모두 새 해시로 다시 써진다</b>(트리 · 작성자 · 작성 시각은 그대로인 다른 커밋이 된다).
/// 그 커밋이 이미 원격에 올라갔거나 다른 브랜치도 품고 있으면 남의 이력과 갈라진다.
/// 그래서 2026-09-18 사용자 결정대로 <b>좁은 범위</b>에서만 켠다 - 지금 브랜치의 조상이고,
/// 원격에 아직 없고, 사이에 병합 커밋 · 태그가 없고, 작업 중이 아니고, 워킹트리가 깨끗할 때.
///
/// 막지 않고 묻기만 하는 [커밋 수정](C-1)과 기준이 다른 까닭 - amend 는 한 칸만 바꾸지만
/// 이것은 뒤따르는 커밋까지 모두 갈아 치운다. 되돌릴 자리를 남겨도(refs/original · reflog)
/// 남이 이미 받아 간 뒤에는 되돌릴 수 없다.
/// </summary>
public static class CommitReword
{
/// <summary>
/// 막을 까닭. 바꿔도 되면 null.
/// 화면에서 그대로 보여 줄 문장이므로 <b>무엇을 하면 풀리는지</b>까지 적는다.
/// </summary>
/// <param name="hasCommit">이력에서 커밋을 골랐는가.</param>
/// <param name="operationInProgress">병합 · rebase · cherry-pick · revert 가 도는 중인가.</param>
/// <param name="workingTreeDirty">커밋하지 않은 변경이 있는가.</param>
/// <param name="isAncestorOfHead">고른 커밋이 지금 HEAD 에서 닿는가(HEAD 자신 포함).</param>
/// <param name="pushedTo">이미 올라간 원격 브랜치 이름. 안 올라갔으면 null.</param>
/// <param name="otherBranches">그 커밋을 함께 품은 <b>다른 로컬 브랜치</b> 이름들.</param>
/// <param name="tagsInRange">다시 쓸 구간에 걸린 태그 이름들.</param>
/// <param name="mergeCommits">다시 쓸 구간의 병합 커밋 수(고른 커밋 포함).</param>
public static string? BlockReason(bool hasCommit, bool operationInProgress, bool workingTreeDirty,
bool isAncestorOfHead, string? pushedTo,
IReadOnlyList<string>? otherBranches, IReadOnlyList<string>? 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;
}
/// <summary>바꾸기 전에 띄울 확인 글. 무엇이 몇 개 다시 써지는지와 되돌릴 자리를 함께 적는다.</summary>
/// <param name="shortSha">고른 커밋의 짧은 해시.</param>
/// <param name="subject">고른 커밋의 지금 제목.</param>
/// <param name="rewriteCount">다시 쓸 커밋 수(고른 커밋 포함).</param>
/// <param name="headShortSha">지금 HEAD 짧은 해시 - 되돌릴 자리.</param>
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" +
"계속할까요?";
}
/// <summary>바꾼 뒤 알릴 글.</summary>
public static string Done(string? oldHead, string? newHead, int rewriteCount) =>
$"메시지 바꿈 완료 (옛 HEAD {Short(oldHead)} → 새 HEAD {Short(newHead)}) · 커밋 {rewriteCount}개 다시 씀";
/// <summary>
/// 새로 친 메시지를 git 이 <c>-m</c> 으로 받을 때처럼 다듬는다(cleanup=whitespace).
/// 줄 끝 공백 · 앞뒤 빈 줄을 없애고, 빈 줄 여럿은 하나로, 끝은 줄바꿈 하나. <c>#</c> 줄과 들여쓰기는 둔다.
///
/// <b>왜</b> - 다듬지 않고 쓰니 칸을 어떻게 고쳤느냐에 따라 끝 줄바꿈이 있다 없다 했다
/// (2026-09-22 화면: "셋" 으로 끝난 커밋. git 이 만든 커밋은 모두 줄바꿈으로 끝난다).
/// libgit2 의 다듬기(<c>PrettifyMessages</c>)를 켜면 <b>손대지 않은 뒤 커밋들 메시지까지</b> 다듬어지므로,
/// 새 메시지 하나만 여기서 다듬는다.
/// </summary>
/// <returns>다듬은 글. 남는 것이 없으면 빈 글.</returns>
public static string Clean(string? message)
{
var lines = (message ?? string.Empty).Replace("\r\n", "\n").Replace('\r', '\n').Split('\n');
var kept = new List<string>(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";
}
/// <summary>메시지가 비면 바꾸지 않는다 - 빈 메시지 커밋은 만들지 않는다.</summary>
public static bool IsUsableMessage(string? message) => !string.IsNullOrWhiteSpace(message);
/// <summary>바뀐 것이 없으면 다시 쓸 까닭도 없다(해시만 바뀌는 헛일).</summary>
public static bool Changed(string? oldMessage, string? newMessage) =>
Normalize(oldMessage) != Normalize(newMessage);
/// <summary>우클릭 메뉴에 걸어 둘 설명 - 왜 꺼졌는지는 눌러 보면 알려 준다.</summary>
public static string MenuHint =>
"고른 커밋의 메시지를 바꿉니다. 그 커밋부터 HEAD 까지 새 해시로 다시 써집니다 (원격에 올리기 전에만)";
private static string Normalize(string? text) =>
(text ?? string.Empty).Replace("\r\n", "\n").Trim();
private static string Join(IReadOnlyList<string> 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);
}
}

View File

@ -495,6 +495,109 @@ namespace MergeHub.Services
return commit.Sha;
}
// ================= 마지막이 아닌 커밋의 메시지 바꾸기 (11차 C-2) =================
/// <summary>
/// 고른 커밋의 메시지를 바꿔도 되는지 <b>재 본다</b>(바꾸지는 않는다).
/// 판단 문장은 <see cref="CommitReword"/> 가 만든다 - 여기서는 저장소에서 읽을 수 있는 사실만 모은다.
/// </summary>
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<string>(), Array.Empty<string>());
// 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<string>(), Array.Empty<string>());
// 다시 쓸 커밋 = 고른 커밋 + HEAD 에서 닿지만 고른 커밋에서는 닿지 않는 것들.
var after = Repo.Commits.QueryBy(new CommitFilter
{
IncludeReachableFrom = head,
ExcludeReachableFrom = target,
SortBy = CommitSortStrategies.Topological
}).ToList();
var range = new List<Commit>(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<string>(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);
}
/// <summary>
/// 고른 커밋의 메시지를 바꾸고 그 뒤 커밋을 <b>같은 내용으로 다시 쓴다</b>(`git rebase -i` 의 reword).
///
/// 트리 · 작성자 · 작성 시각 · 부모 관계는 그대로 두고 메시지만 갈아 끼우므로, 남는 것은 해시뿐이다.
/// 바꾸기 전 자리는 <c>refs/original/…</c> 아래에 남겨 되돌릴 수 있게 한다 - 같은 자리에 두 번 쓰면
/// libgit2 가 "이미 있다" 로 막으므로 시각을 붙여 새 자리에 남긴다.
/// </summary>
/// <returns>바꾸기 전 HEAD · 바꾼 뒤 HEAD · 다시 쓴 커밋 수 · 되돌릴 자리(백업 ref 이름) · 고친 커밋의 새 해시.</returns>
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,
// 켜면 <b>손대지 않은 뒤 커밋들 메시지까지</b> 다듬어진다. 새 메시지만 위에서 다듬었다.
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 =>

View File

@ -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);
// ---- 병합 ---------------------------------------------------------
/// <summary>
/// 브랜치를 현재 HEAD 로 병합. 기획안 6p 비목표("사람 확인 없는 자동 커밋 금지")에 따라

View File

@ -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));
/// <summary>우클릭 메뉴 [메시지 바꾸기...] 에 걸어 둘 설명.</summary>
public string RewordHint => CommitReword.MenuHint;
/// <summary>
/// 이력에서 고른 커밋의 메시지를 바꾼다(11차 C-2, `git rebase -i` 의 reword).
///
/// HEAD 가 아니면 <b>그 커밋부터 HEAD 까지가 새 해시로 다시 써진다</b>. 그래서 좁은 범위에서만 한다 -
/// 막을 까닭은 <see cref="CommitReword.BlockReason"/> 가 정하고, 여기서는 눌렀을 때 <b>왜 안 되는지 말해 준다</b>.
/// 메뉴를 꺼 두지 않는 까닭: 꺼 두면 왜 안 되는지 알 길이 없고, 까닭은 저장소를 읽어 봐야 안다.
///
/// 저장 안 한 편집은 <see cref="GitWorkspace.RunAndRefreshAsync"/> 가 지금까지의 Git 작업과 같은 창으로 묻는다.
/// </summary>
[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)));
});
// 고른 커밋을 <b>새 해시로</b> 따라간다. 그냥 두면 옛 해시가 사라져 선택이 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();

View File

@ -415,6 +415,14 @@
<ListBox x:Name="HistoryList" Grid.Row="0" Grid.Column="0" ItemsSource="{Binding Workspace.Commits}"
SelectedItem="{Binding SelectedCommit}" HorizontalContentAlignment="Stretch"
ScrollViewer.HorizontalScrollBarVisibility="Disabled">
<!-- 기획안의 "커밋 우클릭 → 이름 변경"(11차 C-2). 메뉴는 시각 트리 밖이라
목록(PlacementTarget)의 DataContext 를 가져와야 명령이 걸린다. -->
<ListBox.ContextMenu>
<ContextMenu DataContext="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource Self}}">
<MenuItem Header="메시지 바꾸기..." Command="{Binding RewordCommand}"
ToolTip="{Binding RewordHint}"/>
</ContextMenu>
</ListBox.ContextMenu>
<ListBox.ItemTemplate>
<DataTemplate>
<StackPanel Margin="0,2">

View File

@ -30,6 +30,18 @@ namespace MergeHub.Views
e.Handled = true; // [취소] 거나, 창이 누름을 끊어 목록이 스스로 못 고른다
if (answer == true) ChooseHistoryRow(vm, HistoryList.Items.IndexOf(target));
};
// 우클릭한 줄을 먼저 고른다(11차 C-2). 목록은 오른쪽 누름으로는 선택을 바꾸지 않아서, 그대로 두면
// <b>가리킨 줄이 아닌 다른 커밋</b>의 메시지를 바꾸게 된다 - 다시 쓴 이력은 되돌리기 번거롭다.
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;

View File

@ -0,0 +1,65 @@
<Window x:Class="MergeHub.Views.RewordWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Width="560" SizeToContent="Height"
WindowStyle="None"
ResizeMode="NoResize"
ShowInTaskbar="False"
Background="{DynamicResource Ubisam.Brush.ContentBackground}">
<!-- 별도 창이라 셸의 리소스를 자동 상속받지 못한다 - ShortcutHelpWindow · 플랫폼 대화상자와 같은 패턴. -->
<Window.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="pack://application:,,,/UbisamBase.Core;component/Themes/ShellStyles.xaml"/>
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Window.Resources>
<Border BorderBrush="{DynamicResource Ubisam.Brush.SubTabBar.Border}" BorderThickness="1">
<DockPanel TextElement.Foreground="{DynamicResource Ubisam.Brush.ContentForeground}">
<Border x:Name="TitleBar" DockPanel.Dock="Top"
Background="{DynamicResource Ubisam.Brush.Sidebar.Background}"
BorderBrush="{DynamicResource Ubisam.Brush.SubTabBar.Border}"
BorderThickness="0,0,0,1" Padding="16,10"
MouseLeftButtonDown="TitleBar_MouseLeftButtonDown">
<DockPanel>
<Button DockPanel.Dock="Right" Content="✕" Click="Cancel_Click"
Background="Transparent" BorderThickness="0" Padding="8,0"
MinHeight="20" FontSize="13"
Foreground="{DynamicResource Ubisam.Brush.Muted}"/>
<TextBlock Text="커밋 메시지 바꾸기" FontWeight="SemiBold"
Foreground="{DynamicResource Ubisam.Brush.ContentForeground}"/>
</DockPanel>
</Border>
<Border DockPanel.Dock="Bottom" Padding="16,12"
BorderBrush="{DynamicResource Ubisam.Brush.SubTabBar.Border}" BorderThickness="0,1,0,0">
<DockPanel>
<Button DockPanel.Dock="Right" Content="바꾸기" MinWidth="84" Padding="14,7"
Margin="8,0,0,0"
Style="{StaticResource Ubisam.Style.Button.Primary}"
IsDefault="False" Click="Ok_Click"/>
<Button DockPanel.Dock="Right" Content="취소" MinWidth="84" Padding="14,7"
IsCancel="True" Click="Cancel_Click"/>
<TextBlock x:Name="CountText" VerticalAlignment="Center" FontSize="11.5"
TextWrapping="Wrap"
Foreground="{DynamicResource Ubisam.Brush.Muted}"/>
</DockPanel>
</Border>
<StackPanel Margin="16,14">
<!-- 어느 커밋을 고쳤는지 창 안에서도 보여야 한다 - 목록에서 고른 줄과 다른 커밋을 고치면 되돌리기 어렵다. -->
<TextBlock x:Name="TargetText" FontFamily="Consolas" FontSize="11.5"
TextTrimming="CharacterEllipsis"
Foreground="{DynamicResource Ubisam.Brush.Warning}"/>
<TextBlock Text="새 메시지" Margin="0,10,0,4" FontSize="12"
Foreground="{DynamicResource Ubisam.Brush.Muted}"/>
<!-- 여러 줄을 받는다. 커밋 메시지 칸(한 줄)과 달리 본문까지 칠 수 있다. -->
<TextBox x:Name="MessageBox" Height="120" AcceptsReturn="True" TextWrapping="Wrap"
VerticalScrollBarVisibility="Auto" FontFamily="Consolas"/>
</StackPanel>
</DockPanel>
</Border>
</Window>

View File

@ -0,0 +1,58 @@
using System.Windows;
using System.Windows.Input;
namespace MergeHub.Views
{
/// <summary>
/// 고른 커밋의 새 메시지를 받는 창(11차 C-2).
///
/// 커밋 메시지 칸(Git 탭 아래)은 한 줄이라 본문을 칠 수 없다(11차 C-1 의 8번). 여기서는 여러 줄을 받는다 -
/// 메시지를 고치러 들어온 자리에서까지 제목만 칠 수 있으면 고치는 뜻이 없다.
///
/// [바꾸기] 는 기본 단추로 두지 않는다. Enter 는 줄바꿈이어야 하고, 실수로 눌러 이력을 다시 쓰면 안 된다.
/// </summary>
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();
};
}
/// <summary>새 메시지를 받아 온다. [취소] 하거나 창을 닫으면 null.</summary>
/// <param name="target">고친 커밋을 알아볼 한 줄(해시 · 작성자 · 날짜).</param>
/// <param name="currentMessage">지금 메시지 - 칸에 미리 넣어 둔다.</param>
/// <param name="countText">몇 개가 다시 써지는지 아래에 늘 보이는 말.</param>
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;
}
}