로컬에서 기능 하나를 만들다 보면 커밋 히스토리가 이렇게 쌓인다.

f16a902 Add logout feature
fbfb5b2 Fix login button style
6cc4fec Add missing import
cacd382 Fix typo in login
0559677 Add login feature

Fix typo, Add missing import, Fix login button style는 전부 Add login feature에 들어갔어야 할 수정이다. 작업 중에 자주 커밋하는 것 자체는 문제가 없다. 잃어버릴 일이 없고 되돌아갈 지점이 많다. 다만 이 상태로 push하면 리뷰어가 의미 없는 커밋 4개를 읽어야 한다.

로컬에서는 자유롭게 커밋하고 push 직전에 정리한다. 정리에 쓰는 도구는 네 가지다.

fixup으로 잡다한 커밋 흡수하기

위 히스토리를 정리하려면 interactive rebase를 연다.

git rebase -i HEAD~5

에디터가 열리면 흡수할 커밋의 pickfixup으로 바꾼다. fixup은 앞 커밋에 변경 내용을 합치고 그 커밋 메시지는 버린다. 메시지까지 합치려면 squash를 쓴다.

pick   0559677 Add login feature
fixup  cacd382 Fix typo in login
fixup  6cc4fec Add missing import
fixup  fbfb5b2 Fix login button style
pick   f16a902 Add logout feature

저장하고 닫으면 커밋 두 개만 남는다.

$ git log --oneline
f65953c Add logout feature
b5d8028 Add login feature

rebase 에디터에서 쓰는 명령은 다음이 전부다.

명령하는 일
pick커밋을 그대로 둠
reword메시지만 고침
squash앞 커밋에 합치고 메시지도 합침
fixup앞 커밋에 합치지만 메시지는 버림
drop커밋을 삭제

autosquash로 정리를 미리 예약하기

이미 push했거나 리뷰 중인 커밋에 작은 수정을 더할 때, rebase 에디터에서 매번 줄을 손으로 옮기는 것은 번거롭다. --fixup으로 어느 커밋에 붙일지 미리 표시해 둘 수 있다.

# 원래 커밋(예: fb36099)에 붙일 수정
git add auth.js
git commit --fixup=fb36099

fixup! Add user authentication이라는 제목의 커밋이 생긴다. 나중에 --autosquash로 rebase하면 Git이 이 커밋을 대상 바로 뒤로 옮기고 fixup으로 표시한다.

git rebase -i --autosquash HEAD~3

에디터가 이미 정렬된 채로 열린다. 순서를 맞출 필요가 없다.

pick   fb36099 Add user authentication
fixup  282ffc9 fixup! Add user authentication
pick   39fbb15 Add settings page

저장하면 fixup! 커밋이 흔적 없이 흡수된다.

$ git log --oneline
57b4bd0 Add settings page
d9b5470 Add user authentication
db15ca9 Initial commit

git config --global rebase.autosquash true로 켜두면 --autosquash를 매번 붙이지 않아도 된다.

reset –soft로 rebase 없이 합치기

연속된 커밋 N개를 하나로 합칠 때는 rebase가 필요 없다. reset --soft가 더 간단하다. HEAD만 N개 뒤로 옮기고 변경 내용은 전부 staged 상태로 남긴다.

$ git log --oneline
d7cd134 WIP: Add error handling
d8c29c3 WIP: Connect login to API
67df8a4 WIP: Add login form skeleton
a088330 Initial commit
git reset --soft HEAD~3

세 커밋이 사라지고 변경 내용은 staging에 그대로 올라와 있다.

$ git status
On branch main
Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
	new file:   a
	new file:   b
	new file:   c

여기서 한 번 커밋하면 끝이다.

git commit -m "feat: add complete login flow"

reset의 세 모드는 staging과 작업 디렉터리에 손대는 범위만 다르다.

옵션HEADStaging작업 디렉터리
--soft이동유지유지
--mixed(기본)이동초기화유지
--hard이동초기화초기화

--hard는 변경 내용을 실제로 날린다. 합칠 때가 아니라 버릴 때 쓰는 옵션이다.

amend로 방금 만든 커밋 고치기

파일을 빠뜨렸거나 커밋 메시지에 오타가 났다면 새 커밋을 만들지 말고 마지막 커밋을 고친다.

# 빠뜨린 파일을 마지막 커밋에 추가 (메시지는 그대로)
git add helper.js
git commit --amend --no-edit
$ git show --stat --oneline HEAD
6d52075 feat: add auth
 auth.js   | 1 +
 helper.js | 1 +
 2 files changed, 2 insertions(+)

helper.js가 별도 커밋 없이 기존 커밋에 들어갔다. 메시지만 고치려면 git commit --amend -m "새 메시지"를 쓴다.

push한 커밋을 고쳤다면

amend, rebase, reset은 모두 커밋의 해시를 바꾼다. 기존 커밋을 새 커밋으로 갈아치우는 것이다. push 전이면 문제가 없다. 이미 원격에 올라간 커밋을 고치면 로컬과 원격의 히스토리가 갈라져 일반 push가 거부된다.

이때는 force push가 필요하다. --force 대신 --force-with-lease를 쓴다.

git push --force-with-lease origin feature/my-branch

--force-with-lease는 내가 마지막으로 fetch한 이후 누군가 그 브랜치에 push했으면 거부한다. 동료의 커밋을 모르고 덮어쓰는 사고를 막는다. 원칙은 혼자 쓰는 브랜치에서만 히스토리를 고치고, 공유 브랜치(main, develop)에서는 하지 않는 것이다.