커밋들을 자유롭게 넘나드는 방법 - git rebase

git add, git commit, git push 제외하고 자주 쓰는 git command는 당연 git rebase다. 왜냐하면 팀에서 fast-forward merge가 규칙이기 때문이다. fast-forward merge가 되면 커밋을 일렬(linear)로 유지할 수 있다. (간단하게 머지 커밋을 남기지 않는 머지 전략이다.) 또 다른 경우는 커밋을 수정하고 싶을 때다. 일련의 커밋들 사이에 마음에 들지 않은 커밋 메시지가 있다든가, 해당 커밋의 작업을 수정하고 싶다든가 할 때 사용한다. 본 글을 읽으면 다음 상황에 대응할 수 있다.

  • git history를 일렬로 관리하고 싶다.
  • 커밋들 일부를 합칠 수 있다.
  • 특정 커밋 이름을 변경할 수 있다.
  • 특정 커밋 작업을 변경할 수 있다.
  • 특정 커밋 작업을 삭제할 수 있다.
  • git rebase의 위험성을 알고 있다.

그렇다면 git rebase의 동작원리와 git rebase --interactive 옵션에 대해 알아보자.

git rebase 동작 원리

표준 모드의 Git 리베이스는 현재 작업 중인 브랜치의 커밋을 자동으로 가져와서 전달된 브랜치의 헤드로 적용합니다.

git rebase <타겟 브랜치>는 현재 작업 중인 브랜치의 커밋들을 자동으로 가져와서 타겟 브랜치의 헤드로 옮기는 명령어다. 타겟 브랜치를 기반으로 현재 브랜치의 커밋들을 옮긴다. 두개의 브랜치가 있다고 가정하자.

  • master
  • feature : master을 기반으로 만들어진 브랜치

이제 main과 feature 각각 작업이 추가되어서 git graph가 아래 이미지처럼 변경되었다. merge를 깔끔하게 하기 위해서 rebase를 사용하고 싶다. 그러면 어떤 브랜치가 타겟브랜치가 되어야 하는가?

정답은 master이다. git checkout feature로 이동한 후, git rebase master을 실행해야 한다. feature 브랜치의 커밋들이 master브랜치 뒤로 옮겨진다. 옮기는 과정중에 merge-confilct처럼 충돌이 발생할 수 있다. 이 때는 충돌을 해결하고 git commit을 다시 실행한다. 그 후 git rebase --continue를 실행하면 된다.

만약 master에서 git rebase feature를 실행하면 feature를 기반으로 master의 커밋들을 옮긴다. master의 커밋들이 feature 브랜치 뒤로 옮겨진다. master의 브랜치가 완전히 꼬여서 리모트 깃 저장소에 올릴 수 없는 상태가 된다.

정석적인 방법은 다음과 같다. 해당 방법으로 깃 히스토리를 일렬로 유지할 수 있다.

# rebase가 필요한 브랜치로 이동
git checkout feature
# 머지 대상 브랜치로 rebase
git rebase master
# 머지 대상 브랜치로 이동
git checkout master
# 머지 대상 브랜치를 현재 브랜치로 머지
git merge feature

mergerebase의 차이는 다음과 같다. 아래 왼쪽 이미지는 머지(3-way-merge)의 예시다. fast-forward merge가 아니라면 머지 커밋이 추가되며 머지된다. 이 경우에는 일렬로 히스토리가 유지되지 않는다. 하지만 오른쪽 이미지처럼 rebase를 사용하면 무조건 fast-forward-merge가 되어서 일렬로 히스토리가 유지된다.

git rebase --interactive 옵션

interactive옵션을 사용하면 rebase를 더욱 세밀하게 조정할 수 있다. git rebase -i <수정할 커밋의 직전 커밋>을 사용하면 된다. base를 수정할 커밋의 직전 커밋으로 하고 그 이후의 commit들을 내 맘대로 수정할 수 있다. git rebase -i HEAD~3을 실행하면 아래와 같은 창을 볼 수 있다.

pick 7485b30 fix: example  빌드에러
pick cf10d0f main1
pick fd1b858 main2
#  p, pick = use commit 해당 커밋을 그대로 사용한다.
#  r, reword = use commit, but edit the commit message 커밋 메시지를 변경한다.
#  e, edit = use commit, but stop for amending 커밋을 수정한다.
#  s, squash = use commit, but meld into previous commit 이전 커밋과 합친다. 커밋 메시지는 그대로 사용한다.
#  f, fixup = like "squash", but discard this commit's log message 이전 커밋과 합치고 커밋 메시지를 버린다.
#  x, exec = run command (the rest of the line) using shell rebase 과정 중 입력한 명령어를 실행한다.

rebase를 베이스로 사용한 커밋의 이후 커밋들이 오래된 순서로 정렬되어 나타난다. 커밋 SHA 앞에 있는 pick 키워드를 변경해서 커밋의 운명을 결정할 수 있다. p,r,e,s,f,x 등등 여러 키워드가 있는데 rebase 창에서 주석으로 설명이 작성되어 있다. 키워드를 적고 입력창을 나가면 키워드가 p(pick)이 아닌 커밋들로 이동해서 각 키워드에 맞게 작업을 진행한다.

만약 main1 커밋 메세지를 변경하고 싶다면 다음과 같이 변경해야 한다.

pick 7485b30 fix: example앱 빌드에러
r cf10d0f main1
pick fd1b858 main2

:wq를 입력해서 창을 나가면, 커밋 메시지를 변경할 수 있는 창이 나타난다. 메시지를 변경하고 :wq를 입력하면 rebase가 완료된다.

다시 돌아와서 fix: example 앱 빌드에러main1 커밋을 합치고 싶다면 다음과 같이 변경해야 한다.

# 아래로 내려갈수록 최신 커밋이다.
pick 7485b30 fix: example  빌드에러
s cf10d0f main1
pick fd1b858 main2

:wq를 입력해서 창을 나가면 커밋 메시지를 변경할 수 있는 창이 나타나고 메시지를 적고 창을 나가면 두 커밋이 합쳐진 채로 rebase가 완료된다.

특정 커밋 작업을 삭제하고 싶을 때는 다음과 같이 변경한다. 창을 나가면 git rebase --continue를 실행하지 않아도 rebase는 완료된다.

# 아래로 내려갈수록 최신 커밋이다.
pick 7485b30 fix: example  빌드에러
# drop 키워드 혹은 해당 줄(line)을 지워버리면 된다.
drop cf10d0f main1
pick fd1b858 main2

다음은 특정 커밋을 메시지도 수정하고 작업도 바꾸고 싶을 때는 아래와 같이 작성한다.

# 변경하고 싶은 커밋에 e 혹은 edit 키워드를 입력한다.
pick 7485b30 fix: example  빌드에러
pick cf10d0f main1
e fd1b858 main2

창을 나가면 아래와 같은 메시지가 나타난다.

Stopped at d2d8daf...  main2
You can amend the commit now, with
 
  git commit --amend
 
Once you are satisfied with your changes, run
 
  git rebase --continue

여기서 세 가지 선택지가 있다.

  1. 현재 커밋에 새로운 작업 더하기
  2. 새로운 작업을 추가하고 새로운 커밋을 만들기
  3. 현재 커밋을 쪼개기

1번은 제일 간단하다. 작업을 한 후 git addgit commit --amend를 실행하면 된다. 그리고 git rebase --continue를 실행하면 끝난다.

2번은 새로운 작업을 추가하고 git addgit commit을 실행하면 된다. 그리고 git rebase --continue를 실행하면 끝난다.

마지막으로 현재 커밋을 쪼개려면 reset 명령어를 사용해야 한다. git reset HEAD~1을 통해서 커밋을 되돌린다. 그 후 작업을 나뉘어서 git addgit commit을 실행한다. (같은 파일에서 일부만 add해서 커밋하고 싶다면 git add -p를 사용하면 된다.)

git rebase의 위험성

git rebase가 위험한 이유는 커밋들을 옮기면서 해시값이 모두 변경되기 때문이다. 깃은 전체 파일들을 보고 커밋 해시값을 결정한다. rebase를 실행하면 기존 브랜치와는 파일들이 달라지므로 커밋 해시값도 달라지는 것이다. 이를 커밋이 유실된다고 말하기도 한다. rebase를 실행한 후 원격저장소에 force push를 해야 하는 것도 이 때문이다.

만약 rebase결과를 다시 돌리고 싶으면 어떻게 할까? git reflog를 사용하면 된다. git reflog는 로컬에서 발생한 깃 관련 히스토리들을 볼 수 있는 명령어다. 돌리고 싶은 커밋의 해시값을 찾아서 git reset --hard <해시값>을 실행하면 된다.

8e5a5c5 (main) HEAD@{1}: checkout: moving from main to 8e5a5c5
8e5a5c5 (main) HEAD@{2}: checkout: moving from fe265c1dd7d20e3f6fb5f8d06f20cabdf6c26190 to main
fe265c1 (fearue) HEAD@{3}: checkout: moving from main to fe265c1
8e5a5c5 (main) HEAD@{4}: rebase (finish): returning to refs/heads/main
8e5a5c5 (main) HEAD@{5}: rebase (continue): main2
fe265c1 (fearue) HEAD@{6}: rebase (start): checkout fearue
fd1b858 (HEAD) HEAD@{7}: commit: main2
cf10d0f HEAD@{8}: commit: main1

하지만 더 위험한 것은 원격저장소에 코드를 push했을 때다. 만약 해당 브랜치가 다른 사람과 함께 작업하는 브랜치였다면 골치 아픈 일이 발생한다. 다른 작업자가 git pull을 실행할 때 충돌이 발생할 수 있다. 충돌하는 과정에서 작업 자체가 변경될 수 있다. 그리고 다른 작업자는 충돌을 해결하고 커밋을 추가해 또다시 push한다. 이런 과정을 반복하면서 코드가 꼬일 수 있다. 어쩔 수 없이 원격브랜치에 push할 때는 공동 작업자들과 충분히 협의하고 push 해야 한다.

마치며

사실 입사하고 나서 rebase를 처음 알았다. 그전까지는 merge만 사용했었다. 입사 전에는 혼자 개발했기 때문에 커밋 히스토리 관리의 필요성을 느끼지 못했다. 하지만 협업을 하면서 그 필요성을 느끼게 되었다. 커밋 히스토리가 일렬로 유지되지 않는다면 커밋 히스토리가 뚱뚱해져서 히스토리 파악이 어려워진다. rebase는 커밋히스토리를 깔끔하게 할 수 있다는 것을 넘어서 --interactive 속성을 통해서 커밋들을 자유롭게 넘나들 수 있게 해준다. 커밋을 쪼갤 수도 있고, 커밋을 삭제할 수 있고, 커밋 명만 변경할도 있다. 커밋을 넘나들 수 있는 무기가 생겼지만 모든 무기는 적절하게 사용해야 한다. 우리가 가진 무기의 위험성을 파악하고 적절한 때에 사용하자.

reference