커밋 히스토리 재작성 (Commit History Rewrite)
기존 저장소의 커밋 히스토리를 conventional commits 형식으로 재작성합니다. 커밋을 분석하고, 유형별로 분류하며, squash 후보를 식별한 후, 완전한 안전 장치와 함께 재작성을 실행합니다.
개요
히스토리는 문서입니다. main 브랜치의 모든 커밋 메시지는 미래의 엔지니어(및 에이전트)가 무엇이 변경되었고 왜 변경되었는지 이해하는 데 사용하는 영구 기록입니다. "fix", "update", "wip" 같은 메시지는 이 문서를 망가뜨립니다. Release-please는 버전 범프를 결정하기 위해 커밋 메시지를 파싱합니다. 형식에 맞지 않는 메시지는 잘못된 릴리스 또는 릴리스 누락을 초래합니다.
코드는 부채입니다. 하이럼의 법칙은 git 히스토리에도 적용됩니다. 커밋 메시지에 의존하는 충분한 도구(체인지로그, 릴리스 자동화, git bisect, git blame)가 있다면, 해당 메시지의 형식은 계약이 됩니다. 자동화를 도입하기 전에 히스토리를 정리하면 이전 메시지가 모든 하위 도구를 오염시키는 것을 방지할 수 있습니다.
이 스킬은 메시지를 재작성하며, 코드는 변경하지 않습니다. 파일 트리는 재작성 전후로 동일해야 합니다. 변경되는 것은 커밋 메타데이터뿐입니다.
사용 시기
- 기존에 형식을 따르지 않는 히스토리가 있는 저장소에 conventional commits 도입
- 커밋 로그가 WIP, fixup, 병합, 또는 의미 없는 메시지로 오염됨
- release-please(작동에 conventional commits가 필요함)를 위해 저장소 준비
- 오픈소스화 또는 저장소 이전 전에 정리
사용하지 말아야 할 경우
- 저장소가 이미 conventional commits로 게시된 경우(재작성할 내용 없음)
- 통보되지 않은 활성 협력자가 있는 공유 브랜치
- 마지막 몇 개의 커밋만 수정하면 되는 경우(
git rebase -i HEAD~N직접 사용) - 업스트림에서 커밋 형식을 제어하는 포크 저장소
프로세스
1단계: 저장소 평가
# 전체 커밋 수 계산
git rev-list --count HEAD
# 커밋 메시지 요약 보기
git log --oneline --no-merges
# 협력자 확인 (작성자 다양성)
git shortlog -sne --no-merges
# 병합 커밋 밀도 파악
git log --merges --oneline | wc -l
판단 지점: 커밋 수가 50개 미만이면 대화형 리베이스를 사용합니다. 50개 이상이면 git-filter-repo를 사용합니다.
2단계: 기존 커밋 분류
각 커밋을 두 가지 신호(메시지 텍스트와 변경된 파일)를 사용하여 분석합니다. 메시지가 모호할 때(예: "update stuff")는 diff가 진실의 원천입니다.
신호 1: 메시지 내용
| 메시지 신호 | 할당된 유형 |
|---|---|
| "add", "new", "implement", "create", "introduce", "support" | feat |
| "fix", "bug", "patch", "resolve", "correct", "handle error" | fix |
| "refactor", "restructure", "reorganize", "simplify", "extract", "move" | refactor |
| "update dep", "bump", "upgrade", "version", "config", "setup" | chore |
| "test", "spec", "coverage", "assert" | test |
| "doc", "readme", "comment", "typo in doc" | docs |
| "ci", "workflow", "pipeline", "action", "deploy" | ci |
신호 2: 변경된 파일 (메시지가 모호할 때)
git show --stat --format="" <hash>
| 파일 패턴 | 할당된 유형 |
|---|---|
*.md, docs/**만 포함 | docs |
*test*, *spec*, __tests__/**만 포함 | test |
.github/workflows/**, .gitlab-ci.yml, Jenkinsfile만 포함 | ci |
package.json, *.lock, requirements.txt, Cargo.toml만 포함 | chore |
설정 파일(.eslintrc, tsconfig.json, .prettierrc)만 포함 | chore |
Squash 후보
| 신호 | 조치 |
|---|---|
| 메시지가 "WIP", "wip", "temp", "save", "checkpoint", 단일 단어, 또는 비어 있음 | 가장 가까운 의미 있는 커밋에 squash |
| 메시지가 "fixup", "fix typo", "oops", "lint", "formatting" | 이전 커밋에 squash |
| 병합 커밋(squash 병합이 아닌 경우) | squash 후보 — 내용이 이미 main에 있는지 평가 |
모호한 커밋의 경우 전체 diff(git show <hash>)를 확인하여 내용에서 유형을 결정합니다.
3단계: 양질의 메시지 작성
재작성된 메시지는 커밋 메시지 표준을 따라야 합니다.
<type>[optional scope]: <short imperative description>
[optional body explaining WHY, not what]
좋은 재작성 예:
"initial setup" → "chore: initial project setup"
"added login" → "feat: add user login"
"fix bug" → "fix: resolve login redirect loop"
"Update README.md" → "docs: update README with setup instructions"
"updated deps" → "chore(deps): update dependencies"
나쁜 재작성 예 (하지 말 것):
"stuff" → "chore: stuff" ← 여전히 의미 없음
"changes" → "feat: changes" ← 설명 없음
"fix" → "fix: fix" ← 중복
원본 메시지가 의미 없을 경우, diff를 읽고 적절한 설명을 작성합니다. diff가 너무 크거나 모호하면 사용자에게 해당 커밋의 목적을 물어봅니다.
4단계: 재작성 계획 제시
파괴적 행동을 하기 전에, 사용자에게 완전한 계획을 보여줍니다.
재작성 계획
============
전체 커밋 수: 47
재작성할 커밋: 32
Squash할 커밋: 8
이미 형식을 따르는 커밋: 7
보존된 작성자: 3명 (Alice <alice@co>, Bob <bob@co>, CI Bot <bot@co>)
제안된 변경사항:
abc1234 "initial setup" → "chore: initial project setup"
def5678 "added login" → "feat: add user login"
ghi9012 "wip" → [def5678에 SQUASH]
jkl3456 "fix bug" → "fix: resolve login redirect loop"
mno7890 "Update README.md" → "docs: update README with setup instructions"
pqr1234 "feat: add search" → [유지 — 이미 형식 준수]
...
안전:
✓ 백업 브랜치: backup/pre-rewrite-<timestamp>
⚠ 재작성 후 강제 푸시 필요
⚠ 모든 협력자는 푸시 후 다시 클론하거나 리셋해야 함
변경 없음:
- 파일 트리 (내용 변경 제로)
- 모든 커밋의 작성자 이름, 이메일, 날짜
- 커밋 순서 및 부모 관계 (squash된 커밋 제외)
진행하기 전에 명시적인 사용자 승인을 기다리십시오. "좋아요" 같은 모호한 답변으로 진행하지 말고, "yes" 또는 이에 상당하는 명확한 승인을 요구합니다.
5단계: 백업 생성
# 현재 HEAD에 백업 브랜치 생성
git branch "backup/pre-rewrite-$(date +%Y%m%d-%H%M%S)"
# 백업 존재 확인
git branch -l 'backup/*'
# 선택적으로 추가 안전을 위해 원격에 백업 푸시
git push origin "backup/pre-rewrite-$(date +%Y%m%d-%H%M%S)"
6단계: 재작성 실행
옵션 A: 대화형 리베이스 (50개 미만 커밋)
# 루트부터 리베이스
git rebase -i --root
편집기에서:
- squash 후보는
squash또는fixup으로 표시 - 메시지 재작성은
reword로 표시 - 커밋 순서 유지
작성자 보존: 대화형 리베이스는 기본적으로 작성자 이름, 이메일, 날짜를 보존합니다. 특별히 요청하지 않는 한 --reset-author를 사용하지 마십시오. 특별히 요청받지 않는 한 --committer-date-is-author-date를 사용하지 마십시오.
옵션 B: git-filter-repo (50개 이상 커밋)
이전 메시지를 새 메시지로 매핑하는 Python 콜백 스크립트(rewrite-messages.py)를 생성합니다.
import re
REWRITES = {
b'old hash prefix': b'new message',
# ... 계획에서 생성
}
def rewrite(commit, metadata):
# 원본 메시지 또는 해시로 조회
if commit.original_id in REWRITES:
commit.message = REWRITES[commit.original_id]
적용:
git filter-repo --commit-callback "$(cat rewrite-messages.py)" --force
작성자 보존: git-filter-repo는 기본적으로 작성자를 보존합니다. 사용자가 명시적으로 요청하지 않는 한 --mailmap 또는 작성자 재작성 플래그를 사용하지 마십시오.
커밋 Squashing
squash 후보의 경우, 가장 가까운 의미 있는 커밋에 병합합니다. 결과 커밋:
- 의미 있는 커밋의 conventional commit 메시지를 사용
- 의미 있는 커밋의 원본 작성자를 보존 (squash 대상의 작성자가 아님)
- squash된 범위의 모든 변경 사항을 결합
7단계: 검증
모든 검증 확인을 실행합니다. 푸시를 진행하기 전에 모든 확인이 통과해야 합니다.
# 1. 재작성된 로그 검토
git log --oneline --no-merges
# 2. 모든 메시지가 conventional commits 형식과 일치하는지 검증
FAILURES=$(git log --format="%s" --no-merges | grep -cvE "^(feat|fix|docs|chore|refactor|test|ci)(\(.+\))?!?: .+")
if [[ "$FAILURES" -gt 0 ]]; then
echo "실패: $FAILURES개의 형식 미준수 커밋 발견:"
git log --format="%h %s" --no-merges | grep -vE "^[a-f0-9]+ (feat|fix|docs|chore|refactor|test|ci)(\(.+\))?!?: .+"
exit 1
fi
echo "통과: 모든 커밋이 형식을 준수합니다"
# 3. 내용 변경이 전혀 없는지 확인 (중요 — 메시지만 변경되었음을 증명)
DIFF=$(git diff "backup/pre-rewrite-"*..HEAD --stat)
if [[ -n "$DIFF" ]]; then
echo "실패: 내용 차이 발견 — 재작성 과정에서 코드가 변경됨"
echo "$DIFF"
exit 1
fi
echo "통과: 파일 트리 동일"
# 4. 작성자 보존 확인
echo "=== 재작성 전 작성자 ==="
git -C . log backup/pre-rewrite-* --format="%an <%ae>" --no-merges | sort -u
echo "=== 재작성 후 작성자 ==="
git log --format="%an <%ae>" --no-merges | sort -u
# 두 목록이 반드시 동일해야 함
어느 하나라도 실패하면 푸시하지 마십시오. 백업으로 리셋하고 조사합니다.
git reset --hard backup/pre-rewrite-*
8단계: 푸시 (확인 포함)
이는 원격 협력자에게 파괴적입니다. 사용자에게 확인합니다.
⚠ 강제 푸시 경고
브랜치: main
원격: origin
작업: git push --force-with-lease origin main
이 푸시 후, 모든 협력자는 다음을 실행해야 합니다:
git fetch origin
git reset --hard origin/main
계속하려면 "yes"를 입력하십시오:
git push --force-with-lease origin main
동시 원격 변경을 덮어쓰지 않도록 --force 대신 --force-with-lease를 사용합니다. --force-with-lease가 거부되면 재작성 이후 누군가가 브랜치에 푸시한 것입니다. 재시도하기 전에 조사하십시오.
재작성 후 정리
성공적인 푸시 및 협력자 통보 후:
# 원격이 로컬과 일치하는지 확인
git log --oneline -5
git log --oneline -5 origin/main
# 이 두 결과가 동일해야 함
# 백업 브랜치는 최소 2주간 유지한 후 삭제
# git branch -D backup/pre-rewrite-*
# git push origin --delete backup/pre-rewrite-*
합리화에 대한 대응
| 합리화 | 현실 |
|---|---|
| "예전 히스토리는 중요하지 않아" | Release-please는 커밋 메시지를 파싱하여 버전을 결정합니다. 형식에 맞지 않는 히스토리는 잘못된 릴리스나 릴리스 누락을 초래합니다. git bisect, git blame, git log --grep 모두 의미 있는 메시지에 의존합니다. |
| "이 커밋부터 새로 시작하면 돼" | blame, 작성자, 맥락을 잃게 됩니다. --orphan 브랜치는 개발 내러티브를 파괴합니다. 재작성은 나쁜 메시지를 제외한 모든 것을 보존합니다. |
| "재작성하기엔 너무 위험해" | 백업 브랜치로 완전히 되돌릴 수 있습니다. diff에 내용 변경이 전혀 없음을 검증하십시오. 문제가 발생하면 git reset --hard backup/pre-rewrite-*. |
| "나중에 필요할 때 하면 돼" | 히스토리 재작성은 더 많은 사람이 클론하고 브랜치를 만들수록 시간이 지남에 따라 더 어려워집니다. 더 많은 하위 의존성이 생기기 전에 지금 하십시오. |
| "강제 푸시는 위험해" | --force-with-lease는 다른 사람의 작업을 덮어쓰지 않도록 보호합니다. 백업 브랜치와 결합하면 안전합니다. |
| "메시지가 거의 비슷해" | "거의 비슷하다"는 release-please가 파싱하지 못한다는 의미입니다. Conventional commits는 엄격한 형식입니다. 메시지는 형식을 따르거나 따르지 않습니다. |
| "수동으로 릴리스 태그를 달면 돼" | 수동 태그는 표류하고, 잊히며, 체인지로그 통합이 부족합니다. 핵심은 자동화입니다. 그러나 자동화에는 파싱 가능한 입력이 필요합니다. |
위험 신호
- 백업 브랜치 생성 건너뛰기
--force-with-lease대신--force사용- 통보 없이 다른 사람이 활발히 작업 중인 브랜치 재작성
- 7단계 diff에서 내용 차이 발생 (재작성이 메시지뿐만 아니라 코드도 변경했음을 의미)
- 재작성 후 작성자 수 변경 (작성자가 보존되지 않았음을 의미)
- 재작성 계획에 대한 사용자 승인 없이 진행
- 여전히 의미 없는 재작성 메시지 (예:
"chore: stuff") - 다른 작성자 간의 커밋을 논의 없이 squash
- 재작성 후 최소 2주 동안 백업 브랜치를 유지하지 않음