들어가며 — 되돌리기 전에 물어야 할 질문 하나
되돌리기 명령을 외우면 매번 다시 검색하게 됩니다. reset과 revert와 restore와 checkout이 전부 비슷하게 생겼고, 검색 결과 상단에는 상황을 특정하지 않은 명령어 나열이 있습니다. 그중 하나를 골라 붙여 넣었다가 하루치 작업을 날리는 것이 가장 흔한 사고 경로입니다.
명령을 고르기 전에 두 가지만 확정하면 선택지는 하나로 좁혀집니다. 첫째, 되돌리려는 것이 어디에 있는가. 워킹 트리인가, 스테이징 영역인가, 커밋인가. 둘째, 그 커밋이 이미 다른 사람에게 갔는가. 첫 번째 질문이 명령의 종류를 정하고, 두 번째 질문이 이력을 고쳐도 되는지를 정합니다.
세 개의 트리 — 되돌릴 수 있는 것과 없는 것
Git은 파일의 상태를 세 곳에 들고 있습니다. 마지막 커밋이 가리키는 스냅숏, 다음 커밋에 들어갈 내용을 담은 인덱스, 그리고 지금 디스크에 놓인 워킹 트리입니다. 되돌리기 명령은 전부 이 셋 중 어디를 건드리느냐로 구분됩니다.
$ git status --short
M pricing.ts # 인덱스와 워킹 트리 모두 커밋과 다름
M coupon.ts # 워킹 트리만 커밋과 다름
?? refund.ts # 어디에도 없음
여기서 이 글 전체를 관통하는 규칙 하나가 나옵니다. 커밋된 적이 있는 것은 거의 항상 복구되고, 커밋된 적이 없는 것은 거의 복구되지 않습니다. 뒤에서 볼 참조 로그는 커밋을 가리키던 기록이지 워킹 트리의 백업이 아닙니다. 그래서 위험도는 명령 이름이 아니라 "이 명령이 커밋되지 않은 변경을 덮어쓰는가"로 매겨야 합니다.
아직 커밋하지 않았을 때 — restore와 stash의 함정
가장 흔히 검색되는 명령이 여기에 있습니다. 그리고 가장 오래된 조언이 남아 있는 곳도 여기입니다.
# 스테이징만 풀기 (파일 내용은 그대로)
$ git restore --staged pricing.ts
# 워킹 트리의 수정을 버리기 (되돌릴 수 없음)
$ git restore pricing.ts
# 특정 커밋 시점의 파일만 꺼내 오기
$ git restore --source=HEAD~2 --staged --worktree pricing.ts
검색 결과 상단에는 아직도 아래 형태가 많이 보입니다.
$ git checkout -- pricing.ts
$ git reset HEAD pricing.ts
동작은 같지만 권하지 않습니다. git checkout은 브랜치 전환과 파일 복원이라는 전혀 다른 두 일을 한 이름으로 하고, 브랜치와 이름이 같은 파일이 있으면 의도와 다르게 동작합니다. Git 2.23에서 이 둘을 git switch와 git restore로 쪼갠 이유가 정확히 그것입니다. 명령이 하나뿐이던 시절의 조언을 지금 따를 이유는 없습니다.
추적되지 않는 파일은 restore의 대상이 아닙니다. 지우려면 별도 명령이고, 이쪽은 참조 로그도 fsck도 도와주지 않습니다. 반드시 먼저 확인하십시오.
$ git clean -nd
Would remove refund.ts
Would remove tmp/
$ git clean -fd
Removing refund.ts
Removing tmp/
잠시 치워 두고 싶을 때 쓰는 stash에는 함정이 세 개 있습니다.
$ git status --short
M pricing.ts
?? coupon.ts
$ git stash push -m "쿠폰 작업 중"
Saved working directory and index state On main: 쿠폰 작업 중
$ git status --short
?? coupon.ts
첫째, 추적되지 않는 파일은 그대로 남습니다. 새로 만든 파일까지 치우려면 별도 옵션이 필요하고, 무시 목록에 걸린 파일까지 포함하려면 또 다른 옵션이 필요합니다.
$ git stash push -u -m "쿠폰 작업 중" # 추적되지 않는 파일 포함
$ git stash push -a -m "빌드 산출물까지" # 무시된 파일까지 포함
둘째, 꺼낼 때 충돌이 나면 stash가 사라지지 않고 목록에 남습니다. 성공했을 때만 삭제되기 때문인데, 충돌을 해결한 뒤 삭제하지 않으면 같은 변경이 두 벌 쌓입니다.
$ git stash list
stash@{0}: On main: 쿠폰 작업 중
$ git stash drop
셋째, 이것이 실무에서 가장 자주 물리는 함정인데, stash는 브랜치에 묶이지 않습니다. 다른 브랜치에서 꺼내면 그대로 적용됩니다. 편리한 기능이지만 잘못 꺼내면 엉뚱한 브랜치에 남의 변경이 얹힙니다.
커밋했지만 아직 밀지 않았을 때 — amend와 reset
메시지만 고칠 것인지, 커밋 자체를 없앨 것인지에 따라 갈립니다.
# 메시지만 수정
$ git commit --amend -m "쿠폰 정책 반영 — 만료 검사 추가"
# 방금 커밋에 빠뜨린 파일을 얹기 (메시지는 그대로)
$ git add src/order/coupon.ts
$ git commit --amend --no-edit
--amend도 새 커밋 객체를 만듭니다. 해시가 바뀌므로 이미 푸시한 커밋에는 리베이스와 똑같은 규칙이 적용됩니다.
커밋 자체를 물릴 때가 reset의 자리입니다. 세 옵션은 세 개의 트리 중 어디까지를 함께 옮기느냐로 나뉩니다.
$ git log --oneline
08daaa8 쿠폰 정책 반영
dd596de 할인율 적용
fc945f2 가격 계산기 초안
$ git reset --soft HEAD~2
$ git status --short
M pricing.ts
$ git reset --mixed
Unstaged changes after reset:
M pricing.ts
$ git status --short
M pricing.ts
--soft는 커밋 두 개를 없애면서 그 내용을 스테이징된 채로 남겼고, --mixed는 스테이징까지 풀었습니다. 파일 내용은 어느 쪽에서도 사라지지 않았습니다.
| 명령 | 브랜치 참조 | 인덱스 | 워킹 트리 | 잃을 수 있는 것 | 쓰는 상황 |
|---|---|---|---|---|---|
git reset --soft | 이동 | 그대로 | 그대로 | 없음 | 커밋 여러 개를 하나로 다시 묶을 때 |
git reset (기본값 mixed) | 이동 | 대상 커밋으로 | 그대로 | 스테이징 상태만 | 커밋과 스테이징을 풀고 파일은 유지할 때 |
git reset --hard | 이동 | 대상 커밋으로 | 대상 커밋으로 | 커밋되지 않은 모든 변경 | 확실히 버릴 때만 |
git reset --keep | 이동 | 대상 커밋으로 | 겹치면 중단 | 없음 | 로컬 변경을 지키면서 되돌릴 때 |
git reset --merge | 이동 | 대상 커밋으로 | 머지 상태 정리 | 스테이징된 변경 | 실패한 머지를 물릴 때 |
표에서 실무적으로 가장 중요한 줄은 마지막 두 개입니다. 되돌리고는 싶은데 작업 중인 수정을 잃기는 싫을 때 사람들은 습관적으로 --hard를 치는데, 그 상황에 정확히 맞는 옵션이 따로 있습니다. --keep은 되돌릴 범위와 로컬 수정이 겹치면 아무것도 하지 않고 멈춥니다. 덮어쓰기 전에 거부하는 편이 덮어쓰고 나서 후회하는 것보다 낫습니다.
이미 푸시했을 때 — revert가 유일하게 안전한 답인 이유
여기서부터는 규칙이 달라집니다. 커밋이 다른 사람의 저장소에 이미 있다면, 그 커밋을 없애는 모든 방법은 다른 사람의 이력을 깨뜨립니다. 이력을 고치는 대신 이력을 덧붙이는 것이 답입니다.
$ git revert 08daaa8
[main 5b21c4e] Revert "쿠폰 정책 반영"
1 file changed, 1 deletion(-)
revert는 변경을 반대로 적용한 새 커밋을 하나 더 쌓습니다. 아무 커밋도 사라지지 않으므로 남들은 그냥 평소처럼 받아 오면 됩니다. 여러 개를 한 번에 되돌리고 커밋은 하나만 만들고 싶다면 이렇게 합니다.
$ git revert --no-commit dd596de^..08daaa8
$ git commit -m "쿠폰 정책 롤백"
머지 커밋에는 부모가 둘이라 어느 쪽을 기준으로 되돌릴지 알려 줘야 합니다. 보통은 대상 브랜치, 즉 첫 번째 부모입니다.
$ git revert -m 1 40600e3
[main 3c06ce3] Revert "Merge branch 'feature/checkout'"
1 file changed, 2 deletions(-)
delete mode 100644 retry.txt
여기 잘 알려지지 않은 함정이 있습니다. 머지를 revert한 뒤 그 브랜치를 고쳐서 다시 머지하면, 되돌렸던 변경은 돌아오지 않습니다. Git 입장에서 그 브랜치는 이미 머지된 상태이고 머지는 이력의 도달 가능성으로만 판단하기 때문입니다. 실제 파일은 revert 커밋이 지운 상태 그대로 남습니다. 정답은 다시 머지하기 전에 revert 커밋을 한 번 더 revert하는 것입니다.
$ git revert 3c06ce3 # revert를 revert
$ git merge feature/checkout
Git 문서에도 이 상황을 다루는 별도의 안내가 있을 만큼 흔한 사고입니다. 머지를 되돌릴 때는 "나중에 이 브랜치를 다시 넣을 것인가"를 먼저 판단하고, 다시 넣을 거라면 머지 대신 브랜치의 문제 커밋만 revert하는 편이 깔끔합니다.
반대로 절대 하지 말아야 할 조언도 분명합니다. 공유 브랜치에서 커밋을 없애겠다고 되돌린 뒤 강제로 밀어 넣는 방식입니다.
# 공유 브랜치에서는 하지 마십시오
$ git reset --hard HEAD~1
$ git push --force origin main
이미 받아 간 사람들의 로컬은 그대로이고, 그들이 다음에 푸시하면 지웠던 커밋이 되살아납니다. 지우려던 것이 시크릿이었다면 더 심각합니다. 강제 푸시로는 서버에서 객체가 즉시 사라지지 않고, 그동안 CI 로그와 포크에는 이미 복제되어 있습니다. 그 경우의 순서는 .gitignore가 작동하지 않을 때 편 마지막 절에 따로 정리했습니다.
reflog — 지운 것은 대개 아직 거기 있다
브랜치를 지웠거나 --hard로 커밋을 날렸을 때 가장 먼저 볼 곳입니다. Git은 HEAD와 각 브랜치가 어떤 커밋을 가리켰는지를 로컬에 계속 기록합니다.
$ git reflog
fc945f2 HEAD@{0}: reset: moving to HEAD~2
08daaa8 HEAD@{1}: commit: 쿠폰 정책 반영
dd596de HEAD@{2}: commit: 할인율 적용
fc945f2 HEAD@{3}: commit (initial): 가격 계산기 초안
$ git reset --hard HEAD@{1}
HEAD is now at 08daaa8 쿠폰 정책 반영
브랜치를 지운 경우에는 한 가지를 알고 있어야 합니다. 브랜치를 삭제하면 그 브랜치의 참조 로그도 함께 삭제됩니다. 그래서 브랜치 이름으로 조회하면 아무것도 나오지 않습니다. 대신 HEAD의 참조 로그에는 그 브랜치에서 만든 커밋이 그대로 남아 있습니다.
$ git branch -D tmp/lost
Deleted branch tmp/lost (was 990a949).
$ git reflog
08daaa8 HEAD@{0}: checkout: moving from tmp/lost to main
990a949 HEAD@{1}: commit: 환불 규칙 추가
08daaa8 HEAD@{2}: checkout: moving from main to tmp/lost
$ git branch tmp/lost 990a949
삭제 메시지 자체에 커밋 해시가 들어 있다는 점도 기억해 둘 만합니다. 터미널을 위로 조금만 올리면 복구가 끝나는 경우가 많습니다.
참조 로그의 한계도 정확히 알아 둬야 합니다. 이것은 로컬 파일이고, 클론에는 따라오지 않으며, 영원하지 않습니다. 기본값은 도달 가능한 항목 90일, 도달 불가능한 항목 30일입니다.
$ git config gc.reflogExpire
90 days
$ git config gc.reflogExpireUnreachable
30 days
그리고 아래 두 명령은 이 안전망을 즉시 없앱니다. 저장소 용량을 줄여 준다는 이유로 검색 결과에 자주 등장하는데, 실행하는 순간 복구 가능성이 사라진다는 점을 알고 써야 합니다.
$ git reflog expire --expire=now --all
$ git gc --prune=now
참조 로그에도 없을 때 — fsck로 고아 객체 건지기
리베이스가 꼬였거나 stash를 잘못 버렸거나, 참조 로그에 기록이 남지 않는 방식으로 커밋이 떨어져 나간 경우가 있습니다. 이때는 객체 데이터베이스를 직접 훑습니다.
$ git fsck --full --unreachable --no-reflogs
Checking object directories: 100% (256/256), done.
unreachable blob ed7ce12274bc71c66cb596feaaf9f02ce91b820e
unreachable tree 970df65883de43d243ce919be12841c5b9f01857
unreachable commit 990a9490f7069940e9d425bde9d1f5bc44048b1d
$ git show --stat 990a949
commit 990a9490f7069940e9d425bde9d1f5bc44048b1d
Author: Demo <d@e.com>
환불 규칙 추가
pricing.ts | 1 +
찾았다면 이름을 붙여 살려 냅니다.
$ git branch recovered/refund 990a949
여기서 자주 놓치는 사실이 하나 더 있습니다. 출력에 블롭이 함께 나오는 이유는, 스테이징만 하고 커밋하지 않은 내용도 이미 객체로 저장되기 때문입니다. git add를 한 번이라도 했다면 그 시점의 파일 내용은 저장소 안에 들어와 있습니다. --hard로 날린 뒤에도 아래처럼 꺼낼 수 있습니다.
$ git cat-file -p ed7ce12 > pricing.recovered.ts
파일 형태로 한꺼번에 받고 싶다면 전용 옵션이 있습니다. 커밋과 그 외 객체를 각각의 디렉터리에 풀어 줍니다.
$ git fsck --lost-found
$ ls .git/lost-found/commit .git/lost-found/other
이 방법에도 시한이 있습니다. 정리 작업은 기본적으로 2주가 지난 도달 불가능 객체를 삭제하므로, 오래된 사고는 여기서도 건지지 못할 수 있습니다.
$ git config gc.pruneExpire
2.weeks.ago
마치며 — 기억할 것은 명령이 아니라 경계선
되돌리기 명령을 전부 외울 필요는 없습니다. 사고가 났을 때 스스로에게 물어야 할 것은 두 가지뿐입니다. 이 변경은 커밋된 적이 있는가, 그리고 이 커밋은 다른 사람에게 갔는가.
커밋된 적이 있다면 참조 로그와 fsck가 거의 항상 건져 냅니다. 커밋된 적이 없다면 어떤 명령도 도와주지 않습니다. 그리고 다른 사람에게 이미 갔다면 이력을 고치는 대신 revert로 덧붙이는 것이 유일하게 안전한 길입니다. 되돌리기의 실력은 명령을 아는 것이 아니라, 위험한 명령을 치기 전에 커밋을 하나 만들어 두는 습관입니다.
명령들이 왜 이렇게 나뉘는지가 궁금하다면 Git 내부 구조로 이해하는 명령어 편에서 참조와 인덱스의 실체를 다뤘습니다.
현재 단락 (1/135)
되돌리기 명령을 외우면 매번 다시 검색하게 됩니다. reset과 revert와 restore와 checkout이 전부 비슷하게 생겼고, 검색 결과 상단에는 상황을 특정하지 않은 명...