← 블로그로 돌아가기

git reflog로 잃어버린 commit 복구하기 (아마 아무것도 잃지 않았습니다)

tutorial git

"전부 날아갔어요" (스포일러: 아마 아닐 겁니다)

일단 심호흡부터 하세요. 잃어버렸다고 생각하는 작업이 한 번이라도 커밋된 적이 있다면 -- 잘못된 브랜치에서였든, 나중에 "삭제한" 커밋 안에 있었든 -- 그 작업은 거의 확실히 지금 이 순간에도 리포지토리 안에 남아 있습니다. Git은 무언가를 잃어버리는 것보다 보관하는 데 훨씬 능숙하고, 앞으로 10분만 읽으면 코드가 다시 화면에 돌아올 가능성이 매우 높습니다.

Git의 내부 동작에 관한 안심되는 진실은 이렇습니다. Git은 무언가를 즉시 삭제하는 일이 거의 없습니다. 여러분이 만드는 모든 커밋은 .git 디렉터리에 객체로 저장되고 SHA로 식별됩니다. git reset --hard를 실행하거나, 브랜치를 삭제하거나, rebase로 히스토리를 다시 쓰더라도 Git은 그 커밋 객체들을 지우지 않습니다. 옮기는 것은 포인터 -- 브랜치 이름과 HEAD -- 뿐이라서, 더 이상 그 커밋들을 가리키지 않게 될 뿐입니다. 커밋은 "도달 불가능(unreachable)" 상태가 됩니다. 즉 어떤 브랜치나 태그로도 거기에 닿을 수 없게 되지만, 객체 자체는 가비지 컬렉션이 손대는 것을 고려하기까지 몇 주 동안 디스크에 그대로 남아 있습니다.

그러니 문제는 커밋이 사라진 것이 아닙니다. 그 커밋을 가리키는 이름이 없어진 것입니다. 필요한 것은 SHA입니다. 그리고 Git에는 리포지토리가 거쳐 간 모든 SHA를 조용히 기록해 온 일지가 있습니다. 바로 reflog입니다.

reflog는 실제로 무엇인가

reflog(reference log)는 HEAD와 각 브랜치 끝이 시간에 따라 어디를 가리켜 왔는지를 기록하는, 머신별 로컬 일지입니다. HEAD가 움직일 때마다 -- 커밋, checkout, merge, rebase, reset, amend를 할 때마다 -- Git은 이 일지에 한 줄을 추가합니다. 리포지토리의 블랙박스인 셈입니다.

확인하려면 다음을 실행하세요.

git reflog

출력은 다음과 같은 모습입니다.

e4f5a6b HEAD@{0}: reset: moving to HEAD~3
a1b2c3d HEAD@{1}: commit: add payment validation
9f8e7d6 HEAD@{2}: commit: refactor checkout flow
3c4d5e6 HEAD@{3}: checkout: moving from main to feature/payments

위에서 아래로 "최신 항목 먼저" 순으로 읽습니다. 각 줄에는 HEAD가 가리키던 SHA, HEAD@{1} 같은 위치 참조, HEAD를 움직인 작업, 그리고 짧은 설명이 표시됩니다. HEAD@{n} 문법은 "n번의 이동 전에 HEAD가 있던 곳"을 뜻합니다. HEAD@{0}은 현재 위치이고, HEAD@{1}은 한 번의 작업 전, 이런 식입니다. 이 참조들은 커밋을 받는 모든 명령에 사용할 수 있으며, 구조 작업이 그토록 곧바로 가능해지는 이유가 바로 여기에 있습니다.

위 예시에서 이야기는 명확합니다. 커밋 두 개(HEAD@{2}HEAD@{1})를 만든 뒤, reset이 HEAD를 세 커밋 뒤로 옮겼습니다. 그 두 커밋은 사라지지 않았습니다. a1b2c3d에 그대로 있습니다.

로그를 가진 참조는 HEAD만이 아닙니다. 각 브랜치도 자신만의 로그를 가지고 있습니다.

git reflog show feature/payments

이 명령은 feature/payments의 끝이 거쳐 온 모든 위치를 보여 줍니다. 그동안 했던 모든 checkout의 잡음 없이 한 브랜치의 히스토리만 보고 싶을 때 유용합니다.

구조 시나리오, 단계별로

1. git reset --hard로 너무 멀리 갔을 때

커밋 하나만 되돌리려다 세 개를 날렸거나, 완전히 엉뚱한 곳으로 reset한 경우입니다. reflog는 reset 직전에 있던 위치를 기록해 두었고, 그 위치가 바로 HEAD@{1}입니다.

git reset --hard HEAD@{1}

이것으로 끝입니다. reset 자체도 그저 또 하나의 HEAD 이동이었으므로, HEAD를 이전 위치로 되돌리면 모든 것이 복원됩니다. 잘못된 reset 이후에 다른 작업을 했다면, 먼저 git reflog를 실행해 reset 항목 바로 앞의 줄을 찾고, 대신 그 SHA로 reset하세요. 되돌리기 전략을 더 폭넓게 살펴보려면 Git 커밋을 되돌리는 방법을 참고하세요.

2. 브랜치를 삭제했을 때

브랜치를 삭제하면 포인터가 삭제될 뿐, 커밋은 삭제되지 않습니다. 이 머신에서 최근에 그 브랜치를 checkout했거나 거기에 커밋했다면, 그 끝은 HEAD reflog에 있습니다. 찾아보세요.

git reflog | grep "feature/payments"

그 브랜치에서 만든 마지막 커밋이나, 마지막 checkout: moving from feature/payments 항목을 찾으세요. 그 줄의 SHA가 브랜치의 끝입니다. 이제 그 지점을 가리키는 브랜치를 다시 만들면 됩니다.

git branch feature/payments a1b2c3d

브랜치가 삭제되던 순간과 똑같은 모습으로 돌아옵니다. 덤으로, Git은 브랜치를 삭제할 때 끝 SHA를 출력합니다(Deleted branch feature/payments (was a1b2c3d)). 이 메시지가 아직 터미널 스크롤백에 남아 있다면 reflog 검색을 통째로 건너뛸 수 있습니다.

3. rebase가 잘못됐을 때

rebase는 커밋을 다시 쓰는 작업이라, 충돌투성이 rebase의 한가운데에서는 브랜치가 복구 불가능할 만큼 망가진 것처럼 느껴질 수 있습니다. 그렇지 않습니다. rebase 이전 상태가 reflog에 있습니다. rebase가 아직 진행 중이라면 가장 깔끔한 탈출구는 git rebase --abort입니다. 이미 끝났는데 결과가 마음에 들지 않는다면, 시작 전의 항목을 찾으세요.

git reflog
# "rebase (start)"를 찾으세요 -- 그 바로 아래 항목이
# rebase 전에 브랜치가 있던 위치입니다
git reset --hard HEAD@{5}

({5}는 여러분의 reflog에서 rebase 이전 항목이 차지하는 위치로 바꾸세요.) 브랜치는 정확히 원래대로 돌아갑니다. rebase 때문에 이런 상황에 자주 놓인다면, 두려움 없는 인터랙티브 rebase 가이드에서 rebase를 위험이 아닌 일상으로 만드는 방법을 다룹니다.

4. --amend 후 커밋이 사라졌을 때

git commit --amend는 커밋을 수정하는 것이 아니라, 새 커밋을 만들고 브랜치를 그쪽으로 옮깁니다. 원래 커밋은 도달 불가능해지지만 멀쩡하며, amend 직후라면 HEAD@{1}에 그대로 있습니다. 살펴보려면 다음과 같이 하세요.

git show HEAD@{1}

엉뚱한 커밋을 amend했거나 원본이 다시 필요하다면, 그 지점으로 reset하거나 필요한 곳으로 cherry-pick하세요.

5. detached HEAD에서 커밋한 뒤 다른 곳으로 이동했을 때

특정 커밋이나 태그를 checkout하고, detached HEAD 상태에서 커밋을 만든 다음, 브랜치를 checkout했더니 커밋이 증발한 것처럼 보입니다. Git은 이동할 때 이 상황을 경고하면서 고아가 된 SHA까지 출력해 줍니다. 어쨌든 reflog에는 남아 있습니다.

git reflog
# "checkout: moving from <sha> to <branch>" 항목
# 이전에 만든 마지막 커밋을 찾으세요
git branch rescued-work e4f5a6b

detached 상태의 커밋들이 이제 진짜 브랜치 위에 살게 되었고, 필요한 곳 어디로든 merge하거나 rebase할 수 있습니다.

복원하기 전에 확인하세요

복구한 SHA로 무언가를 가리키기 전에, 먼저 들여다보세요. 생각하는 그 커밋이 맞는지 확인합니다.

git show a1b2c3d

커밋 메시지, 작성자, 날짜, 전체 diff가 출력됩니다. 한 줄짜리 항목 대신 커밋 상세 정보와 함께 reflog를 둘러보고 싶다면, log의 reflog 모드를 사용하세요.

git log -g

그리고 무엇보다 안전한 습관이 있습니다. 현재 브랜치를 복구한 커밋으로 reset하는 대신, 먼저 그 위에 임시 브랜치를 만드는 것입니다.

git branch rescue a1b2c3d

이제 복구한 작업에 도달 가능한 영구적인 이름이 생겼습니다. 현재 브랜치는 아무것도 바뀌지 않았고, 가비지 컬렉션은 구조된 커밋에 절대 손댈 수 없으며, 여유 있게 살펴보고 diff하고 merge할 수 있습니다. 브랜치는 공짜입니다. 100% 확신이 없을 때는 언제든 하나 만드세요.

한계, 솔직하게

reflog는 놀라운 안전망이지만 분명한 경계가 있고, 필요해지기 전에 알아 두어야 합니다.

  • 철저히 로컬입니다. reflog는 여러분의 머신에만 존재하며 push, pull, clone 어느 것으로도 전달되지 않습니다. 새로 clone하면 reflog는 비어 있는 상태로 시작합니다. 노트북에서 커밋을 잃었다면 중요한 것은 그 노트북의 reflog입니다. 동료의 clone도, 서버의 사본도 도움이 되지 않습니다.
  • 항목은 만료됩니다. 기본 설정에서, 브랜치에서 아직 도달 가능한 커밋의 reflog 항목은 90일 후에, 도달 불가능한 커밋의 항목은 30일 후에 만료됩니다. 만료된 뒤에는 가비지 컬렉션이 도달 불가능한 객체를 정말로 삭제할 수 있습니다. 실제로는 충분하고도 남는 시간이지만, reflog가 구해 주는 것은 지난달의 실수이지 작년의 실수가 아니라는 뜻이기도 합니다.
  • 커밋된 적 없는 것은 복구할 수 없습니다. git reset --hardgit checkout -- <file>로 파괴된 커밋되지 않은 변경 사항은 애초에 Git 객체였던 적이 없으므로, 어떤 일지에도 기록되지 않았습니다. 단 하나의 부분적 예외: 최소한 git add로 staging된 적이 있는 파일은 blob 객체로 존재하며, git fsck --lost-found가 그 매달린(dangling) blob들을 .git/lost-found/로 끄집어낼 수 있습니다. 파일 이름도 히스토리도 없는 내용물뿐이지만, 아무것도 없는 것보다는 낫습니다. 이것은 최후의 수단이지 워크플로가 아닙니다.
  • drop한 stash에는 별도의 비상구가 있습니다. drop하거나 clear한 stash는 HEAD reflog에는 없지만, stash 커밋들은 매달린 객체로 살아남는 경우가 많습니다. git fsck --unreachable | grep commit과 후보들에 대한 git show로 찾을 수 있습니다. stash를 자주 쓴다면 git stash 가이드에서 더 안전한 워크플로를 다룹니다.

예방: 미래의 패닉을 지루하게 만들기

위의 모든 시나리오에는 한 가지 전제 조건이 있었습니다. 작업이 커밋되어 있었다는 것입니다. 여기서 유일하게 의미 있는 예방 조언이 나옵니다. 일찍 커밋하고 자주 커밋하세요. 작고 거칠고 작업 중인 커밋에는 비용이 들지 않습니다. 나중에 squash하거나 reword하거나 순서를 바꿀 수 있습니다. 무언가가 커밋되는 순간 그것은 객체 데이터베이스에 들어가고 reflog가 뒤를 지켜 줍니다. 커밋되지 않은 순간부터는 운에 기대는 것입니다.

커밋이 너무 무겁게 느껴지는 빠른 컨텍스트 전환에는, 커밋되지 않은 변경 사항을 checkout 사이에서 곡예하듯 옮기지 말고 stash를 사용하세요. stash 역시 Git이 다시 찾을 수 있는 진짜 객체입니다.

GitSquid가 맡는 부분 (그리고 CLI가 이기는 부분)

솔직한 답: reflog는 커맨드 라인이 올바른 도구인 영역 중 하나입니다. GitSquid에는 reflog 브라우저가 없으며, git reflog가 필요할 때는 터미널을 열어 사용해야 합니다. 이 글의 명령들이 바로 복구 경로입니다.

대신 GitSquid가 하는 일은 주변 워크플로를 더 안전하게 만들어 reflog를 찾을 일 자체를 줄이는 것입니다. 파일 타임라인(아무 파일이나 우클릭하고 Show history 선택)은 파일별 시각적 히스토리를 제공해서, "그 코드가 어디로 갔지?"라는 질문에 복구 작업 없이도 답해 주는 경우가 많습니다. reset이나 브랜치 삭제 같은 파괴적 작업은 커밋 그래프의 명시적인 컨텍스트 메뉴를 거치므로, 실행하기 전에 무엇을 하려는 것인지 정확히 보입니다. 그리고 "방금 무슨 일이 있었는지"를 재구성할 때는 명령 로그(Cmd/Ctrl+Shift+L)가 GitSquid가 리포지토리에서 실행한 모든 Git 명령을 전체 인자와 종료 코드와 함께 보여 줍니다. 사건의 전말을 짜 맞출 때 reflog 옆에 두고 싶은 증거가 정확히 이것입니다.

빠른 참조

상황 명령
HEAD가 거쳐 온 위치 보기 git reflog
브랜치 끝이 거쳐 온 위치 보기 git reflog show <branch>
잘못된 reset --hard 되돌리기 git reset --hard HEAD@{1}
삭제한 브랜치 복원하기 git branch <name> <sha>
끝난 rebase 되돌리기 git reset --hard HEAD@{n} (rebase 이전 항목)
복구한 커밋 살펴보기 git show <sha>
상세 정보와 함께 reflog 둘러보기 git log -g
구조한 작업을 안전하게 보관하기 git branch rescue <sha>
staging만 되고 커밋되지 않은 파일의 최후 수단 git fsck --lost-found

reflog는 "전부 날아갔다"를 "포인터를 10분 동안 잘못 둔 것뿐"으로 바꿔 줍니다. 자주 커밋하고, 패닉에 빠지기 전에 reflog를 확인하고, 복구한 것은 다른 무엇을 건드리기 전에 임시 브랜치에 보관하세요. 그리고 파괴적인 작업을 명시적으로 보여 주고 실행하는 모든 명령의 전체 로그를 남기는 Git 클라이언트를 원한다면, GitSquid 무료 다운로드를 해 보세요. reflog를 대체하지는 않겠지만, reflog가 필요한 순간을 줄이는 데는 도움이 될 것입니다.