rebase는 좋은 기능이지만, 조심히써야하는 기능 중 하나이다.
rebase가 사용되는 주된 용도들은 다음이다.
1. git merge 대신 사용
2. git commit, history를 지우는 용도
rebase
를 통해 code를 merge할 수 있고, commit과 history 등도 지울 수 있다.
다음의 예제를 보도록 하자.
main : m1 --> m2
\
feat2: f1---->f2---->f3
main
branch의 m1 commit으로부터 feat2
branch를 만들어 f1, f2, f3 commit을 만들었다고 하자. 이제 code를 넣으려고 했는데 다음과 같이 main
branch가 commit을 이어 나가고 있다.
feat1: ------o1--------
/ \
main : m1 --> m2 -------> m3
\
feat2: f1---->f2---->f3
feat2
branch는 m2
, m3
을 반영하지 않았기 때문에, 이들을 병합해주어야 한다. 병합을 위해서 다음과 같이 commit이 만들어지게 된다.
feat1: ------o1--------
/ \
main : m1 --> m2 -------> m3
\ \
feat2: f1---->f2---->f3--[mf1]
병합 commit
병합 commit
인 [mf1]
가 새로 나오게 된다. []
로 친 것으로 병합 commit임을 표현한 것이다. 그런데, 또 code를 넣으려고 하니까 다음과 같이 되었다.
feat1: ------o1---------->--o2---
/ \ \
main : m1 --> m2 -------> m3 ------m4
\ \
feat2: f1---->f2---->f3--[mf1]
열받는 상황이지만 또 main
branch에 이는 m4
commit을 feat2
에 병합 시킬 수 밖에 없다. 그러면 또 병합 commit이 만들어지게 되는 것이다.
feat1: ------o1---------->--o2---
/ \ \
main : m1 --> m2 -------> m3 ------m4
\ \ \
feat2: f1---->f2---->f3--[mf1]-----[mf2]
[mf1]
, [mf2]
둘 다 병합 commit이기 때문에 대괄호로 감쌌다.
이렇게 별다른 기능이 추가되지 않았음에도, feat2
branch는 merge commit들을 계속 만들 수 밖에 없다. 이렇게되면 commit history의 대부분이 merge commit이 될 수 밖에 없다.
feat1: ------o1---------->--o2---
/ \ \
main : m1 --> m2 -------> m3 ----->m4---->[mf2]
\ \ \ /
feat2: f1---->f2---->f3--[mf1]-----[mf2]
m5
가 바로 feat2
branch에서 main
branch로 넣는 commit이 되는 것이다.
rebase
는 이렇게 매번 merge conflict 과정에서 발생하는 merge commit을 만들어주지 않아도되는 장점을 가지고 있다. 이렇게되면 추가적인 commit 없이도 다른 branch와의 병합이 가능하다는 것인데, 다음과 같이 된다는 것이다.
feat1: ------o1--------
/ \
main : m1 --> m2 -------> m3
\
feat2: f1---->f2------>f3
feat2
branch의 f1
, f2
, f3
commit들을 main
branch로 병합하고 싶다.
feat1: ------o1--------
/ \
main : m1 --> m2 -------> m3------[mf1]
\ /
feat2: f1---->f2---->f3-----[mf1]
m2
,m3
를 merge하고, merge conflict를 해결한 merge commit [mf1]
를 main branch에 mf1
로 들어가게 된다.
main : m1 --> m2 --> m3 --> f1 -->f2 -->f3
feat2:
merge commit들은 없어지고 feat2
의 feature commit들만 차례대로 main
에 들어간다. 이름 그대로 rebase
한 것이다. base(branch의 시작점)을 다른 branch로 바꾼 것이다. 이렇게되면 f3
commit을 만든 이후로 main
에 rebase
만 해주면 commit history가 이쁘게 남게된다.
단, feat2
branch의 결과를 보도록 하자. 남은 commit들이 없는 것을 볼 수 있다. rebase를 사용하면 기존의 branch가 파생되는 모습이 사라지게 되어, 어디서부터 branch가 나뉘어졌고 code가 추가되었는 지는 볼 수가 없다.
demo를 해보기 위해서 github에 demo
repo를 하나 만들고, 다음의 명령어를 입력하도록 하자.
mkdir demo
cd demo
echo "# demo" >> README.md
git init
git add README.md
git commit -m "main commit1"
git branch -M main
git remote add origin https://github.com/colt/demo.git
git push -u origin main
commit이 main
branch에 잘 들어간 것을 볼 수 있다. 다음으로 feat branch인 feat1
을 하나 만들어보자. 그리고 README
에 다음의 code를 넣어보도록 하자.
# first commit
git switch -c feat1
echo "feat1 commit1" >> ./README.md
git add ./README.md
git commit -m "feat1 commit1"
# second commit
echo "feat1 commit2" >> ./README.md
git add ./README.md
git commit -m "feat1 commit2"
# third commit
echo "feat1 commit3" >> ./README.md
git add ./README.md
git commit -m "feat1 commit3"
commit3개가 만들어진 것을 볼 수 있다. 현재의 상황을 정리하면 다음과 같다.
main : m1 (remote)
\
feat2: f1---->f2---->f3 (local)
이제 conflict를 만들기 위해 다음과 같이 main branch에 commit 2개를 만들도록 하자.
git switch main
echo "main commit2" >> ./README.md
git add ./README.md
git commit -m "main commit2"
git push origin main
echo "main commit3" >> ./README.md
git add ./README.md
git commit -m "main commit3"
git push origin main
정리하면 다음과 같다.
main : m1 ---> m2 ---> m3 (remote)
\
feat2: f1---->f2---->f3 (local)
feat2
는 main
branch와 README.md
값이 충돌하게 되는 것이다.
먼저 merge를 하는 경우를 알아보자. merge의 경우 다음의 명령어로 가능하다.
git pull origin main
git switch feat1
git merge main
다음의 결과를 받게된다.
Auto-merging README.md
CONFLICT (content): Merge conflict in README.md
Automatic merge failed; fix conflicts and then commit the result.
이는 conflict가 발생했다는 것으로 conflict를 해결해야한다는 것이다.
# demo
<<<<<<< HEAD
feat1 commit1
feat1 commit2
feat1 commit3
=======
main commit2
main commit3
>>>>>>> main
우리는 둘 다 쓰도록 하자.
# demo
feat1 commit1
feat1 commit2
feat1 commit3
main commit2
main commit3
다음으로 merge 후에는 commit을 만들어야 한다. 즉, merge commit이 필요하다는 것이다.
git add ./README.md
git commit -m "feat merge commit1"
정리하면 다음과 같다.
main : m1 ---> m2 ---> m3 (remote)
\ \
feat2: f1--->f2-->f3--->[mf1] (local)
이렇게된 상태이다. mf1
commit이 바로 merge commit으로 아직 main
branch에 반영되지 않았다.
이 상태에서 github에 push해버리면 다음과 같이 되는 것이다.
main : m1 ---> m2 ---> m3 -----> [mf1] (remote)
\ \ /
feat2: f1--->f2-->f3--->[mf1] (local)
더 정확히는 다음과 같이 main branch의 commit들이 뒤죽박죽으로 만들어진다.
main : m1 --> f1 --> f2 --> f3 --> m2 --> m3 --> [mf1] (remote)
feat2: m1 --> f1- -> f2- -> f3 --> [mf1]
이것이 git merge
의 단점 중 하나이다. 이러한 feat branch들이 여러 개가 있다면 code가 어디서부터 어떻게 변동되었는 지 알기가 힘들다. 또한, merge commit이 너무 많아져서 commit history가 복잡해지는 경향성도 있다.
git log --oneline --graph --all --decorate
* c53eb24 (HEAD -> feat1, origin/feat1) feat merge commit1
|\
| * 1f58f15 (origin/main, main) main commit3
| * e413769 main commit2
* | c1c31b8 feat1 commit3
* | 456327d feat1 commit2
* | d5500a4 feat1 commit1
|/
* 0539362 main commit1
이러한 상태가 되는 것이다. 수 많은 branch에서 commit들을 생성해서 merge해버리면 이 graph가 매우 복잡하게 뻗어나갈 것이 분명하다. 이는 history를 알기 너무 어렵고, commit으로 code 추적이 쉽지 않다는 것을 의미한다.
따라서, 이런 경우에 git rebase
를 사용하여 해당 문제를 해결할 수 있다.
먼저, github에 적용된 merge를 revert하도록 하자.
되돌리는 방법은 git revert
와 git reset
을 사용하면 된다. 단지, git revert
는 특정 commit의 내용을 없애지면 commit의 기록은 없애지 않는다. 즉, 민감한 정보가 들어간 경우 민감한 정보가 들어간 commit을 revert하여 github에서 삭제시킬 수 있지만, 그 commit 기록이 남는다. 따라서, 민감한 정보가 남아있는 commit이 남아있다.
반면에 git reset
은 특정 commit으로 되돌아가는 방법이다. 이때 이전의 commit 기록은 남지않는다. 따라서, 민감한 정보가 있는 경우는 git reset
으로 되돌려야 한다.
git reset
을 쓰기 전에 git log
나 git reflog
를 통해서 전체 commit history를 볼 수 있다.
git reflog
...
d5500a4 HEAD@{15}: commit: feat1 commit1
0539362 HEAD@{16}: checkout: moving from main to feat1
0539362 HEAD@{17}: Branch: renamed refs/heads/master to refs/heads/main
0539362 HEAD@{19}: commit (initial): main commit1
이런 식으로 나올 것이다. 가장 맨 마지막에 있는 것이 처음 들어간 commit을 말한다. 해당 commit으로 되돌아가보자.
git reset --hard 0539362
되돌아가졌다면 README를 확인해보도록 하자.
# demo
# demo
만 남아있는 것을 볼 수 있다.
그렇다면 main
commit3까지 만들었던 때로 다시 되돌아가보자.
git reflog
...
c53eb24 (origin/feat1, feat1) HEAD@{8}: commit (merge): feat merge commit1
c1c31b8 HEAD@{9}: checkout: moving from main to feat1
1f58f15 HEAD@{10}: commit: main commit3
e413769 HEAD@{11}: commit: main commit2
0539362 (HEAD -> main) HEAD@{12}: checkout: moving from main to main
0539362 (HEAD -> main) HEAD@{13}: checkout: moving from feat1 to main
c1c31b8 HEAD@{14}: commit: feat1 commit3
456327d HEAD@{15}: commit: feat1 commit2
d5500a4 HEAD@{16}: commit: feat1 commit1
0539362 (HEAD -> main) HEAD@{17}: checkout: moving from main to feat1
0539362 (HEAD -> main) HEAD@{18}: Branch: renamed refs/heads/master to refs/heads/main
0539362 (HEAD -> main) HEAD@{20}: commit (initial): main commit1
1f58f15
까지가 main
과 feat1
branch가 서로 작업이 이루어지고 병합이 이루어지지 않은 commit 경계인 것을 볼 수 있다. 이제 되돌아가보자.
git reset --hard 1f58f15
# demo
main commit2
main commit3
잘 되돌아간 것을 볼 수 있다. 이제 github에도 해당 값을 반영하도록 하자.
git push -f origin main
github에 들어가면 commit들이 다시 되돌아간 것을 볼 수 있을 것이다.
이제 feat1
branch로 돌아가서 merge를 취소시키기로 하자.
git switch feat1
git log
commit c53eb24720bce4b4c9328aa6bafd71a8f11e4d17 (HEAD -> feat1, origin/feat1)
Merge: c1c31b8 1f58f15
Author: colt
Date: Tue Jan 21 10:54:59 2025 +0900
feat merge commit1
commit 1f58f1521213829ff894d7d4e98c468222ca06c5 (origin/main, main)
Author: colt
Date: Tue Jan 21 10:51:20 2025 +0900
main commit3
...
commit c1c31b8980a957e194b0c9be79a1b7740cb1ae1e
Author: colt
Date: Tue Jan 21 10:47:16 2025 +0900
feat1 commit3
merge하기 이전인 feat1 commit3
으로 되돌아가도록 하자.
git reset --hard c1c31b8980a957e194b0c9be79a1b7740cb1ae1e
cat ./README.md
다음과 같이 README.md
가 나오면 성공이다.
# demo
feat1 commit1
feat1 commit2
feat1 commit3
해당 reset 사항을 github에도 반영하도록 하자.
git push -f origin feat1
github
에도 이쁘게 반영되었을 것이다.
이제 다시 처음으로 돌아왔는데, 현재 되돌린 사항은 다음과 같다.
git log --oneline --graph --all --decorate
* 1f58f15 (origin/main, main) main commit3
* e413769 main commit2
| * c1c31b8 (HEAD -> feat1, origin/feat1) feat1 commit3
| * 456327d feat1 commit2
| * d5500a4 feat1 commit1
|/
* 0539362 main commit1
다시 이상태가 된 것이다.
main : m1 ---> m2 ---> m3 (remote)
\
feat2: f1---->f2---->f3 (local)
이제 rebase를 해보도록 하자. 먼저 main
branch를 최신 상태로 유지하도록 한다.
git switch main
git pull origin main
다음으로, feat1
branch에서 git rebase
를 실행하도록 하자.
git switch feat1
git rebase main
First, rewinding head to replay your work on top of it...
Applying: feat1 commit1
Using index info to reconstruct a base tree...
M README.md
Falling back to patching base and 3-way merge...
Auto-merging README.md
CONFLICT (content): Merge conflict in README.md
error: Failed to merge in the changes.
Patch failed at 0001 feat1 commit1
hint: Use 'git am --show-current-patch' to see the failed patch
Resolve all conflicts manually, mark them as resolved with
"git add/rm <conflicted_files>", then run "git rebase --continue".
You can instead skip this commit: run "git rebase --skip".
To abort and get back to the state before "git rebase", run "git rebase --abort".
뭔가 많은 내용이 나올 것이다. 이는 conflict가 발생해서 그런데, merge와는 다르게 README.md
에 다음을 rebase하라고 나온다.
# demo
<<<<<<< HEAD
main commit2
main commit3
=======
feat1 commit1
>>>>>>> feat1 commit1
이는 rebase
의 특성 상 충돌이 난 commit들에 대해서 하나씩 merge 작업을 시도하기 때문이다. 따라서 feat commit1
부터 차근차근 merge를 시키면 된다.
# demo
main commit2
main commit3
feat1 commit1
만약 conflict에 대해서 merge 작업 중에 잘못 코드를 건드렸어서 처음부터 rebase를 하고싶다면 다음의 명령어를 입력하면 된다.
git rebase --abort
conflict를 해결했다면 git add
명령어를 통해 conflict를 해결했다고 알려주어야 한다.
git add ./README.md
다음으로 rebase를 계속 진행하겠다는 명령어를 입력해주면 된다.
git rebase --continue
Applying: feat1 commit1
Applying: feat1 commit2
Using index info to reconstruct a base tree...
M README.md
Falling back to patching base and 3-way merge...
Auto-merging README.md
Applying: feat1 commit3
Using index info to reconstruct a base tree...
M README.md
Falling back to patching base and 3-way merge...
Auto-merging README.md
feat1 commit1
, feat1 commit2
, feat1 commit3
모두 rebase가 완료된 것을 볼 수 있다.
이제 git log
를 통해서 전체적으로 어떻게 commit 사항이 연결된 것인지 확인해보도록 하자.
git log --oneline --graph --decorate --all
* 3e28ed3 (HEAD -> feat1) feat1 commit3
* cc20d5a feat1 commit2
* 20be382 feat1 commit1
* 1f58f15 (origin/main, main) main commit3
* e413769 main commit2
| * c1c31b8 (origin/feat1) feat1 commit3
| * 456327d feat1 commit2
| * d5500a4 feat1 commit1
|/
* 0539362 main commit1
쭉 연결된 것을 볼 수 있다.
main : m1 ---> m2 ---> m3 ---> f1---->f2---->f3
\
feat2: f1---->f2---->f3 (local)
이렇게 된 것을 볼 수 있다.
이처럼 rebase는 별도의 merge commit없이도, merge를 진행할 수 있으며 commit msg가 main branch에 이쁘게 정렬된 것을 볼 수 있다. 이러한 git rebase
기능 덕분에 git commit history를 보는 것이 더 쉬워진다.
이제 해당 코드를 main branch에 올려보도록 하자.
git push origin feat1
To https://github.com/colt/demo.git
! [rejected] feat1 -> feat1 (non-fast-forward)
다음의 에러가 발생할 것이다. 에러가 발생한 이유는 간단한데, github에 있는 feat1 branch의 commit 이력과 local에 있는 feat1 branch의 commit 이력이 다르기 때문이다. 따라서, --force
명령어로 강제적인 push를 해줄 수 밖에 없다.
git push -f origin feat1
성공할 것이고, pull requests를 만들 수 있을 것이다.
git rebase는 다른 사람들이 공유하는 branch에 대해서는 rebase
하지말라는 것이다. 이는 하나의 대전제인데, git rebase
는 반드시 자기 자신의 feat branch(local branch)에만 사용하여 다른 사람들이 공유하는 branch로 집어 넣도록 한다.
이러한 대전제가 생긴 이유는 rebase
를 사용하면 git history를 바꾸기 때문이다. 모두가 공유하는 branch를 rebase 해버리면 엄청난 충돌을 만들어낼 수 있다는 것이다.
아래와 같이 owner
와 clinet
가 동일한 main
branch에서 서로 다른 commit으로 branch를 파서 개발 중이라고 하자.
owner: o1 --> o2
/
main: m1 --> m2 --> m3
\
client: c1
이때 owner
가 o1
, o2
commit을 만들고, main
branch로 가서 owner
branch의 내용들을 rebase하도록 한다면 다음과 같이 될 수 있다.
owner: o1 --> o2
/
main: m1 --> m2 --> o1 --> o2 --> m3
\
client: c1
c1
의 경우 날벼락을 맞게 된다. 내가 알던 m3
commit의 내용이 o1
, o2
에 의해서 달라질 수 있기 때문이다. 기존의 code가 변화한 것은 둘째치고 commit history가 변화했으니 어디서 무엇이 어떻게 바뀌었는 지 알기가 쉽지 않다.
commit들이 섞여 들어가게 되고, 마지막 commit은 분명 m3
인데, 원래 알던 m3
가 아니게 될 것이다. o1
, o2
가 들어가게 되면서 m3
에 변화를 가져오게 되는 것이다.
만약, owner
branch에서 main
으로 rebase를 실행했었다면 다음과 같이 되었을 것이다.
owner: o1 --> o2
/
main: m1 --> m2 --> m3 --> o1 --> o2
\
client: c1
commit history가 훨씬 깔끔해지고, 상대방의 code에 큰 침해를 가하지 않는다.
따라서, 절대 모두가 공유하는 branch에 rebase를 실행하지 않도록 하고, 자신이 사용하는 branch에만 rebase를 사용하도록 하자.