Skip to content

필사 모드: git merge vs rebase — what to use when, and why a shared branch is never rebased

English
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

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.

PolicyShape of main historyPR boundary recordedBisect resolutionUndoingTeams it fits
Merge commit enforcedGraph with visible forksKept in the merge commitPer commit, can narrow by first parentRevert the merge commit against the first parentTeams that care about release tracking and an audit trail
Rebase then fast-forwardFully straight lineNot keptFinest possible, per commitRevert one commit at a timeTeams that treat each commit as a review unit
Squash mergeStraight line, one commit per PROne commit is the PRPinned to the PROne revert and you are doneTeams that review per PR and delete branches immediately
Rebase then merge commitIntegration points on a straight lineKept in the merge commitPer commitRevert the merge commitTeams 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.

현재 단락 (1/117)

The merge versus rebase argument is usually consumed as a matter of taste. One camp shows screenshot...

작성 글자: 0원문 글자: 10,967작성 단락: 0/117