Zettelkasten

squash 머지 후 stacked PR은 git rebase --onto로 중복된 상위 커밋을 떼어낸다

·수정 2

요약

  • stacked PR을 squash 머지하면 상위 PR이 main에 **단일 새 커밋(다른 SHA)**으로 들어가, 하위 PR 히스토리의 원래 상위 커밋들이 "내용은 같지만 SHA가 다른" 중복으로 남는다.
  • git rebase --onto <목적지> <시작경계> <옮길브랜치>로 그 중복 커밋을 떼어내고 하위 PR 고유 커밋만 새 main 위에 다시 얹는다.
  • 하위 PR이 상위 PR 위에 "더하기"만 했다면(같은 라인을 재수정하지 않았다면) 이 rebase는 충돌이 나지 않는다.

본문

문제: squash 머지가 stacked PR의 베이스를 깬다

PR-B의 base가 PR-A 브랜치인 stacked 구조에서 PR-A를 squash 머지하면:

main:  ... ─ X(=A를 squash한 단일 커밋)      ← A의 내용이 들어있지만 SHA는 새것
                │
PR-B 브랜치:    a1 ─ a2 ─ a3 ─ b1            ← a1~a3 = A의 원래 커밋들 (main의 X와 SHA 다름)
                              ↑ PR-B tip

GitHub은 PR-B의 base를 자동으로 main으로 retarget하고 "MERGEABLE"로 표시하지만, PR-B의 히스토리에는 a1·a2·a3가 그대로 남아 있다. 이 커밋들은 main의 X내용은 같지만 SHA가 달라 git이 별개로 본다. 그대로 머지하면 같은 변경이 중복 적용되거나 충돌이 난다.

해결: rebase --onto로 상위 커밋만 버린다

git rebase --onto origin/main <PR-A-tip> PR-B
            └─목적지         └─시작경계    └─옮길브랜치
  • 시작경계(PR-A-tip = a3) 다음부터 PR-B tip까지 = b1 하나만 대상
  • 그걸 origin/main 위에 새로 얹음 → a1·a2·a3는 버려짐
main: ... ─ X ─ b1'      ← PR-B 고유 커밋만 깔끔하게

일반 git rebase main은 main에 동등 커밋(patch-id)이 없는 커밋을 전부 옮긴다. squash 머지된 a1~a3X와 SHA·patch-id가 달라 동등으로 인식되지 못해 재적용 대상에 남는다 — 그래서 그대로 두면 다시 재생된다. --onto시작 경계를 직접 지정해 그 중간 커밋을 버릴 수 있다는 점이 핵심 차이다.

3단 이상 스택이면 각 단계가 squash 머지될 때마다 그 아래 PR을 다시 --onto 해야 한다. 즉 squash 머지 1회 = 다음 PR rebase 1회.

왜 충돌이 안 나는가

rebase 충돌은 옮길 커밋이 바꾼 라인새 베이스가 바꾼 라인이 겹칠 때만 난다. squash 스택 정리에서는 보통 안 겹친다:

  1. main의 X는 PR-A를 squash한 것이라 PR-B가 깔고 있던 A 내용과 글자 단위로 동일. PR-B의 diff가 참조하는 context 라인이 main에 그대로 있다 → 겹쳐도 같은 내용이라 충돌 아님.
  2. PR-B는 PR-A 위에 코드를 추가했을 뿐 A가 쓴 라인을 재수정하지 않았다 → 부딪힐 라인이 없음.

따라서 충돌은 다른 독립 PR이 그 사이 main에 머지되면서 PR-B와 같은 라인을 건드린 경우에만 발생한다. squash 중복 제거 자체는 구조적으로 무충돌이다.

대조적으로, 처음 PR을 만들고 한참 뒤 rebase할 때 충돌이 잦은 건 그 사이 다른 작업이 같은 파일·같은 영역(예: 같은 enum 꼬리, 같은 함수)을 건드렸기 때문이다. 충돌 여부는 "rebase를 하느냐"가 아니라 "겹치는 변경이 있느냐"로 결정된다.

실무 절차

# 상위 PR squash 머지 직후
git fetch origin main
git checkout PR-B
git rebase --onto origin/main <PR-A-tip-SHA> PR-B   # 로컬 PR-A 브랜치 SHA를 시작경계로
# 빌드/테스트 검증 후
git push --force-with-lease origin PR-B
  • 시작경계는 머지 전 로컬에 남아있는 상위 PR 브랜치 tip을 쓰면 정확하다.
  • rebase로 SHA가 바뀌므로 push는 --force-with-lease(원격이 내가 아는 상태일 때만 강제)로 안전하게.

관련 노트

참고