Git rebase interactive 사용법 | pick·squash·fixup·충돌 해결·실수 복구

이 글의 핵심

interactive rebase는 커밋 기록을 새로 쓰는 작업이라 이미 원격에 푸시한 커밋에 쓰면 팀원의 히스토리와 어긋납니다. 이 글은 에디터에 열리는 목록을 읽는 법과 rebase 범위를 지정하는 방법부터 설명하고, merge와 rebase를 언제 쓸지, force push 전에 확인할 점, 꼬였을 때 abort와 reflog로 되돌리는 순서를 정리합니다.

들어가며

기능 브랜치에서 작업하다 보면 wip, fix typo, lint, 리뷰 반영 같은 커밋이 쌓입니다. 작업하는 동안에는 자주 커밋하는 편이 안전하지만, 그대로 PR을 올리면 리뷰어는 무엇이 실제 변경이고 무엇이 잡음인지 구분하기 어렵고, 나중에 git log나 git blame, git bisect로 이력을 추적할 때도 방해가 됩니다. git rebase -i(interactive rebase)는 이런 커밋을 의미 있는 단위로 합치고, 메시지를 고치고, 순서를 바꾸고, 큰 커밋을 쪼개는 도구입니다.

다만 rebase는 기존 커밋을 고치는 것이 아니라 새 커밋을 만들어 브랜치를 그쪽으로 옮기는 작업입니다. 이미 원격에 푸시해서 다른 사람이 받아 간 커밋을 rebase하면 히스토리가 서로 어긋나므로, 언제 써도 되는지를 먼저 이해해야 합니다. 이 글은 동작 원리, 명령별 사용법, 충돌 해결, 실수했을 때의 복구 순서를 다룹니다.


rebase가 하는 일

pick C, fixup D, fixup E로 커밋 세 개를 하나로 합치기 전후의 이력

그림: todo 목록에서 C는 pick, D와 E는 fixup으로 지정하면 두 커밋의 변경이 C에 합쳐지고 D·E의 메시지는 버려진다. 합쳐진 커밋은 해시가 새로 만들어진다(C’).

rebase 전:
main:     A --- B --- C
                \
feature:         D --- E --- F

feature에서 git rebase main 실행 후:
main:     A --- B --- C
                      \
feature:               D' --- E' --- F'

feature와 main의 공통 조상은 B입니다. git rebase main은 B 이후 feature에만 있는 커밋(D, E, F)을 골라, main의 끝인 C 위에 하나씩 다시 적용합니다. 현재 Git의 기본 동작(merge 백엔드)은 각 커밋을 cherry-pick과 같은 방식으로 3-way 병합해 적용하며, 진행 상태는 .git/rebase-merge/에 기록됩니다.

새로 만들어진 D’, E’, F’은 원래 커밋과 변경 내용과 메시지, 작성자(author)는 같지만 해시가 다릅니다. 커밋 해시는 트리(그 시점의 전체 파일 스냅샷), 부모 커밋, 작성자, 커미터, 메시지를 모두 포함한 내용의 해시인데, 부모가 B에서 C로 바뀌었고 C의 변경이 들어간 만큼 트리도 달라지며, 커미터 시각도 rebase한 시점으로 바뀌기 때문입니다. 원래 커밋 D, E, F는 사라지지 않고 저장소에 남아 있지만 어느 브랜치도 가리키지 않게 되며, reflog로 찾을 수 있습니다.

interactive rebase는 같은 작업을 하되, 적용할 커밋 목록(todo 목록)을 에디터로 먼저 보여 주고 각 커밋을 어떻게 처리할지 사용자가 정하게 합니다. 기준점을 바꾸지 않고 git rebase -i HEAD~3처럼 실행하면, 같은 자리에 커밋을 다시 쌓으면서 정리만 하는 용도로 쓸 수 있습니다.


범위 지정과 todo 목록

범위 지정

git rebase -i HEAD~3                        # 최근 3개 커밋 (HEAD~3은 기준점이며 수정 대상 아님)
git rebase -i abc1234                       # abc1234 이후의 커밋 (abc1234 자신은 제외)
git rebase -i $(git merge-base main HEAD)   # main에서 갈라진 이후의 내 커밋만, 기준점은 그대로
git rebase -i main                          # 내 커밋을 정리하면서 main의 최신 끝으로 옮김
git rebase -i --root                        # 첫 커밋부터 전체 (공유 저장소에서는 거의 쓰지 않음)

HEAD~3은 현재 커밋에서 부모를 세 번 거슬러 올라간 커밋입니다. 커밋이 A - B - C - D - E(HEAD)라면 HEAD~3은 B이고, git rebase -i HEAD~3이 보여 주는 목록은 C, D, E 세 개입니다. 지정한 커밋은 기준점일 뿐 목록에 들어가지 않는다는 점이 자주 헷갈립니다.

git rebase -i main과 git rebase -i $(git merge-base main HEAD)는 목록에 나오는 커밋은 같지만 결과가 다릅니다. 앞의 것은 main에 새 커밋이 생겼다면 내 커밋을 그 위로 옮기므로 충돌이 날 수 있고, 뒤의 것은 갈라진 지점을 그대로 두고 정리만 합니다. 정리와 기준점 이동을 한 번에 하면 충돌의 원인이 “정리 때문인지 main의 변경 때문인지” 구분하기 어려우므로, 처음에는 둘을 나눠서 하는 것이 편합니다.

git rebase -i abc1234 def5678처럼 인자를 두 개 주면 “두 커밋 사이”가 아니라, 먼저 def5678을 체크아웃한 뒤 abc1234를 기준으로 rebase하라는 뜻입니다. 두 번째 인자는 브랜치 이름으로 주는 것이 일반적입니다.

todo 목록 읽기

pick abc1234 feat: 로그인 API
pick def5678 fix: typo
pick fed9012 wip

# Rebase 1234567..fed9012 onto 1234567 (3 commands)
#
# Commands:
# p, pick <commit> = use commit
# r, reword <commit> = use commit, but edit the commit message
# e, edit <commit> = use commit, but stop for amending
# s, squash <commit> = use commit, but meld into previous commit
# f, fixup [-C | -c] <commit> = like "squash" but keep only the previous
#                    commit's log message, unless -C is used ...
# x, exec <command> = run command (the rest of the line) using shell
# b, break = stop here (continue rebase later with 'git rebase --continue')
# d, drop <commit> = remove commit
# ...
# These lines can be re-ordered; they are executed from top to bottom.
#
# If you remove a line here THAT COMMIT WILL BE LOST.
#
# However, if you remove everything, the rebase will be aborted.

참고로 이 글에서 시험한 Git 2.54(Apple Git-157)는 커밋 제목 앞에 #을 붙여 pick abc1234 # feat: 로그인 API처럼 보여 줍니다. # 뒤는 설명일 뿐이라 동작은 같고, 옛 버전은 # 없이 제목을 적습니다. git log와 달리 todo 목록은 오래된 커밋이 위에 있고, 위에서부터 차례로 실행됩니다. 줄의 순서를 바꾸면 적용 순서가 바뀌고, 줄을 지우면 drop과 같습니다. 첫 줄의 1234567..fed9012 onto 1234567은 “1234567 이후부터 fed9012까지의 커밋을 1234567 위에 다시 쌓는다”는 뜻입니다.

명령동작
pick커밋을 그대로 적용
reword적용한 뒤 메시지 편집기를 열어 메시지만 수정
edit적용한 뒤 멈춤. 내용 수정(--amend)이나 커밋 분할에 사용
squash바로 앞 커밋에 합치고, 두 메시지를 합쳐 편집
fixup바로 앞 커밋에 합치고, 이 커밋의 메시지는 버림
drop커밋을 버림
exec셸 명령 실행. 실패하면 그 자리에서 멈춤
break그 자리에서 멈춤. git rebase --continue로 재개

에디터와 rebase 취소

rebase는 에디터 프로세스가 끝나는 순간 todo 파일을 읽어 진행합니다. 그래서 에디터를 그냥 닫는 것은 취소가 아닙니다. 파일을 고치지 않고 닫으면 처음 내용(모두 pick)으로 진행되고, 기준점을 바꾸지 않은 rebase라면 결과적으로 아무것도 바뀌지 않습니다. 정말로 중단하려면 todo의 모든 줄을 지우고 저장하거나(Git이 error: nothing to do로 중단), Vim에서는 :cq로 오류 코드를 내며 종료합니다. 이미 시작된 rebase는 언제든 git rebase --abort로 시작 전 상태로 돌아갈 수 있습니다.

VS Code처럼 별도 창으로 열리는 에디터는 Git이 창이 닫힐 때까지 기다리도록 --wait를 줘야 합니다. 이것이 빠지면 에디터 명령이 즉시 끝나서, Git은 사용자가 아무것도 고치지 않았다고 보고 모든 커밋을 pick으로 진행해 버립니다. “에디터가 열리자마자 rebase가 끝났다”는 증상이 대부분 이 경우입니다.

git config --global core.editor "code --wait"     # VS Code
git config --global core.editor "subl -w"         # Sublime Text
git config --global core.editor "vim"

명령별 사용법

fixup: 잡음 커밋 흡수

git log --oneline
fed9012 wip
def5678 fix: typo
abc1234 feat: 로그인 API
1234567 feat: 회원가입 API

git rebase -i HEAD~3
pick abc1234 feat: 로그인 API
fixup def5678 fix: typo
fixup fed9012 wip

fixup은 그 커밋의 변경을 바로 위 줄의 커밋에 합치고 메시지는 버립니다. 결과는 feat: 로그인 API 커밋 하나이며, 내용에는 오타 수정과 wip 변경이 포함됩니다. 합칠 대상이 위에 있어야 하므로 목록의 첫 줄을 fixup으로 바꾸면 cannot 'fixup' without a previous commit 오류가 납니다. 합치려는 커밋이 대상과 떨어져 있다면 줄을 대상 바로 아래로 옮긴 뒤 fixup으로 바꿉니다.

squash: 커밋을 합치고 메시지 다시 쓰기

pick abc1234 feat: 로그인 API
squash def5678 feat: 입력 검증 추가
squash fed9012 feat: 리프레시 토큰 처리

저장하고 닫으면 메시지 편집기가 다시 열리고, 세 커밋의 메시지가 # This is the 1st commit message: 같은 주석과 함께 나열됩니다. #으로 시작하는 줄은 무시되므로 필요한 내용만 남겨 새 메시지를 씁니다.

feat: 로그인 API 구현

이메일과 비밀번호로 인증하고 JWT 액세스 토큰과 리프레시 토큰을 발급합니다.
입력값이 비어 있으면 400을, 인증 실패 시 401을 반환합니다.

기계적인 수정(오타, 포맷, 린트)은 fixup, 별도 커밋으로 둘 필요는 없지만 설명은 남겨야 하는 변경은 squash가 맞습니다. fixup -C <커밋>을 쓰면 반대로 합쳐지는 커밋의 메시지를 최종 메시지로 쓸 수 있습니다.

reword: 메시지만 수정

reword abc1234 add login api
reword def5678 fix bug
pick fed9012 refactor: 토큰 발급 로직 정리

reword로 표시한 커밋마다 메시지 편집기가 열립니다. 팀이 Conventional Commits 같은 형식을 쓰는데 접두사를 빠뜨린 커밋이 있을 때 유용합니다. 가장 최근 커밋 하나의 메시지만 바꾸려면 rebase 없이 git commit --amend로 충분합니다.

edit: 과거 커밋의 내용 수정

edit abc1234 feat: 로그인 API
pick def5678 feat: 회원가입 API
# abc1234를 적용한 직후에 멈춤
# Stopped at abc1234...  feat: 로그인 API
# You can amend the commit now, with
#   git commit --amend

git add tests/auth.test.js          # 빠뜨린 파일 추가 또는 코드 수정
git commit --amend --no-edit        # 메시지는 그대로 두고 내용만 갱신
git rebase --continue

수정한 내용이 뒤의 커밋이 고친 부분과 겹치면 --continue 후 뒤 커밋을 적용할 때 충돌이 날 수 있습니다. 같은 rebase에서 여러 커밋을 edit으로 표시하면 순서대로 하나씩 멈춥니다.

큰 커밋 쪼개기

git show --stat abc1234
#  src/auth/login.js           | 50 +++
#  src/auth/signup.js          | 60 +++
#  src/middleware/validate.js  | 40 +++
#  tests/auth.test.js          | 100 +++

git rebase -i abc1234~1     # 목록에서 abc1234를 edit으로 변경
git reset HEAD~1            # 커밋을 취소하되 변경은 작업 디렉터리에 남김

git add src/middleware/validate.js
git commit -m "feat: 입력 검증 미들웨어 추가"

git add src/auth/login.js
git add -p tests/auth.test.js       # 로그인 테스트 부분만 선택
git commit -m "feat: 로그인 API 추가"

git add src/auth/signup.js tests/auth.test.js
git commit -m "feat: 회원가입 API 추가"

git rebase --continue

git reset HEAD~1은 기본 모드(--mixed)라서 커밋과 스테이징만 되돌리고 파일 변경은 그대로 둡니다. 이때 커밋을 나누는 순서가 중요합니다. 로그인 API가 검증 미들웨어를 쓴다면 미들웨어를 먼저 커밋해야, 중간 커밋들도 각자 빌드되고 테스트가 통과합니다. 중간 커밋이 깨져 있으면 나중에 git bisect로 문제를 찾을 때 그 커밋에서 빌드가 실패해 조사가 막힙니다. 마지막에 exec로 각 커밋을 검증하는 방법은 뒤에서 다룹니다.

git add -p: 파일 안에서 나눠 담기

한 파일 안에 서로 다른 목적의 변경이 섞여 있을 때는 git add -p(--patch)로 변경 덩어리(hunk)마다 스테이징 여부를 고릅니다.

@@ -1,5 +1,9 @@
 function login(email, password) {
+  if (!email || !password) {
+    throw new Error('Email and password required');
+  }
   return authenticateWithEmail(email, password);
 }
(1/2) Stage this hunk [y,n,q,a,d,s,e,?]?

y는 이 덩어리를 스테이징하고, n은 건너뛰고, q는 남은 것을 건너뛰고 종료합니다. a와 d는 이 파일의 나머지 덩어리를 모두 스테이징하거나 모두 건너뜁니다. s는 덩어리 사이에 변경되지 않은 줄이 있을 때 더 작은 덩어리로 나누고, 붙어 있는 변경이라 s로 나눌 수 없다면 e로 편집기를 열어 직접 고릅니다. 편집기에서는 스테이징하지 않을 + 줄을 지우고, 남겨 둘 - 줄은 맨 앞 -를 공백으로 바꿉니다. 디버깅용 console.log만 빼고 커밋하거나, 포맷 변경과 로직 변경을 별도 커밋으로 나눌 때 자주 씁니다. 스테이징한 내용은 커밋 전에 git diff --staged로 확인합니다.

순서 변경과 drop

todo 목록에서 줄의 순서를 바꾸면 커밋이 그 순서로 다시 적용되고, 줄을 지우거나 drop으로 바꾸면 그 커밋이 빠집니다. 순서를 바꾼 커밋들이 같은 코드를 순차적으로 고쳤다면, 앞의 변경이 없는 상태에서 뒤의 변경을 적용하게 되어 충돌이 납니다. 빠진 커밋에 뒤의 커밋이 의존해도 마찬가지입니다.

여러 커밋을 하나로: reset —soft

브랜치의 커밋 전체를 하나로 합칠 생각이라면 todo 목록을 편집하는 대신 다음처럼 해도 됩니다.

git reset --soft $(git merge-base main HEAD)
git commit -m "feat: 결제 플로우 추가"

--soft는 브랜치 포인터만 갈라진 지점으로 옮기고, 그동안의 변경은 모두 스테이징된 상태로 둡니다. 결과는 squash와 같고, 이미 푸시한 브랜치라면 같은 주의가 필요합니다.


autosquash: 리뷰 반영을 미리 표시하기

리뷰를 받으면서 수정 커밋이 쌓일 때마다 todo 목록에서 대상 커밋 아래로 줄을 옮기는 것은 번거롭습니다. 커밋할 때 어느 커밋을 고치는 것인지 표시해 두면, rebase가 목록을 알아서 정렬합니다.

git log --oneline
def5678 feat: 회원가입 API
abc1234 feat: 로그인 API

git add src/auth/login.js
git commit --fixup=abc1234        # 메시지: "fixup! feat: 로그인 API"

git add src/auth/signup.js
git commit --fixup=def5678        # 메시지: "fixup! feat: 회원가입 API"

git rebase -i --autosquash $(git merge-base main HEAD)
pick abc1234 feat: 로그인 API
fixup 111aaaa fixup! feat: 로그인 API
pick def5678 feat: 회원가입 API
fixup 222bbbb fixup! feat: 회원가입 API

목록이 이미 정렬되어 열리므로 저장만 하면 됩니다. git commit --squash=<커밋>은 squash! 커밋을 만들어 메시지 편집 기회를 남기고, --fixup=reword:<커밋>은 내용 변경 없이 대상 커밋의 메시지만 바꾸는 커밋을 만듭니다. git config --global rebase.autoSquash true로 설정하면 -i로 실행할 때마다 --autosquash가 적용됩니다.

리뷰 중인 PR에서는 fixup 커밋을 그대로 푸시해 두면 리뷰어가 “지난 리뷰 이후 무엇이 바뀌었는지”를 커밋 단위로 볼 수 있고, 리뷰가 끝난 뒤 autosquash로 합친 다음 한 번만 force push하면 됩니다. GitHub의 “Squash and merge”로 머지할 팀이라면 이 정리가 필요 없을 수도 있습니다.

exec로 각 커밋 검증하기

git rebase -i --exec "npm test" $(git merge-base main HEAD)

--exec는 todo 목록의 각 커밋 뒤에 exec npm test 줄을 넣어 줍니다. 테스트가 실패하면 rebase가 그 커밋에서 멈추므로, 코드를 고친 뒤 git commit --amend와 git rebase --continue로 진행합니다. 커밋을 쪼개거나 순서를 바꾼 뒤 중간 커밋들이 모두 빌드되는지 확인할 때 유용합니다.


충돌 해결

왜 충돌이 나는가

rebase는 커밋마다 3-way 병합을 합니다. 세 버전은 그 커밋의 부모(기준), 지금까지 쌓은 결과(HEAD), 그리고 적용할 커밋입니다. 기준에서 한쪽만 바뀐 부분은 바뀐 쪽을 택하고, 양쪽이 같은 줄이나 바로 붙어 있는 줄을 서로 다르게 바꿨다면 Git은 어느 쪽을 택할지 정할 수 없어 충돌로 표시합니다. 한쪽은 파일을 지우고 다른 쪽은 수정한 경우도 충돌입니다.

반대로 Git이 충돌 없이 합쳤다고 결과가 맞는 것은 아닙니다. 한쪽은 함수에 매개변수를 추가하고 호출부를 고쳤는데, 다른 쪽이 다른 위치에 옛 형태의 호출을 새로 추가했다면 텍스트 수준에서는 겹치지 않아 자동으로 합쳐지지만 빌드가 깨집니다. rebase 후에 빌드와 테스트를 돌려야 하는 이유입니다.

해결 순서

Auto-merging src/auth.js
CONFLICT (content): Merge conflict in src/auth.js
error: could not apply abc1234... feat: 로그인 API
hint: Resolve all conflicts manually, mark them as resolved with
hint: "git add/rm <conflicted_files>", then run "git rebase --continue".
git status                 # Unmerged paths에 충돌 파일, "Last command done"에 현재 커밋
# 파일을 열어 충돌 마커를 정리
git add src/auth.js        # 해결 표시
git rebase --continue      # 다음 커밋으로
<<<<<<< HEAD
function login(username, password) {
  return authenticateUser(username, password);
}
=======
function login(email, password) {
  return authenticateWithEmail(email, password);
}
>>>>>>> abc1234 (feat: 로그인 API)

rebase 중의 HEAD는 내 브랜치가 아니라, 새 기준점 위에 지금까지 다시 쌓은 결과입니다. 위쪽(HEAD)이 기준 쪽(예: main)의 코드와 이미 적용한 내 앞 커밋들이고, 아래쪽이 지금 적용하려는 내 커밋입니다. 그래서 rebase 중에는 git checkout --ours가 기준 쪽, --theirs가 내 커밋을 가리켜 merge 때와 반대입니다. 한쪽을 통째로 택하는 이 옵션은 의미를 확인한 뒤에만 쓰고, 대개는 양쪽 의도를 모두 살려 직접 고칩니다. 지금 적용 중인 커밋 전체는 git show REBASE_HEAD로 볼 수 있습니다.

충돌 마커에 공통 조상의 내용도 함께 보이게 하면 양쪽이 각각 무엇을 바꿨는지 훨씬 판단하기 쉽습니다.

git config --global merge.conflictStyle zdiff3   # Git 2.35 이상, 이전 버전은 diff3
<<<<<<< HEAD
function login(username, password) {
  return authenticateUser(username, password);
}
||||||| parent of abc1234 (feat: 로그인 API)
function login(user, pwd) {
  return auth(user, pwd);
}
=======
function login(email, password) {
  return authenticateWithEmail(email, password);
}
>>>>>>> abc1234 (feat: 로그인 API)

가운데 원본을 보면 위쪽은 매개변수 이름과 호출 함수를 바꿨고, 아래쪽은 이메일 기반 인증으로 바꿨다는 것을 알 수 있으므로, 두 변경을 함께 반영한 코드를 작성할 수 있습니다. 다만 zdiff3는 양쪽에 똑같이 남아 있는 줄(위 예의 닫는 })을 마커 바깥으로 빼므로, 실제 출력은 위 예보다 } 한 줄씩 짧습니다(Git 2.54에서 같은 충돌로 확인). 모든 줄을 마커 안에 보여 주는 쪽은 diff3입니다.

같은 충돌이 반복될 때

기능 브랜치의 여러 커밋이 같은 함수를 조금씩 고쳤다면, main이 그 함수를 바꾼 경우 rebase 중에 커밋마다 비슷한 충돌이 납니다. 이럴 때는 먼저 기준점을 그대로 둔 채(git rebase -i $(git merge-base main HEAD)) 그 커밋들을 squash해 하나로 만든 뒤 main 위로 옮기면 충돌을 한 번만 해결하면 됩니다.

rerere(reuse recorded resolution)를 켜 두면 Git이 충돌 해결 결과를 기록해 두었다가, 같은 충돌이 다시 나면 같은 방식으로 파일을 고쳐 줍니다. 같은 브랜치를 여러 번 rebase하거나, rebase를 취소했다가 다시 할 때 특히 유용합니다.

git config --global rerere.enabled true
# 이후 같은 충돌이 나면:
# Resolved 'src/auth.js' using previous resolution.

rerere는 작업 디렉터리의 파일을 고쳐 줄 뿐 해결 표시까지 하지는 않으므로, 결과를 확인한 뒤 git add하고 --continue해야 합니다(rerere.autoUpdate를 켜면 스테이징까지 합니다). 잘못 기록된 해결이 있다면 git rerere forget <파일>로 지웁니다.

건너뛰기와 포기

git rebase --skip은 지금 적용 중인 커밋을 통째로 버리고 다음으로 넘어갑니다. 그 커밋의 변경이 이미 기준 쪽에 들어가 있어 필요 없는 경우에 쓰고, 단지 충돌이 귀찮아서 쓰면 그 커밋의 변경이 사라집니다. 처음부터 버릴 커밋이라면 todo 목록에서 drop하는 편이 의도가 분명합니다. git rebase --abort는 rebase를 시작하기 전 상태로 완전히 되돌립니다. 충돌 해결에 확신이 없다면 abort하고 다시 시작하는 것이 가장 안전합니다.

rebase 도중 급한 다른 작업을 해야 한다면, rebase를 억지로 중단하기보다 git worktree add ../hotfix main처럼 별도 작업 디렉터리를 만들어 그곳에서 작업하는 편이 안전합니다. 진행 중인 rebase 상태를 건드리지 않고 다른 브랜치를 다룰 수 있습니다.


푸시한 브랜치를 rebase할 때

force push

rebase한 브랜치를 다시 푸시하면, 원격에 있는 옛 커밋과 로컬의 새 커밋이 서로 이어지지 않으므로 일반 git push는 거절됩니다. 이때 --force는 원격의 상태와 상관없이 덮어쓰므로, 그사이 동료가 같은 브랜치에 푸시한 커밋이 있으면 그대로 사라집니다. --force-with-lease는 원격 브랜치가 내가 마지막으로 가져온 상태(원격 추적 브랜치)와 같을 때만 덮어써서 이 사고를 막습니다.

git push --force-with-lease --force-if-includes origin feature/login

다만 IDE나 도구가 백그라운드에서 git fetch를 하면 원격 추적 브랜치가 최신으로 갱신되어, 동료의 커밋을 본 적도 없는데 lease 검사를 통과할 수 있습니다. Git 2.30부터 있는 --force-if-includes를 함께 주면, 원격의 현재 커밋이 내 로컬 브랜치의 이력(reflog)에 포함되어 있을 때만 덮어써서 이 경우도 막아 줍니다.

팀원이 옛 브랜치를 가지고 있을 때

내가 rebase 후 force push한 브랜치를 팀원이 예전에 받아 두고 그 위에 커밋을 했다면, 팀원의 로컬 브랜치에는 옛 커밋들과 팀원의 새 커밋이 함께 있습니다. 이 상태에서 git pull(병합)을 하면 옛 커밋과 새 커밋이 둘 다 이력에 들어가 같은 변경이 두 번 나타나고, 단순히 git rebase origin/feature/login을 하면 옛 커밋들까지 다시 적용하려다 충돌이 납니다.

git fetch origin
# 내 커밋만 골라 새 원격 브랜치 위로 옮김
# <old-tip>: 내가 받았던 옛 원격 커밋 (reflog나 git log로 확인)
git rebase --onto origin/feature/login <old-tip> feature/login

git pull --rebase도 원격 추적 브랜치의 reflog를 보고 옛 커밋을 자동으로 제외하는(fork-point) 방식이라 많은 경우 같은 결과를 냅니다. 이런 혼란을 피하는 가장 좋은 방법은 여러 사람이 함께 커밋하는 브랜치는 rebase하지 않는 것입니다. 개인 기능 브랜치를 PR로 올리기 전이나 리뷰 중에 정리하는 것은 괜찮지만, main이나 develop처럼 여러 사람이 기반으로 쓰는 브랜치는 merge로만 갱신합니다.

merge와 rebase 중 무엇을 쓸까

항목mergerebase
히스토리 모양병합 커밋으로 갈라짐과 합류가 남음한 줄로 이어짐
기존 커밋그대로 유지새 커밋으로 교체 (해시 변경)
충돌 해결병합할 때 한 번다시 적용하는 커밋마다
공유 브랜치안전다른 사람의 이력과 어긋남
커밋 정리불가squash, fixup, reword 등 가능

선형 이력은 git log로 읽기 쉽고 git bisect로 문제 커밋을 찾기도 편합니다. 병합 커밋이 있는 이력은 “이 기능이 언제 어떤 브랜치에서 합쳐졌는가”라는 정보를 보존합니다. 많은 팀이 개인 브랜치 안에서는 rebase로 정리하고, 그 브랜치를 main에 넣을 때는 PR의 merge(또는 squash merge)를 쓰는 방식을 택합니다.


실수했을 때 복구하기

reflog

reflog는 HEAD와 각 브랜치가 가리키던 커밋이 바뀔 때마다 그 이전 값을 기록한 로컬 기록입니다. rebase가 만든 새 커밋 때문에 옛 커밋이 어느 브랜치에서도 보이지 않게 되어도, reflog에는 남아 있습니다.

git reflog
# 3c4d5e6 (HEAD -> feature/login) HEAD@{0}: rebase (finish): returning to refs/heads/feature/login
# 3c4d5e6 HEAD@{1}: rebase (fixup): feat: 로그인 API
# 9a8b7c6 HEAD@{2}: rebase (pick): feat: 회원가입 API
# 1234567 HEAD@{3}: rebase (start): checkout 1234567
# fed9012 HEAD@{4}: commit: wip                      <- rebase 시작 직전의 브랜치 끝
# def5678 HEAD@{5}: commit: fix: typo

rebase 시작 직전의 상태는 rebase (start) 바로 아래 항목입니다. 위 예에서는 HEAD@{4}이며, 그보다 더 오래된 항목으로 돌아가면 그 사이의 커밋까지 잃게 됩니다. 이 번호를 세는 대신 더 간단한 방법이 두 가지 있습니다.

git reset --hard ORIG_HEAD           # rebase 직후라면: rebase 전 브랜치 끝 (다른 명령이 덮어쓰기 전까지)
git reset --hard feature/login@{1}   # 브랜치 reflog: rebase는 브랜치를 끝에서 한 번만 옮기므로 직전 값이 rebase 전

HEAD@{n}은 HEAD가 움직일 때마다 늘어나므로 rebase 중간 단계도 모두 들어 있지만, 브랜치 reflog(<브랜치>@{n})는 rebase가 끝날 때 브랜치를 한 번 옮긴 것만 기록하므로 @{1}이 rebase 이전 상태입니다. git reset --hard는 커밋하지 않은 변경을 지우므로 실행 전에 git status로 작업 디렉터리가 깨끗한지 확인합니다.

실수로 지운 커밋 하나만 되살리기

git reflog | grep "feat: 중요한 기능"
# 7f6e5d4 HEAD@{8}: commit: feat: 중요한 기능
git cherry-pick 7f6e5d4

rebase에서 줄을 실수로 지웠거나 drop한 커밋은 reflog에서 해시를 찾아 cherry-pick으로 다시 가져올 수 있습니다. cherry-pick은 그 커밋의 변경을 현재 HEAD 위에 3-way 병합으로 적용해 새 커밋을 만들며, 충돌이 나면 rebase와 마찬가지로 해결 후 git cherry-pick --continue, 그 커밋을 건너뛰려면 --skip, 취소하려면 --abort를 씁니다.

지운 브랜치 되살리기

git branch -D feature/important
# Deleted branch feature/important (was abc1234).
git branch feature/important abc1234

브랜치를 지우면 그 브랜치의 reflog도 함께 지워집니다. 삭제 메시지의 해시를 쓰거나, 그 브랜치를 체크아웃한 적이 있다면 HEAD reflog(git reflog)에서 해당 커밋을 찾습니다.

reflog로도 되살릴 수 없는 것

reflog는 로컬 저장소에만 있고, 커밋된 적이 있는 상태만 기록합니다. 커밋하지 않은 변경을 git reset --hard나 git checkout -- <파일>로 지웠다면 reflog에는 없습니다. 그 변경을 한 번이라도 git add했다면 내용(blob)은 저장소에 남아 있으므로 git fsck --lost-found로 찾을 수 있지만, 파일 이름과 위치 정보는 없어 직접 맞춰 봐야 합니다. reflog 항목은 기본적으로 90일, 어느 브랜치에서도 닿을 수 없는 커밋의 항목은 30일 뒤에 git gc가 정리합니다(gc.reflogExpire, gc.reflogExpireUnreachable).

큰 rebase를 하기 전에 백업 브랜치를 하나 만들어 두면 reflog를 뒤질 필요도 없습니다.

git branch backup/login-before-rebase
git rebase -i main
# 문제가 생기면
git reset --hard backup/login-before-rebase
# 끝나면
git branch -D backup/login-before-rebase

직접 실행해 확인한 동작

이 글의 설명을 Git 2.54.0(Apple Git-157)에서 새 저장소 하나(main에 커밋 2개, 기능 브랜치에 C feat login, D typo, E wip 3개)로 실행해 대조했습니다. 전역 설정은 비우고, 에디터 대신 GIT_SEQUENCE_EDITOR로 todo 파일을 고쳤습니다.

  • todo 목록 모양: 이 버전에서는 각 줄이 pick 800f5e0 # C feat login처럼 해시 뒤에 #을 붙인 형태로 열렸습니다. 위 예시처럼 # 없이 제목만 오는 출력은 Git 버전에 따라 다를 수 있습니다. 명령 설명 주석은 reword, squash, fixup, exec, break, drop, label, reset, merge, update-ref까지 나왔습니다.
  • 첫 줄을 fixup으로 바꾸면: error: cannot 'fixup' without a previous commit과 git rebase --edit-todo 또는 --abort로 고치라는 안내가 나왔습니다.
  • pick 1개 + fixup 2개: 커밋 3개가 C feat login 하나로 합쳐졌고 작성자는 그대로였으며 해시는 800f5e0에서 5496391로 바뀌었습니다.
  • 복구: 이어서 git reset --hard ORIG_HEAD로 3개짜리 이력이 돌아왔고, 브랜치 reflog의 feat@{1}은 rebase 전 끝이었던 8b96540(E wip)을 가리켰습니다. HEAD reflog에는 rebase (start), rebase (fixup), rebase (finish) 항목이 남았습니다.
  • autosquash: git commit --fixup=<C의 해시> 뒤 git rebase -i --autosquash main의 todo는 pick C, fixup <fixup 커밋>, pick tweak 순서로, 커밋 순서가 C, tweak, fixup! C였는데도 fixup 커밋이 대상 C 바로 아래로 옮겨져 열렸습니다.
  • exec 실패: --exec "grep -q NOPE c.txt"처럼 실패하는 명령을 주면 첫 커밋에서 멈췄고 git status가 HEAD (no branch)를 보여 주었으며, git rebase --abort로 돌아왔습니다.
  • --fixup=reword:<커밋>은 -m과 함께 쓰면 fatal: options '-m' and '--fixup:reword' cannot be used together로 거부돼 메시지는 에디터로 입력해야 했습니다. fixup -C, 충돌 해결, force push는 이 실험에서 실행하지 않았습니다.

정리하는 흐름의 예

git switch -c feature/login main
# ... 작업하며 자유롭게 커밋 ...

# PR 전: 갈라진 지점을 기준으로 정리 (기준 이동 없이)
git rebase -i $(git merge-base main HEAD)

# main이 많이 바뀌었다면 별도로 기준 이동
git fetch origin
git rebase origin/main

git push -u origin feature/login

# 리뷰 반영은 fixup 커밋으로
git commit --fixup=<대상 커밋>
git push

# 리뷰가 끝나면 합치고 한 번만 force push
git rebase -i --autosquash $(git merge-base origin/main HEAD)
git push --force-with-lease --force-if-includes

git rebase -i HEAD~10처럼 개수를 어림잡아 쓰면, 브랜치의 커밋이 그보다 적을 때 main에 이미 있는 커밋까지 목록에 들어옵니다. 그 커밋을 건드리면 main과 다른 이력이 만들어지므로, 범위는 merge-base나 기준 브랜치 이름으로 지정하는 습관이 안전합니다.

참고 자료