우아한테크코스 오리와 코린의 Merge, Rebase, Cherry Pick을 듣고 정리한 글입니다.
들어가기 전, 사전 지식
HEAD는 현재 브랜치를 가리키는 포인터이며, 브랜치는 브랜치에 담긴 커밋 중 가장 마지막 커밋을 가리킨다. origin/HEAD는 원격 레파지토리의 기본 브랜치의 HEAD 포인터를 의미한다.
예시로 1주 차 미션에 내가 반영한 커밋의 시작 로그와 마지막 로그이다.
마지막 로그 :(HEAD -> le2sky, origin/le2sky) refactor: 메서드 간격정리(문제 7)
시작 : (origin/main, origin/HEAD, main) feat: setup precourse onboarding project
$ git remote -v
origin https://github.com/le2sky/java-onboarding.git (fetch)
origin https://github.com/le2sky/java-onboarding.git (push)
마지막 로그는 (HEAD -> le2sky, origin/le2sky) 이다. 즉 origin/le2sky와 le2sky 브랜치는 같은 마지막 커밋을 가지고 있다.( 동기화된 상태) 반면에 origin의 HEAD는 시작 커밋을 가지고 있다. 즉, origin의 마지막 커밋이 반영된 브랜치를 origin/HEAD가 가리키는 것이 아니라, origin의 기본 브랜치를 origin/HEAD가 가리킨다.
병합(Merge)이란?
git은 서로 다른 작업을 하기 위한 별도의 공간을 생성할 때 브랜치를 생성할 수 있다. 기능 구현을 위해서 해당하는 기능을 구현하기 위한 브랜치를 생성한 이후, 기능 구현이 완료되면 main 브랜치에 병합할 수 있다. 두 브랜치를 합치는 과정을 git에서는 merge라는 기능으로 도와준다.
Fast-forward 방식
현재 다음과 같이 두 가지 커밋이 있는 상황이다. oriori 브랜치를 분기하여 커밋을 반영한 상태이다. origin의 main과 HEAD, main이 모두 같은 커밋을 바라본다. 즉, 원격 레파지토리, 로컬 레파지토리의 main은 모두 `feat: setup baseball game project` 커밋으로 동기화되어 있다.
그리고 oriori 브랜치로 두 가지 커밋이 반영되어있는 상태이다. (기능 리스트 추가와 구현) 이제 기능 브랜치를 메인에 병합을 해야 한다.
$ git checkout main
$ git merge oriori
위 명령어를 실행하면 다음과 같이 출력된다.
Updating ..
Fast-forward
로컬 레파지토리의 main 브랜치가 기존 `feat: setup baseball game project` 커밋에서 기능 브랜치의 최신 커밋을 가리키게 된다. 이러한 방식을 병합의 방법 중 하나인 Fast-forward라고 한다.
쉽게 그림으로 표현하자면 다음과 같다.
위와 같은 상황에서 다음과 같은 명령어를 사용했다고 가정한다.
$ git checkout master
$ git merge hotfix
Updating f42c576..3a0874c
Fast-forward
index.html | 2 ++
1 file changed, 2 insertions(+)
결과적으로는 아래 그림처럼 master 브랜치가 hotfix 브랜치가 가리키는 커밋으로 fast-forward(빨리 감기) 된다. 별도의 커밋을 만들어 병합하는 것이 아닌, master가 가리키고 있는 브랜치 포인터를 바꾸기만 하면 된다.
3-way-merge 방식
나의 레파지토리가 다른 레파지토리에서 코드를 당겨오고 푸시를 한다면 내 레파지토리가 downstream이 되며, 다른 레파지토리가 upstream이 된다.
- woowacourse/java-racingcar-precourse을 포크 했다고 가정
- woowacourse/java-racingcar-precourse은 upstream이 된다.
- le2sky/java-racingcar-precourse은 downstream이 된다.
- le2sky/java-racingcar-precourse을 클론 했다고 가정
- le2sky/java-racingcar-precourse은 origin이 된다.
- desktop/woowacourse-project/java-racingcar-precourse의 디폴트 리모트는 le2sky/java-racingcar-precourse이 되며, 이를 origin이라 한다.
만약 위와 같은 상황에서 upstream/main의 변경점을 반영해야 하는 상황이 오면 어떻게 해야 할까? 일단 원본 레파지토리(woowacoursejava-racingcar-precourse)의 변화를 추적하고 싶다면 upstream이라는 이름의 리모트를 추가해야 한다.
$ git remote add upstream https://github.com/woowacourse/java-racingcar-precourse.git
다음과 같이 upstrea/main이 변경사항이 생겼다. 기존 origin/main + A 가 있었다면, upstream/main에 변경점이 생겨서 origin/main + B의 갈래가 생겼다.
우리는 이러한 갈래를 기능 브랜치에 반영해야 한다. 따라서 기능 브랜치로 가서 upstream에 있는 정보를 모두 합쳐야 한다.
$ git fetch upstream main
$ git merge upstream/main
pull 명령어는 fetch + merge를 한 번에 해주는 명령어다. fetch는 원격 저장소의 최신 이력을 확인할 수 있다. 이때 가져온 최신 커밋 이력은 이름 없는 브랜치로 로컬에 가져오게 된다. 이 브랜치는 'FETCH_HEAD'의 이름으로 체크아웃할 수 있다.
쉽게 그림으로 표현하자면 다음과 같다.
브랜치 iss53이 만들어진 이후에 master에 새로운 커밋이 반영됐다. 따라서 위와 같은 경우에는 fast-forward 방식이 사용될 수없다. 왜냐하면, 공통 조상으로부터 다른 갈래가 나왔기 때문이다. Fast-forward 같은 경우에는 공통 조상에서 갈래가 없기 때문에 평평하게 만들기만 하면 된다. (위에서 c4가 없다고 가정하면, C3-C5를 위로 올려서 평평하게 만든다는 뜻)
위와 같은 상황에서 git은 내부적으로 master와 iss53의 공통 조상인 C2를 찾고, 3-way merge 방식으로 C4와 C5를 합치게 된다. 그리고 합쳐진 결과를 새로운 커밋으로 반영한다.
각 브랜치의 마지막 커밋 두 개와 공통 조상 커밋 한 개, 즉 총 3개의 커밋을 이용해 새로운 커밋을 만들어 내는 것을 3-way-merge라고 한다.
공통 조상이 필요한 이유는 충돌을 확인하는데 용이하기 때문이다. 만약 공통 조상 커밋이 없고 두 개의 브랜치만으로 비교해서 병합한다고 가정하자.
b를 제외하고, a가 원래 a였는지 a` 였는지 정하기 어렵다. 따라서 충돌의 여부를 알 수가 없다. 반면 base 커밋을 함께 비교하면 충돌의 여부를 확실히 알 수 있다.
Conflict(충돌)
3-way-merge 중에서 만약 같은 것을 수정한 이력이 있다면, 병합하는 과정에서 Conflict(충돌)가 발생하게 된다. git은 둘 중 어떤 수정사항을 반영해야 하는지 모르기 때문이다.
$ git fetch upstream main
$ git merge upstream/main
$ git status
...
...
Unmerged paths:
(use "git add <file>..." to mark resolution)
both modified: src/test/java/racingcar/ApplicationTest.java // <- merge conflict!
merge를 수행하기 위해서는, 충돌을 해결해야 한다. (두 파일 중 하나의 변경사항을 버린다.) 충돌을 해결한 이후에는 아래와 같이 충돌을 해결한 내용에 대해 스테이징 하고 커밋한다.
$ git add .
$ git status
On branch oriori
all conflicts fixed but you are still merging
...
Changes to be committed:
modified: src/test/java/racingcar/ApplicationTest.java
$ git commit
깃허브에서 제공하는 PR은 조금 다르게 동작하는 3가지 머지 방법을 따로 제공한다.
Create merge commit
$ git checkout main
$ git merge --no-ff feature
create merge commit 방식은 베이스 브랜치가 같아도 fast forward를 진행하는 것이 아니라 하나의 머지 커밋을 만들어 병합한다. 만든 기능에 대해서 머지 분기점이 생기므로 어떤 기능을 만들어 메인에 병합했는지 알 수 있게 되어 가독성이 좋아진다.
Squash and merge
여담으로 개인적으로 애용하는 병합 방식이다.
$ git checkout main
$ git merge --squash feature
$ git commit -m "squash merge message"
create merge commit처럼 새로운 머지 커밋을 만들어 바라보게 한다. 다만 모든 커밋을 하나의 커밋으로 통합하여 병합한다. 이렇게 되면 커밋이 너무 많아 알아보기 힘들 경우에 유용하다. 특정 기능에 대한 커밋을 하나만 두어서 가독성을 높인다.
Rebase and merge
$ git checkout feature
$ git revase master
$ git checkout master
$ git merge feature
rebase는 간단히 이야기하면 base를 다시 조정하는 것이다. 즉, feature에서 작업했던 커밋들을 현재 메인 브랜치 최상단에 복붙 하는 작업이다. 이렇게 되면 커밋이 많을 경우 한 줄로 모든 커밋이 저장되어 난잡해 보일 수 있다.
Merge 요약
- Merge 할 때 base가 같으면 Fast-forward 한다.
- Merge할 때 base가 다르면 merge commit을 생성하여 auto merge 한다. (단 충돌은 개발자가 직접 처리한다.)
- Github Pull Request에서 merge 할 수 있는 3가지 방법이 존재한다.
Rebase란?
merge와의 공통점은 브랜치를 합친다는 점이고, 차이점으로는 merge보다 깨끗한 커밋 기록을 만든다는 것이다.
3-way-merge를 수행했던 시점을 다시 떠올려보자.
$ git remote add upstream https://github.com/woowacourse/java-racingcar-precourse.git
$ git fetch upstream main
$ git rebase upstream/main
fetch 명령어로 원본 저장소의 코드를 불러오고 rebase로 브랜치를 합쳤다. 테스트 코드가 변경된 커밋의 베이스가 아예 변경되었다.
아래는 3-way-merge를 통해 병합된 히스토리 그래프이다. 확실히 rebase가 가독성이 좋다.
merge와 rebase의 목적은 한 브랜치에서 다른 브랜치를 합치는 것이다. 다만, rebase는 현재 branch의 base 자체를 재설정하여 합치는 것이다.
현재 corinne 브랜치의 베이스는 아래와 같다.
corinne 브랜치에서 git rebase upstream/main를 실행하면 다음과 같이 변경된다.
기존에 있던 커밋들은 그대로 존재하고, 베이스 포인터만 변경된다고 생각할 수 있지만, 그것이 아니라 베이스가 바뀔 커밋들을 복사하여 연이어 붙이는 것이다.
따라서 리베이스 전의 커밋들과 리베이스 이후의 커밋들은 id가 다르다.
기본적으로 commit은 이전에 가지고 있던 commit을 포함한다. 그렇다면 베이스가 c1에서 c1+c2로 변경된 것이다.
아래와 같은 극단적인 상황을 rebase로 정리하면 깔끔해질 수 있다.
Rebase 요약
- Rebase는 브랜치를 합치려는 목적으로 사용된다. (커밋 히스토리가 머지와 다르게 선형적으로 그려진다.)
- Rebase는 현재 브랜치의 base를 바꾸겠다는 것이다. 생성된 커밋들은 새롭게 복사되어 base가 변경된다.
- Merge를 한 코드 결과와 Rebase를 한 코드 결과는 같아야 한다.
Cherry-pick 이란?
체리 픽은 다른 브랜치에 있는 커밋을 선택적으로 내 브랜치에 적용시키는 것이다.
위와 같은 상황에서 로컬에서 develop 브랜치가 파생되고, 3개의 커밋이 반영됐다. 이러한 상황에서 기능 2 커밋을 당장 배포해야 하는 상황이 생겼다. 하지만 기능 3은 테스트가 되지 않아 기능3 커밋은 반영하고 싶지 않다.
아래와 같이 기능 2만을 메인에 병합하고 싶은 경우에 어떤 방법을 사용할 수 있을까?
방법 1
$ git checkout main
$ git cherry-pick e19c319
$ git push origin main
이러한 상황에서 체리 픽을 사용할 수 있다. 첫 번째 방법은 메인 브랜치에서 체리픽을 하여 바로 origin main에 push 하는 방법이다.
하지만 위와 같이 main에 직접 push하는 방법은 주로 사용되지 않는다.
방법 2
$ git checkout main
$ git checkout -b cherry
$ git cherry-pick e19c319
$ git push origin cherry
체리 브랜치를 만들어 기능 2 커밋을 체리 픽으로 가져온 다음에 origin cherry로 push 한다. 이후에는 깃헙에서 origin/main으로의 pull request를 연다. 누군가 PR을 승인하여 create merge commit을 하게 되면 아래와 같은 그래프가 그려진다.
Cherry-pick 사용법
한 개만 가져오고 싶을 경우
$ git cherry-pick e119c319
여러 개 가져오고 싶은 경우
$ git cherry-pick e19c319 3fe7c452 bd69a820
연속된 커밋을 가져오고 싶은 경우
$ git cherry-pick e19c319..bd69a820
Cherry-pcik conflict 해결하기
- Conflict를 해결하기 위해 코드를 수정한다.
- Git add 명령으로 수정된 코드를 add 한다.
- Git cherry-pick --continue 명령을 수행한다.
아래와 같은 상황에서 develop 브랜치에서 4c2272cc 커밋이랑 9def3669 커밋을 가져오면 충돌이 발생한다. 충돌을 해결하고 체리 픽을 수행하면 아래와 같은 그래프가 생긴다.
체리픽을 하는 경우 같은 내용을 가진 커밋이 여러 개가 생기기 때문에 누가 누굴 체리 픽했는지 모르는 상황이 생길 수 있다. 이러한 유의점을 잘 파악하고 사용해야 한다.
Cherry-pick 요약
- Cherry-pick으로 다른 브랜치 커밋을 내 브랜치로 가져올 수 있다. (한 개, 여러 개 또는 연속된 구간의 커밋)
- Cherry-pick이 된 커밋들은 복사된다.
- Cherry-pick 진행 시 충돌이 발생할 수 있으며, git add, continue 명령어로 해결한다.
'맛있지만 저작권 문제 > 테코톡' 카테고리의 다른 글
[테코톡 정리] 어썸오의 JVM Memory Layout (0) | 2022.11.12 |
---|---|
[테코톡 정리] 제리의 MVC 패턴 (0) | 2022.11.12 |