Split View: git merge vs rebase — 무엇을 언제 쓰고, 공유 브랜치는 왜 리베이스하지 않는가
git merge vs rebase — 무엇을 언제 쓰고, 공유 브랜치는 왜 리베이스하지 않는가
들어가며 — 같은 코드, 다른 역사
merge와 rebase 논쟁은 대개 취향 싸움처럼 소비됩니다. 깔끔한 일직선이 좋다는 쪽과 있었던 일을 그대로 남겨야 한다는 쪽이 각자의 스크린샷을 들고 나옵니다. 그런데 두 명령이 실제로 무엇을 만드는지를 커밋 해시 수준에서 보면, 논쟁의 절반은 저절로 정리됩니다.
핵심부터 말하면 이렇습니다. 머지는 커밋을 하나 더 만들고 기존 커밋은 건드리지 않습니다. 리베이스는 기존 커밋을 옮기는 것이 아니라, 내용은 같고 부모가 다른 새 커밋을 만들어 냅니다. 그래서 해시가 바뀝니다. 이 한 문장이 "공유된 브랜치는 리베이스하지 않는다"는 규칙의 유일한 근거이고, 규칙의 예외가 어디까지인지도 여기서 나옵니다.
커밋 해시가 말해 주는 것 — 리베이스는 커밋을 옮기지 않는다
갈라진 브랜치 하나를 준비했습니다.
$ git log --oneline --graph --all
* 8c377e2 (HEAD -> feature/checkout) 결제 실패 재시도 추가
* 6d466fd 결제 요청 타임아웃 조정
| * c76f2e6 (main) 로깅 포맷 통일
|/
* a2338b7 주문 API 스키마 정리
여기에 리베이스를 겁니다.
$ git rebase main
Rebasing (1/2)
Rebasing (2/2)
Successfully rebased and updated refs/heads/feature/checkout.
$ git log --oneline --graph --all
* 4a17a14 (HEAD -> feature/checkout) 결제 실패 재시도 추가
* 7040fea 결제 요청 타임아웃 조정
* c76f2e6 (main) 로깅 포맷 통일
* a2338b7 주문 API 스키마 정리
커밋 메시지도 같고 변경 내용도 같은데 해시가 전부 바뀌었습니다. 8c377e2가 4a17a14가 되고, 6d466fd가 7040fea가 됐습니다. 이유는 커밋 객체의 생김새를 보면 즉시 납득됩니다.
$ git cat-file -p HEAD
tree c9cff0bf1786d3c413655ad220869022721584a4
parent 7040fea9c7bc15cd1d77bb26d843c8ba81140fec
author Demo <d@e.com> 1785081971 +0900
committer Demo <d@e.com> 1785081971 +0900
결제 실패 재시도 추가
커밋 객체 안에는 부모 커밋의 해시가 들어 있고, 커밋의 해시는 이 내용 전체를 해싱한 값입니다. 부모가 달라지면 커밋 해시는 반드시 달라집니다. 즉 리베이스는 커밋을 이동시키는 명령이 아니라 재작성하는 명령이고, 재작성이라는 말은 정확히 "새 객체를 만들고 브랜치 참조를 그쪽으로 옮겼다"는 뜻입니다.
그러면 원래 커밋은 어디로 갔을까요. 사라지지 않았습니다. 아무도 가리키지 않게 됐을 뿐입니다.
$ git reflog show feature/checkout
4a17a14 feature/checkout@{0}: rebase (finish): refs/heads/feature/checkout onto c76f2e6
8c377e2 feature/checkout@{1}: commit: 결제 실패 재시도 추가
6d466fd feature/checkout@{2}: commit: 결제 요청 타임아웃 조정
a2338b7 feature/checkout@{3}: branch: Created from HEAD
리베이스 결과를 리뷰하고 싶다면 두 버전을 나란히 비교하는 전용 명령이 있습니다. 강제 푸시된 남의 브랜치를 다시 볼 때 특히 유용합니다.
$ git range-diff main feature/checkout@{2} feature/checkout
1: 6d466fd = 1: 7040fea 결제 요청 타임아웃 조정
-: ------- > 2: 4a17a14 결제 실패 재시도 추가
황금률의 진짜 경계선
"이미 푸시한 커밋은 리베이스하지 마라"는 문장은 자주 인용되지만 조금 부정확합니다. 문제의 본질은 푸시 여부가 아니라 다른 사람의 저장소에 그 커밋 객체가 있는지, 그리고 그 위에 누군가의 작업이 얹혀 있는지입니다.
동료의 클론에는 8c377e2가 그대로 남아 있습니다. 내가 4a17a14로 갈아 끼운 브랜치를 강제 푸시하면, 동료가 다음에 받아 올 때 자기 로컬의 옛 커밋과 새 커밋을 둘 다 가진 상태가 됩니다. 여기서 동료가 무심코 머지하면 같은 변경이 두 번 들어간 이력이 만들어지고, 충돌은 이 지점에서 시작됩니다.
그래서 규칙은 이렇게 다시 쓰는 편이 정확합니다. 다른 사람의 작업이 그 위에 쌓여 있는 커밋은 재작성하지 않는다. 이 기준으로 보면 흔히 말하는 예외들이 예외가 아니라 규칙의 정상적인 적용임을 알 수 있습니다.
- 아직 아무도 체크아웃하지 않은 내 리뷰 브랜치는 리베이스해도 됩니다. 많은 팀이 리뷰 중 리베이스를 관행으로 허용하는 이유입니다.
- 내 로컬에만 있는 커밋을 정리하는 리베이스는 언제나 안전합니다. 아래 설정은 그것을 기본 동작으로 만듭니다.
$ git config --global pull.rebase true
$ git config --global rebase.autoStash true
문제는 그다음입니다. 리베이스한 브랜치를 올리려면 강제 푸시가 필요한데, 여기서 흔히 보이는 조언이 위험합니다.
# 흔한 조언 — 남의 커밋도 같이 지웁니다
$ git push -f origin feature/checkout
# 최소한 이렇게
$ git push --force-with-lease origin feature/checkout
To github.com:example/shop.git
+ 8c377e2...4a17a14 feature/checkout -> feature/checkout (forced update)
여기서 한 걸음 더 들어가야 합니다. --force-with-lease는 원격 추적 참조가 내가 마지막으로 본 값과 같은지를 확인하는데, IDE나 백그라운드 작업이 방금 fetch를 해 버렸다면 그 참조는 이미 최신으로 갱신돼 있습니다. 내가 보지 못한 커밋이 들어왔는데도 검사는 통과합니다. 이 구멍을 막는 옵션이 따로 있습니다.
$ git config --global push.useForceIfIncludes true
$ git push --force-with-lease origin feature/checkout
스택처럼 쌓아 올린 브랜치가 여러 개라면, 아래 옵션이 아래쪽 브랜치들의 참조까지 함께 옮겨 줍니다. 이것을 모르면 리베이스할 때마다 하위 브랜치를 손으로 다시 붙여야 합니다.
$ git rebase --update-refs main
$ git config --global rebase.updateRefs true
머지 커밋은 정보인가 소음인가
머지 커밋을 없애야 한다는 주장의 근거는 대개 "로그가 지저분하다"입니다. 이 불만은 대부분 도구 사용법으로 해결됩니다.
머지 커밋은 부모가 둘입니다. 첫 번째 부모는 머지를 받은 쪽, 즉 main의 직전 상태이고, 두 번째 부모가 들어온 브랜치입니다. 첫 번째 부모만 따라가면 main 입장에서의 통합 순서만 남습니다.
$ git log --oneline --graph --first-parent main
* 40600e3 Merge branch 'feature/checkout'
* c76f2e6 로깅 포맷 통일
* a2338b7 주문 API 스키마 정리
릴리스 노트를 만들거나 "언제 무엇이 들어갔는지"를 볼 때 원하는 그림이 바로 이것입니다. 이분 탐색도 같은 방식으로 브랜치 내부를 건너뛰고 통합 단위로만 좁힐 수 있습니다.
$ git bisect start --first-parent
$ git bisect bad main
$ git bisect good v2.14.0
그래서 머지 커밋 자체는 소음이 아닙니다. 진짜 소음은 통합 의도가 없는 머지 커밋입니다. 리베이스 없이 습관적으로 받아 오다가 생기는 "Merge branch 'main' into feature" 같은 커밋이 그렇습니다. 이것은 아무 정보도 담고 있지 않고 그래프만 꼬아 놓습니다. 아래 설정이면 그 종류의 머지 커밋은 아예 생기지 않습니다.
$ git config --global pull.ff only
정리하면 대립 구도가 잘못됐습니다. 선택지는 머지 커밋을 허용할 것인가가 아니라 머지 커밋이 사람의 결정을 기록하게 할 것인가, 아니면 도구가 아무 때나 찍게 둘 것인가입니다.
스쿼시 머지가 주는 것과 가져가는 것
스쿼시 머지는 브랜치의 커밋 전부를 하나로 합쳐 얹습니다. 얻는 것은 분명합니다. main의 커밋 하나가 PR 하나에 정확히 대응하고, 그 커밋은 CI를 통과한 상태이며, 되돌리기는 커밋 하나를 revert하면 끝납니다. 리뷰 단위와 이력 단위가 일치한다는 것은 생각보다 큰 장점입니다.
가져가는 것도 분명한데 잘 이야기되지 않습니다.
첫째, 이분 탐색의 해상도가 PR 크기로 고정됩니다. 800줄짜리 PR이 스쿼시되면 이분 탐색은 그 800줄까지만 좁혀 주고, 그 안쪽은 손으로 읽어야 합니다. 반대로 개별 커밋이 남아 있으면 더 좁힐 수 있지만, 브랜치 중간 커밋은 CI를 통과한 적이 없어 빌드가 깨질 수 있습니다. 이때는 건너뛰라고 알려 주면 됩니다.
$ git bisect skip
둘째, 체리픽의 정밀도를 잃습니다. 핫픽스 브랜치에 그 PR의 로그 개선 한 줄만 옮기고 싶어도, 남아 있는 커밋은 800줄짜리 덩어리뿐입니다.
셋째, 이것이 가장 많이 물리는 함정인데, Git 입장에서 그 브랜치는 머지된 적이 없습니다. 스쿼시 커밋의 부모는 원본 브랜치가 아니기 때문입니다. 그 결과 아래 명령은 이미 반영된 브랜치를 목록에 보여 주지 않습니다.
$ git branch --merged main
hotfix/coupon-null
* main
같은 이유로 스쿼시 머지된 브랜치를 지우지 않고 계속 쓰다가 다시 머지하면, Git은 공통 조상 이후의 변경을 전부 새것으로 취급해 이미 반영된 내용까지 충돌로 들이밉니다. 스쿼시 머지를 쓰는 팀은 머지 직후 소스 브랜치를 지운다는 규칙을 함께 가져가야 합니다.
리베이스 도중 같은 충돌이 반복되는 이유
머지는 충돌을 한 번만 해결하는데 리베이스는 커밋마다 같은 자리를 다시 물어봅니다. 짜증의 원인이지만 동작은 정확합니다.
머지는 양쪽 끝점과 공통 조상, 이렇게 세 지점만 비교합니다. 반면 리베이스는 커밋을 하나씩 다시 적용하고, 그때마다 삼자 병합을 새로 수행합니다. 같은 파일의 같은 구역을 내 커밋 세 개가 연달아 건드렸다면 그 구역의 충돌을 세 번 만나게 됩니다. 리베이스는 한 번의 병합이 아니라 N번의 병합입니다.
해결책은 같은 해결을 기억시키는 것입니다.
$ git config --global rerere.enabled true
$ git rebase main
Auto-merging src/checkout/retry.ts
CONFLICT (content): Merge conflict in src/checkout/retry.ts
Recorded preimage for 'src/checkout/retry.ts'
error: could not apply 6d466fd... 결제 요청 타임아웃 조정
# 충돌을 해결하고 계속
$ git add src/checkout/retry.ts
Recorded resolution for 'src/checkout/retry.ts'
$ git rebase --continue
Auto-merging src/checkout/retry.ts
CONFLICT (content): Merge conflict in src/checkout/retry.ts
Resolved 'src/checkout/retry.ts' using previous resolution.
마지막 줄이 rerere가 일하는 소리입니다. 충돌 이전 상태와 내가 만든 결과를 짝지어 저장해 두었다가, 같은 충돌 상태가 다시 나타나면 저장된 결과를 그대로 적용합니다. 리베이스를 중단했다가 다시 시도할 때도 그대로 살아 있습니다.
충돌 해결 자체를 쉽게 만드는 설정도 같이 걸어 두는 편이 좋습니다. 기본 충돌 표시는 양쪽 결과만 보여 주지만, 아래 설정은 공통 조상의 원본까지 함께 보여 줍니다. 상대가 무엇을 바꿨는지가 아니라 둘 다 무엇에서 출발했는지를 알아야 올바르게 합칠 수 있습니다.
$ git config --global merge.conflictStyle zdiff3
빨리 감기, 병합 커밋 강제, 그리고 팀 정책 고르기
브랜치가 갈라지지 않았다면 머지는 새 객체를 아예 만들지 않고 참조만 앞으로 옮깁니다.
$ git merge feature/checkout
Updating c76f2e6..4a17a14
Fast-forward
retry.txt | 2 ++
1 file changed, 2 insertions(+)
반대로 병합 사실을 반드시 기록하고 싶다면 강제로 머지 커밋을 만듭니다.
$ git merge --no-ff feature/checkout
Merge made by the 'ort' strategy.
retry.txt | 2 ++
1 file changed, 2 insertions(+)
여기까지 오면 정책 선택은 취향이 아니라 팀이 무엇을 쓰는지의 문제가 됩니다. 이분 탐색을 실제로 돌리는가, 릴리스 추적이 필요한가, 리뷰 단위가 커밋인가 PR인가.
| 정책 | main 이력 모양 | PR 경계 기록 | 이분 탐색 해상도 | 되돌리기 | 잘 맞는 팀 |
|---|---|---|---|---|---|
| 머지 커밋 강제 | 갈래가 보이는 그래프 | 머지 커밋에 남음 | 커밋 단위, 첫 부모로 좁히기 가능 | 머지 커밋을 첫 부모 기준으로 revert | 릴리스 추적과 감사 이력이 중요한 팀 |
| 리베이스 후 빨리 감기 | 완전한 일직선 | 남지 않음 | 커밋 단위로 가장 촘촘함 | 커밋 하나씩 revert | 커밋 하나하나를 리뷰 단위로 다루는 팀 |
| 스쿼시 머지 | 일직선, PR당 커밋 하나 | 커밋 하나가 곧 PR | PR 단위로 고정 | 커밋 하나 revert로 끝 | PR 단위로 리뷰하고 브랜치를 바로 지우는 팀 |
| 리베이스 후 머지 커밋 | 일직선 위의 통합 지점 | 머지 커밋에 남음 | 커밋 단위 | 머지 커밋 revert | 이력의 촘촘함과 경계 기록을 둘 다 원하는 팀 |
기준을 하나만 고르라면 이렇게 묻는 편이 실용적입니다. 장애가 났을 때 이 저장소에서 원인 커밋을 어떻게 찾을 계획인가. 이분 탐색을 실제로 쓰는 팀은 커밋의 촘촘함이 자산이고, PR 설명과 이슈 링크로 추적하는 팀은 커밋 하나가 PR 하나인 편이 훨씬 편합니다. 답이 정해지면 나머지는 따라옵니다.
마치며 — 규칙 하나를 기억한다면
merge와 rebase에 대한 조언은 많지만 근거는 하나입니다. 리베이스는 커밋을 옮기지 않고 새로 만들며, 따라서 해시가 바뀝니다. 여기서 공유 브랜치 규칙이 나오고, 강제 푸시가 필요한 이유가 나오고, 같은 충돌이 반복되는 이유가 나오고, 스쿼시된 브랜치가 머지된 것으로 인식되지 않는 이유도 나옵니다.
Git 객체 모델이 명령의 동작을 어떻게 결정하는지는 Git 내부 구조로 이해하는 명령어 편에서 이어서 다뤘습니다. 되돌리는 쪽이 급하다면 상황별 되돌리기 편이 더 빠른 길입니다.
git merge vs rebase — what to use when, and why a shared branch is never rebased
Introduction — Same code, different history
The merge versus rebase argument is usually consumed as a matter of taste. One camp shows screenshots of a clean straight line, the other shows screenshots arguing that what happened should stay on the record. Yet once you look at what the two commands actually produce, at the level of commit hashes, half the argument settles itself.
Start with the core. A merge creates one more commit and leaves the existing commits untouched. A rebase does not move the existing commits, it produces new commits whose content is identical and whose parent is different. That is why the hashes change. This one sentence is the only basis for the rule that a shared branch is not rebased, and it is also where the boundary of the exceptions comes from.
What the commit hashes tell you — a rebase does not move commits
Here is a branch that has diverged.
$ git log --oneline --graph --all
* 8c377e2 (HEAD -> feature/checkout) 결제 실패 재시도 추가
* 6d466fd 결제 요청 타임아웃 조정
| * c76f2e6 (main) 로깅 포맷 통일
|/
* a2338b7 주문 API 스키마 정리
Now rebase it.
$ git rebase main
Rebasing (1/2)
Rebasing (2/2)
Successfully rebased and updated refs/heads/feature/checkout.
$ git log --oneline --graph --all
* 4a17a14 (HEAD -> feature/checkout) 결제 실패 재시도 추가
* 7040fea 결제 요청 타임아웃 조정
* c76f2e6 (main) 로깅 포맷 통일
* a2338b7 주문 API 스키마 정리
The commit messages are the same and the changes are the same, yet every hash is different. 8c377e2 became 4a17a14, and 6d466fd became 7040fea. The reason is immediately convincing once you look at the shape of a commit object.
$ git cat-file -p HEAD
tree c9cff0bf1786d3c413655ad220869022721584a4
parent 7040fea9c7bc15cd1d77bb26d843c8ba81140fec
author Demo <d@e.com> 1785081971 +0900
committer Demo <d@e.com> 1785081971 +0900
결제 실패 재시도 추가
A commit object carries the hash of its parent inside it, and the hash of the commit is a hash over that entire content. If the parent changes, the commit hash must change. In other words a rebase is not a command that relocates commits, it is a command that rewrites them, and rewrite means exactly this: it created new objects and moved the branch ref over to them.
So where did the original commits go? They did not disappear. They merely stopped being pointed at by anything.
$ git reflog show feature/checkout
4a17a14 feature/checkout@{0}: rebase (finish): refs/heads/feature/checkout onto c76f2e6
8c377e2 feature/checkout@{1}: commit: 결제 실패 재시도 추가
6d466fd feature/checkout@{2}: commit: 결제 요청 타임아웃 조정
a2338b7 feature/checkout@{3}: branch: Created from HEAD
If you want to review the result of a rebase, there is a dedicated command that compares the two versions side by side. It is especially useful when you come back to a colleague branch that was force-pushed.
$ git range-diff main feature/checkout@{2} feature/checkout
1: 6d466fd = 1: 7040fea 결제 요청 타임아웃 조정
-: ------- > 2: 4a17a14 결제 실패 재시도 추가
Where the golden rule really draws its line
The sentence do not rebase commits you have already pushed gets quoted often, but it is slightly inaccurate. The heart of the problem is not whether you pushed, it is whether that commit object lives in someone else repository, and whether anyone has stacked work on top of it.
A colleague clone still holds 8c377e2. If you force-push the branch you swapped over to 4a17a14, the next time they fetch they end up holding both their old local commit and the new one. If that colleague then merges without thinking, you get a history where the same change went in twice, and that is where conflicts start.
So the rule is more accurate when rewritten this way. Do not rewrite a commit that other people have stacked their work on. Read through that lens, the exceptions people usually name are not exceptions at all, they are the rule applied normally.
- A review branch of yours that nobody has checked out yet can be rebased. That is why many teams allow rebasing during review as a matter of convention.
- A rebase that tidies commits living only in your local clone is always safe. The settings below make that the default behavior.
$ git config --global pull.rebase true
$ git config --global rebase.autoStash true
The trouble comes next. Publishing a rebased branch requires a force push, and the advice you usually see at this point is dangerous.
# Common advice — it deletes other people commits along the way
$ git push -f origin feature/checkout
# At the very least, do this
$ git push --force-with-lease origin feature/checkout
To github.com:example/shop.git
+ 8c377e2...4a17a14 feature/checkout -> feature/checkout (forced update)
You have to take one more step here. --force-with-lease checks whether the remote-tracking ref still matches the value you last saw, but if your IDE or a background job just ran a fetch, that ref has already been refreshed to the latest value. A commit you never saw arrived, and the check passes anyway. There is a separate option that closes this hole.
$ git config --global push.useForceIfIncludes true
$ git push --force-with-lease origin feature/checkout
If you keep several branches stacked on one another, the option below carries the refs of the lower branches along with the rebase. Without it you have to reattach every downstream branch by hand every time you rebase.
$ git rebase --update-refs main
$ git config --global rebase.updateRefs true
Are merge commits information or noise?
The argument for eliminating merge commits usually rests on the log being messy. Most of that complaint is solved by knowing your tools.
A merge commit has two parents. The first parent is the side that received the merge, that is, the state main was in just before, and the second parent is the branch that came in. Follow only the first parent and you are left with the integration order as seen from main.
$ git log --oneline --graph --first-parent main
* 40600e3 Merge branch 'feature/checkout'
* c76f2e6 로깅 포맷 통일
* a2338b7 주문 API 스키마 정리
That is exactly the picture you want when you write release notes or ask when something landed. Bisect can skip branch internals the same way and narrow down to integration units only.
$ git bisect start --first-parent
$ git bisect bad main
$ git bisect good v2.14.0
So a merge commit is not noise in itself. The real noise is a merge commit with no integration intent. Commits like Merge branch 'main' into feature, the kind that appear when you habitually pull without rebasing, are exactly that. They hold no information at all and only tangle the graph. With the setting below, that kind of merge commit never gets created.
$ git config --global pull.ff only
To put it plainly, the framing is wrong. The choice is not whether to allow merge commits, it is whether merge commits record a human decision or whether the tool gets to stamp them whenever it likes.
What a squash merge gives you and what it takes away
A squash merge folds all of a branch commits into one and lays that on top. What you gain is clear. One commit on main corresponds exactly to one PR, that commit is in a state that passed CI, and undoing is a matter of reverting a single commit. Having the review unit and the history unit match is a bigger advantage than it sounds.
What it takes away is just as clear, and rarely discussed.
First, bisect resolution gets pinned to the size of a PR. If an 800-line PR is squashed, bisect narrows you down to those 800 lines and no further, and the inside is read by hand. If the individual commits survive you can narrow further, but the intermediate commits on a branch never passed CI, so the build may be broken. In that case you just tell it to skip.
$ git bisect skip
Second, you lose cherry-pick precision. Even if all you want is to move that one-line logging improvement from the PR onto a hotfix branch, the only commit left is the 800-line lump.
Third, and this is the trap people hit most often, as far as Git is concerned that branch was never merged. The parent of the squash commit is not the original branch. As a result the command below does not list a branch that has already landed.
$ git branch --merged main
hotfix/coupon-null
* main
For the same reason, if you keep using a squash-merged branch instead of deleting it and merge it again, Git treats every change since the common ancestor as new and pushes already-applied content at you as a conflict. A team that uses squash merges has to carry the rule that the source branch is deleted right after the merge.
Why the same conflict repeats during a rebase
A merge makes you resolve a conflict once, while a rebase asks about the same spot again for every commit. It is a source of irritation, but the behavior is precisely correct.
A merge compares only three points: the two endpoints and the common ancestor. A rebase, by contrast, replays commits one at a time and performs a fresh three-way merge at each step. If three of your commits touched the same region of the same file in a row, you will meet the conflict in that region three times. A rebase is not one merge, it is N merges.
The solution is to make Git remember the same resolution.
$ git config --global rerere.enabled true
$ git rebase main
Auto-merging src/checkout/retry.ts
CONFLICT (content): Merge conflict in src/checkout/retry.ts
Recorded preimage for 'src/checkout/retry.ts'
error: could not apply 6d466fd... 결제 요청 타임아웃 조정
# Resolve the conflict and continue
$ git add src/checkout/retry.ts
Recorded resolution for 'src/checkout/retry.ts'
$ git rebase --continue
Auto-merging src/checkout/retry.ts
CONFLICT (content): Merge conflict in src/checkout/retry.ts
Resolved 'src/checkout/retry.ts' using previous resolution.
That last line is the sound of rerere working. It pairs the pre-conflict state with the result you produced and stores it, and when the same conflict state shows up again it applies the stored result as is. It stays alive even when you abort a rebase and try again.
It is worth pairing that with a setting that makes conflict resolution itself easier. The default conflict display shows only the two results, but the setting below also shows the original from the common ancestor. You can only combine correctly if you know what both sides started from, not just what the other side changed.
$ git config --global merge.conflictStyle zdiff3
Fast-forward, forcing a merge commit, and choosing a team policy
If the branches have not diverged, a merge creates no new object at all and simply moves the ref forward.
$ git merge feature/checkout
Updating c76f2e6..4a17a14
Fast-forward
retry.txt | 2 ++
1 file changed, 2 insertions(+)
Conversely, if you want the fact of the merge recorded no matter what, force a merge commit.
$ git merge --no-ff feature/checkout
Merge made by the 'ort' strategy.
retry.txt | 2 ++
1 file changed, 2 insertions(+)
Once you get this far, choosing a policy stops being a matter of taste and becomes a question of what the team actually uses. Do you really run bisect, do you need release tracking, is the review unit a commit or a PR.
| Policy | Shape of main history | PR boundary recorded | Bisect resolution | Undoing | Teams it fits |
|---|---|---|---|---|---|
| Merge commit enforced | Graph with visible forks | Kept in the merge commit | Per commit, can narrow by first parent | Revert the merge commit against the first parent | Teams that care about release tracking and an audit trail |
| Rebase then fast-forward | Fully straight line | Not kept | Finest possible, per commit | Revert one commit at a time | Teams that treat each commit as a review unit |
| Squash merge | Straight line, one commit per PR | One commit is the PR | Pinned to the PR | One revert and you are done | Teams that review per PR and delete branches immediately |
| Rebase then merge commit | Integration points on a straight line | Kept in the merge commit | Per commit | Revert the merge commit | Teams that want both a fine history and a recorded boundary |
If you have to pick a single criterion, this is the practical question to ask. When an incident happens, how do you plan to find the commit that caused it in this repository. Teams that really use bisect treat fine-grained commits as an asset, while teams that trace things through PR descriptions and issue links are far better served by one commit per PR. Once that answer is settled, the rest follows.
Closing — if you remember one rule
There is a lot of advice about merge and rebase, but only one basis for it. A rebase does not move commits, it creates new ones, and therefore the hashes change. From there come the shared branch rule, the reason force pushes are needed, the reason the same conflict repeats, and the reason a squashed branch is not recognized as merged.
How the Git object model determines the behavior of commands is picked up in Reading Git commands through internals. If undoing is the urgent part, Undoing by situation is the faster route.