Git rebase·cherry-pick 실전: 이력을 안전하게 고치는 법

rebase 와 merge 를 실제 그래프로 비교하고, 에디터 없는 대화형 rebase, cherry-pick, force push 로 동료 커밋이 사라지는 장면까지 실출력으로 정리했다.

시리즈: Git 강의 4/6 (중급). 브랜치·merge·push(1~3강)는 할 줄 알지만, 코드 리뷰에서 "main 기준으로 rebase 하고 fixup 커밋은 합쳐 주세요"라는 코멘트를 받고 막힌 분을 위한 글입니다. 다 읽고 나면 rebase, 에디터를 열지 않는 커밋 정리, 수정 커밋 하나만 릴리스 브랜치로 옮기기, 그리고 이력 수정이 동료에게 피해를 주는 순간을 구분할 수 있습니다.

입사 후 첫 PR 에 "WIP", "오타 수정" 커밋이 여덟 개 쌓여 있거나, 브랜치가 main 보다 30 커밋 뒤처졌거나, git push --force 가 위험하다는 말은 들었는데 왜인지는 모르는 상황이라면 바로 이 글입니다. rebase 와 cherry-pick 은 둘 다 "커밋을 다른 곳으로 복사하는" 도구이고, 위험은 오직 "원본 커밋을 이미 누가 가지고 있느냐"에서 나옵니다. 뒤에서는 아무 에러 없이 공유 브랜치에서 동료 커밋이 사라지는 장면을 실제 출력으로 보여 드립니다.

실행 환경은 Git 2.54.0 (Apple Git-157), Apple Silicon Mac(M1 Pro)입니다. 연습용 저장소에서 데모용 이름·메일과 고정된 커밋 시각을 써서, 같은 순서로 따라 하면 같은 해시가 나옵니다. "서버"는 로컬 bare 저장소이고 경로는 ~/demo/… 로 줄였습니다.

같은 브랜치를 merge 와 rebase 로 비교

준비: main 에 커밋 2개, feature/coupon 에 커밋 2개. 그사이 누군가 main 에 README 를 추가했습니다. main 의 변경을 feature 브랜치로 가져오는 두 가지 방법을 해 봅니다.

git switch -c try-merge feature/coupon
git merge --no-edit main
git log --graph --oneline --all
*   a514a17 Merge branch 'main' into try-merge
|\
| * 2f373eb Add README
* | e6de6d4 Validate coupon codes
* | d7647be Add coupon model
|/
* 90e23df Add price list
* 7f32e75 Initial app

merge 는 두 갈래 이력을 그대로 두고, 부모가 둘인 커밋을 하나 더합니다. 이제 rebase 입니다.

git switch feature/coupon
git rebase main
git log --graph --oneline feature/coupon
Successfully rebased and updated refs/heads/feature/coupon.
* de4f46d Validate coupon codes
* 7839332 Add coupon model
* 2f373eb Add README
* 90e23df Add price list
* 7f32e75 Initial app

README 다음에 작업을 시작한 것처럼 일직선이 됐습니다. ID 를 보세요. rebase 전 두 커밋은 d7647be, e6de6d4 였고, 후에는 7839332, de4f46d 입니다. rebase 는 커밋을 옮기지 않습니다. 새로 만듭니다. 변경 내용과 메시지는 같지만 부모가 달라서 해시가 바뀝니다(부모가 해시에 들어가는 이유는 6강에서 내부를 열어 봅니다). 옛 커밋도 지워지지 않고 남아 있고, 브랜치 이름이 복사본을 가리키게 됐을 뿐입니다.

판단 그림. 출발점은 'main 이나 다른 브랜치의 변경을 가져오고 싶다'이다. 특정 커밋 하나만 필요하면 cherry-pick -x 를 쓴다. 아니라면 그 브랜치를 다른 사람이 이미 fetch 해서 가지고 있는지 확인한다. 없다면 main 위로 rebase 해 일직선 이력을 만든다. 있다면 main 을 브랜치에 merge 한다. 이력을 다시 쓰면 다른 사람의 커밋이 사라지거나 중복되기 때문이다.
rebase·merge·cherry-pick 고르는 법. 결정적인 질문은 '그 커밋을 이미 다른 사람이 가지고 있는가'이다.

rebase 충돌: ours 와 theirs 가 뒤집힌다

feature 브랜치는 가격을 90 으로 내렸고, main 은 같은 줄을 120 으로 올렸습니다. rebase 는 feature 커밋을 하나씩 다시 쌓으므로 첫 커밋에서 부딪힙니다.

Rebasing (1/2)Auto-merging config.txt
CONFLICT (content): Merge conflict in config.txt
error: could not apply 8286dc4... Discount price to 90
hint: Resolve all conflicts manually, mark them as resolved with
hint: "git add/rm <conflicted_files>", then run "git rebase --continue".
hint: You can instead skip this commit: run "git rebase --skip".
hint: To abort and get back to the state before "git rebase", run "git rebase --abort".
hint: Disable this message with "git config set advice.mergeConflict false"
Could not apply 8286dc4... # Discount price to 90

파일 내용은 이렇고, 여기 함정이 있습니다.

<<<<<<< HEAD
price=120
=======
price=90
>>>>>>> 8286dc4 (Discount price to 90)
tax=10

rebase 중의 HEAD 는 내 브랜치가 아니라 옮겨 갈 대상(main)입니다. 단축 플래그로 확인했습니다.

$ git checkout --ours config.txt   ->  price=120   (main 쪽)
$ git checkout --theirs config.txt ->  price=90    (내 커밋)

즉 rebase 에서 "ours 채택"은 "내 변경 버리기"입니다. merge 때 버릇대로 --ours 를 고르면 내 작업이 조용히 사라집니다. 해결하지 않고 계속하려 하면 이렇게 나옵니다.

config.txt: needs merge
You must edit all merge conflicts and then
mark them as resolved using git add

합의한 값(price=108)을 적고 git add config.txt → git rebase --continue 하자 두 번째 커밋은 깔끔하게 적용됐습니다. 헷갈리면 git rebase --abort 로 rebase 이전 상태로 돌아갈 수 있습니다.

에디터 없이 대화형 rebase 하기

git rebase -i 는 에디터로 todo 목록을 엽니다. 서버나 CI, 스크립트에서는 에디터를 못 쓸 때가 있고, 글로는 키 입력을 보여 줄 수 없으니 여기서는 환경 변수 두 개로 조작합니다.

  • GIT_SEQUENCE_EDITOR: todo 목록을 편집하는 프로그램
  • GIT_EDITOR: 커밋 메시지를 편집하는 프로그램 (reword, squash 에서 쓰임)

정리 전 브랜치:

fe6b7fb fix typo in comment
b2fb93e remove debug
6656888 WIP
c8673f4 debug print
87fd1dd Add search

먼저 cat 을 "에디터"로 써서 Git 이 만든 todo 를 출력만 해 봅니다(아무것도 바뀌지 않습니다).

$ GIT_SEQUENCE_EDITOR=cat git rebase -i main
pick 87fd1dd # Add search
pick c8673f4 # debug print
pick 6656888 # WIP
pick b2fb93e # remove debug
pick fe6b7fb # fix typo in comment

원하는 todo 를 씁니다. fixup 은 바로 위 커밋에 녹여 넣고 메시지는 버리며, reword 는 변경은 두고 메시지만 바꿉니다. 줄 순서도 바꿀 수 있습니다.

cat > todo.txt <<'EOF'
pick 87fd1dd Add search
fixup c8673f4 debug print
fixup b2fb93e remove debug
fixup fe6b7fb fix typo in comment
reword 6656888 WIP
EOF
cat > msg.sh <<'EOF'
#!/bin/sh
# git 이 커밋 메시지 파일 경로를 $1 로 넘겨준다
printf 'Add search tests\n' > "$1"
EOF
chmod +x msg.sh
GIT_SEQUENCE_EDITOR="cp $PWD/todo.txt" GIT_EDITOR="$PWD/msg.sh" git rebase -i main
Rebasing (2/5)Rebasing (3/5)Rebasing (4/5)Rebasing (5/5)[detached HEAD 43afe11] Add search tests
 Date: …
 1 file changed, 1 insertion(+)
 create mode 100644 test_search.py
Successfully rebased and updated refs/heads/feature/search.
$ git log --oneline main..
43afe11 Add search tests
11d1870 Add search

커밋 5개가 2개가 됐습니다. git diff before-tidy HEAD 는 아무것도 출력하지 않았습니다. 최종 파일은 1바이트도 바뀌지 않았고 이력만 바뀐 겁니다. 정리 전에 백업 브랜치(git branch before-tidy)를 만들고, 정리 후 이 "빈 diff"를 확인하는 것을 습관으로 삼으세요.

"에디터"가 실패하면 Git 은 아무것도 건드리지 않고 멈춥니다.

$ GIT_SEQUENCE_EDITOR=false git rebase -i main
error: there was a problem with the editor 'false'

fixup 커밋과 --autosquash

나중에 몰아서 정리하는 건 귀찮습니다. 옛 커밋을 고치는 커밋은 만들 때 표시를 달아 두는 습관이 낫습니다.

# 11d1870 "Add search" 에서 버그 발견
git add search.py
git commit --fixup=11d1870
GIT_SEQUENCE_EDITOR=true git rebase -i --autosquash main
86ccfa8 fixup! Add search        <- 전
9655543 Test search_all
...
Successfully rebased and updated refs/heads/feature/search.
67a71fb Test search_all          <- 후: fixup! 커밋은 사라지고
a31a276 Add search_all              변경은 "Add search" 안으로 들어감
3bc14a8 Add search tests
91dd3d7 Add search

GIT_SEQUENCE_EDITOR=true 는 Git 이 짜 준 todo 를 그대로 받아들이라는 뜻입니다. --autosquash 가 이미 fixup! 줄을 대상 아래로 옮겨 놓았습니다. 고친 대상뿐 아니라 그 뒤의 모든 커밋 ID 가 바뀐다는 점도 기억하세요.

cherry-pick: 수정 하나만 릴리스 브랜치로

develop 에는 다크 모드 기능, 타임아웃 수정, 재시도 횟수 변경, 세 커밋이 있습니다. 릴리스 브랜치에는 수정만 필요합니다. 앱 심사 중인 버전에 핫픽스만 얹는 상황을 떠올리면 됩니다.

$ git switch release/1.0
$ git cherry-pick -x 42758df
[release/1.0 16a5673] Fix: lower timeout to 10s
 Date: …
 1 file changed, 1 insertion(+), 1 deletion(-)
$ git log -1 --format='%h %s%n%n%b'
16a5673 Fix: lower timeout to 10s

(cherry picked from commit 42758dfeb88d68ecc9d2915678f9377241e34360)

-x 를 붙이면 메시지에 원래 ID 가 덧붙어, 나중에 "같은 수정"임을 추적할 수 있습니다. Git 도 패치를 비교해 판단할 수 있어서, git cherry 는 이미 적용된 변경에 - 를 붙입니다.

$ git cherry -v release/1.0 develop
+ 7226ea7b... Add dark mode flag
- 42758dfe... Fix: lower timeout to 10s
+ 2acd9f81... Retry 3 times

자주 만나는 메시지 세 가지:

# 같은 커밋을 두 번 pick
The previous cherry-pick is now empty, possibly due to conflict resolution.
If you wish to commit it anyway, use:

    git commit --allow-empty

Otherwise, please use 'git cherry-pick --skip'

# merge 커밋을 pick
error: commit a8e75a5f... is a merge but no -m option was given.
fatal: cherry-pick failed

# 진짜 충돌 (릴리스 쪽에서 같은 줄을 바꿔 둠)
CONFLICT (content): Merge conflict in net.conf
error: could not apply 2acd9f8... Retry 3 times
hint: After resolving the conflicts, mark them with
hint: "git add/rm <pathspec>", then run
hint: "git cherry-pick --continue".

git cherry-pick -m 1 <merge> 는 통과합니다. 다만 "첫 번째 부모 기준으로 그 merge 가 들여온 모든 변경"을 일반 커밋 하나로 적용하므로, 릴리스 브랜치에서는 대개 원하는 결과가 아닙니다. 개별 커밋을 pick 하세요.

공유된 이력은 고치지 마라: 실제로 사라진 것

"공개된 브랜치는 rebase 하지 마라"는 누구나 압니다. 어기면 어떻게 되는지 봅시다. 클론 두 개 me, mate 가 bare 원격의 feature/login 을 공유합니다. 동료(mate)가 테스트 커밋을 push 했고, 나는 fetch 하지 않은 채 feature/login 을 새 main 위로 rebase 해서 push 합니다.

$ git push origin feature/login
 ! [rejected]        feature/login -> feature/login (fetch first)
error: failed to push some refs to '~/demo/remote.git'
hint: Updates were rejected because the remote contains work that you do not
hint: have locally.

여기까지는 Git 이 막아 줍니다. 원격 추적 브랜치가 낡았으니 --force-with-lease 도 거부됩니다.

$ git push --force-with-lease origin feature/login
 ! [rejected]        feature/login -> feature/login (stale info)

그리고 아주 흔한 흐름, "stale info? 그럼 fetch 하고 다시".

$ git fetch
$ git push --force-with-lease origin feature/login
 + 0c266b9...114c254 feature/login -> feature/login (forced update)
$ git --git-dir=../remote.git log --oneline feature/login
114c254 Add login
2c0213a Main moves on
98574c9 Initial

동료의 0c266b9 Teammate: add login test 가 공유 브랜치에서 사라졌습니다. 에러도 경고도 없습니다. lease 는 "원격이 내가 마지막으로 fetch 한 상태와 같은가"만 보는데, 방금 fetch 했으니 같았던 겁니다. --force-if-includes 를 더하면 원격 끝 커밋이 내 로컬 이력에 들어 있는지도 확인하므로 막혔습니다.

$ git push --force-with-lease --force-if-includes origin feature/login
 ! [rejected]        feature/login -> feature/login (remote ref updated since checkout)

동료 쪽은 더 곤란합니다. 그냥 git pull 은 fatal: Need to specify how to reconcile divergent branches. 와 힌트를 내고 멈춥니다. 요즘 권장되는 git pull --rebase 는 성공 메시지를 냈습니다.

$ git pull --rebase
Successfully rebased and updated refs/heads/feature/login.
$ git log --oneline -3
114c254 Add login
2c0213a Main moves on
98574c9 Initial

동료 자신의 브랜치에서도 동료 커밋이 사라졌습니다. 원인은 fork-point 로직입니다. pull --rebase 는 origin/feature/login 의 reflog 를 보고 0c266b9 가 한때 "upstream"에 있었다는 사실(본인이 push 했으니 당연)에서, upstream 이 일부러 지웠다고 판단합니다.

$ git reflog show origin/feature/login
114c254 refs/remotes/origin/feature/login@{0}: pull: forced-update
0c266b9 refs/remotes/origin/feature/login@{1}: update by push

복구(이번엔 백업 브랜치가 있었습니다. 없다면 5강의 reflog 로 됩니다):

$ git reset --hard mate-backup
$ git rebase --no-fork-point origin/feature/login
warning: skipped previously applied commit 2b5dd1d
Rebasing (1/1)Successfully rebased and updated refs/heads/feature/login.
94fb347 Teammate: add login test
114c254 Add login

교훈은 "force push 금지"가 아닙니다. "아무도 fetch 하지 않은 브랜치만 force push 하고, 공유 브랜치를 force push 했다면 쓰는 사람에게 반드시 알린다. 상대의 도구가 조용히 작업을 버릴 수 있기 때문이다"입니다.

판단표: rebase, merge, cherry-pick

상황 쓸 것 이유
로컬 브랜치가 main 보다 뒤처졌고 아직 push 안 함 git rebase main 일직선 이력, 옛 ID 를 가진 사람이 없음
push 했지만 나만 쓰는 브랜치 rebase 후 push --force-with-lease --force-if-includes 두 검사가 통과하면 안전
다른 사람이 pull 했거나 그 위에서 작업 중 git merge main 고치면 남의 작업이 사라지거나 중복됨
develop 의 수정 하나만 릴리스 브랜치로 git cherry-pick -x <id> 변경 하나만 복사하고 출처를 남김
리뷰 전 "오타 수정" 커밋이 잔뜩 commit --fixup + rebase -i --autosquash todo 를 손으로 고치지 않고 정리
완성된 기능을 통째로 main 에 merge (또는 팀의 squash merge) 기능 단위로 남고 revert 하기 쉬움
모르겠으면 merge merge 는 아무것도 부수지 않음

실측: 커밋 100 개와 1,000 개 rebase

rebase 는 커밋을 하나씩 다시 적용하므로 브랜치가 길수록 오래 걸립니다. 커밋 N 개(매번 파일에 한 줄 추가)짜리 feature 브랜치를, 새 커밋이 하나 생긴 main 에 맞췄습니다. 매번 같은 상태에서 시작했고(git reset --hard feature-orig), 자동 gc 는 꺼 두었습니다(이유는 아래 흔한 실수에). Apple Silicon Mac(M1 Pro), Git 2.54.0, 각 3회.

커밋 N rebase 1 / 2 / 3회 merge 1 / 2 / 3회
100 0.855 / 0.665 / 0.657 초 0.075 / 0.069 / 0.067 초
1,000 7.901 / 7.271 / 8.669 초 0.104 / 0.133 / 0.140 초
중앙값 막대그래프. 커밋 100 개 rebase 는 0.665초, 커밋 100 개 브랜치의 merge 는 0.069초, 커밋 1,000 개 rebase 는 7.901초, 커밋 1,000 개 merge 는 0.133초.
커밋 N 개짜리 feature 브랜치를 main 에 맞춘 시간(3회 중앙값, Git 2.54.0, Apple Silicon Mac(M1 Pro)). rebase 는 커밋을 전부 다시 적용하고 merge 는 커밋 하나만 더한다.

rebase 는 거의 선형(커밋당 약 7 ms)으로 늘었고 merge 는 0.1초 안팎이었습니다. rebase 된 100(1,000)개 커밋은 모두 새 ID 를 받았습니다. 5~30 커밋짜리 평소 브랜치라면 체감되지 않는 차이지만, 수백 커밋짜리 오래된 브랜치에서는 충돌하는 커밋마다 작업이 멈추는 것이 진짜 비용입니다.

흔한 실수

rebase 충돌을 --ours 로 해결. 일어나는 일: main 쪽이 남고 내 변경은 사라진 채 rebase 가 "성공"한다. 해결: ours = 옮겨 갈 대상이라고 외우고, git diff before-rebase HEAD 로 확인한다.

rebase 후 push 가 거부돼서 git pull. 일어나는 일: 옛 커밋과 새 복사본이 merge 되어 로그에 같은 커밋이 두 개씩 보인다. 해결: 나만 쓰는 브랜치면 push --force-with-lease --force-if-includes, 공유 브랜치면 처음부터 rebase 하지 않는다.

벤치마크·스크립트가 백그라운드 gc 때문에 깨짐. 첫 측정에서 error: Could not read 3f472ec… / fatal: revision walk setup failed 가 나왔습니다. 이전 실행이 남긴 자동 git gc 가 백그라운드에서 계속 돌면서, 지우고 다시 만든 같은 이름의 디렉터리 안에서 작업하고 있었던 것입니다. 커밋을 짧은 시간에 대량으로 만드는 스크립트에서는 연습용 저장소에 git config gc.auto 0 을 설정하세요.

솔직한 한계·버전 메모

  • Git 2.54.0 (Apple Git-157) 에서만 확인했습니다. 힌트의 git config set advice… 표기는 최근 Git 의 것이고, 옛 버전은 git config advice… 로 나옵니다.
  • git switch 는 Git 2.23, --force-if-includes 는 Git 2.30 에서 추가됐습니다. "Merge made by the 'ort' strategy" 의 ort 가 기본이 된 건 2.34 부터입니다.
  • pull.rebase 를 설정하지 않은 pull 이 fatal: Need to specify how to reconcile divergent branches. 로 멈추는지는 버전마다 다르며, 2.54 는 멈췄습니다.
  • 위의 fork-point 동작은 설계대로이고 git rebase --help 의 --fork-point 항목에 설명돼 있습니다.
  • 시간은 작은 파일로 한 대에서 잰 값입니다. 큰 파일이나 충돌이 많으면 rebase 는 더 느립니다.

연습 문제

  1. 브랜치에 커밋 A(가장 오래됨), B "A 의 오타 수정", C 가 있습니다. B 를 A 에 합치고 C 는 남기는 todo 를 쓰고, 스크립트로 적용할 때 설정할 환경 변수를 답하세요.
  2. git rebase main 도중 충돌난 커밋에서 config.txt 는 내 버전을 남기고 싶습니다. git checkout 에 줄 플래그는?

정답

  1. pick A / fixup B / pick C. 적용은 GIT_SEQUENCE_EDITOR="cp todo.txt"(Git 이 인자로 넘기는 파일을 고쳐 쓰는 프로그램이면 무엇이든 됨). 더 쉬운 방법은 B 를 git commit --fixup=A 로 만들어 두고 GIT_SEQUENCE_EDITOR=true git rebase -i --autosquash main 을 실행하는 것입니다.
  2. git checkout --theirs config.txt. rebase 에서 "theirs"는 다시 적용 중인 커밋, 즉 내 변경입니다.

이전: Git 강의 3: 원격 저장소, push·pull·fetch · 다음: Git 강의 5: reset·revert·reflog 로 되돌리기 · 함께 보기: 브랜치와 merge 충돌 · 신입 학습 로드맵으로 돌아가기

자주 묻는 질문

rebase 와 merge 중 무엇을 써야 하나요?

아직 아무와도 공유하지 않은 내 브랜치라면 main 위로 rebase 해 일직선 이력을 만들고, 이미 push 해서 다른 사람이 쓰는 브랜치라면 merge 합니다. rebase 는 다시 적용한 모든 커밋에 새 ID 를 붙입니다. 실제로 100 개 커밋의 해시가 전부 바뀌었고, merge 는 기존 커밋은 그대로 두고 merge 커밋 하나만 더했습니다.

rebase 충돌에서 ours 와 theirs 가 반대인 이유는?

rebase 는 대상 브랜치 위에 내 커밋을 하나씩 다시 쌓기 때문에 HEAD(ours)가 대상인 main 이고, 적용 중인 커밋(theirs)이 내 변경입니다. 실제로 git checkout --ours 는 main 쪽 price=120 을, --theirs 는 내 price=90 을 꺼냈습니다.

git push --force-with-lease 는 항상 안전한가요?

아닙니다. 원격 브랜치가 내 원격 추적 브랜치와 같은지만 확인하므로, 동료가 push 한 뒤 내가 git fetch 를 했다면 조건이 맞아 동료 커밋을 덮어씁니다. 이를 그대로 재현했습니다. Git 2.30 이상의 --force-if-includes 를 함께 쓰면 같은 push 가 'remote ref updated since checkout' 으로 거부됐습니다.

  • #git
  • #중급
  • #rebase
  • #cherry-pick
  • #대화형 rebase
  • #force push

t.laboratory

직접 만들고 운영하는 앱·서버·서비스에서 겪은 문제와, 그 해결에 쓴 코드를 기록하는 기술 블로그입니다.

운영자 소개 · 앱

↑