Git 커밋 취소, 코드는 남기고 되돌리려면?

로그인 화면과 테스트 수정을 한 커밋에 넣어 버렸다. 코드는 살린 채 커밋만 풀려면 무슨 명령을 쓰고, 팀원이 이미 가져간 커밋이면 무엇이 달라질까?

Git 커밋 취소, 코드는 남기고 되돌리려면?

취소할 것이 파일 수정인지, 커밋인지

로그인 화면을 고치고 테스트 파일도 함께 수정했다고 가정한다. 코드 자체는 쓸 만하지만 두 작업을 한 커밋에 넣은 것이 문제일 수 있다. 이때 필요한 것은 파일을 예전 내용으로 덮어쓰는 작업이 아니라 커밋을 풀어 다시 나누는 작업이다. 반대로 실험한 로그인 코드가 필요 없어졌다면 그 파일의 수정 내용까지 버려야 한다. 둘 다 ‘되돌리기’라고 부르지만 남겨야 할 결과가 다르다.

Git의 작업 파일은 편집기에서 보는 내용이고, 인덱스라고도 부르는 스테이징 영역은 다음 커밋에 넣도록 선택한 내용이다. HEAD는 현재 체크아웃한 커밋을 가리킨다. 파일을 고친 뒤 add하면 작업 파일의 그 시점 내용이 스테이징에 들어가고, commit하면 그 내용이 이력에 저장된다. git status 문서는 이 세 상태 사이의 차이를 따로 표시한다.

원하는 결과주로 쓸 명령실행 뒤 남는 것
add한 파일을 이번 커밋에서 뺀다restore --staged파일 수정은 남고 스테이징만 취소된다.
파일의 미커밋 수정을 버린다restore지정한 원본의 내용으로 파일이 바뀐다.
개인 커밋을 풀어 다시 만든다reset --soft 또는 --mixed작업 파일은 남고 브랜치의 커밋 위치가 바뀐다.
공유된 변경의 효과를 취소한다revert기존 이력 뒤에 반대 변경을 담은 커밋이 생긴다.

아래 커밋 예제는 일반 브랜치에 부모가 있는 커밋을 대상으로 한다. 첫 커밋만 있는 저장소, merge 커밋, 진행 중인 rebase나 merge에는 그대로 적용하지 않는다. 특히 HEAD~1은 현재 커밋의 첫 번째 부모를 뜻하므로, 로그에서 되돌릴 대상을 읽은 다음 사용해야 한다.

현재 수정과 커밋 내용을 먼저 펼친다

git status
git diff
git diff --staged
git log --oneline -5

status에서 Changes to be committed 아래의 파일은 이미 add된 상태다. Changes not staged for commit 아래의 파일은 작업 파일에만 추가 수정이 남아 있다. 같은 파일이 두 곳에 나올 수도 있다. 예를 들어 첫 수정분을 add한 뒤 두 번째 수정을 했다면 첫 수정은 스테이징에, 두 번째 수정은 작업 파일에만 존재한다. git diff는 두 번째 수정분을, git diff --staged는 현재 커밋에 들어갈 첫 수정분을 보여준다.

로그인 파일의 두 번째 수정만 버리려면 작업 파일을 인덱스 내용으로 복원한다. 그러면 이미 add한 첫 수정은 남고, 그 뒤 편집한 두 번째 수정만 사라진다. 두 수정 모두 살리면서 이번 커밋에서 파일을 빼려면 스테이징만 취소한다. 이 경우 편집기에서 보이는 파일은 바뀌지 않고 두 수정분을 나중에 다시 add할 수 있다.

취소 작업과 무관한 미완성 변경이 있다면 따로 보관할 수 있다. git stash push -u -m "before-undo"는 추적 중인 수정과 아직 추적하지 않는 파일을 임시로 저장한다. -u는 ignore된 파일까지 포함하지 않는다. 저장 뒤 git stash list와 git stash show -p -u로 필요한 내용이 들어갔는지 보고 진행한다. 나중에 git stash apply로 다시 적용하면 stash 항목은 남는다. 적용 시 충돌할 수도 있으므로 새 작업 위에 즉시 덮어도 된다는 보장은 아니다. stash의 저장·적용 동작이다.

add 취소와 파일 수정 삭제는 다른 restore다

파일 하나를 커밋 대상에서 빼고 수정은 유지하려면 다음과 같이 실행한다. 예제의 경로는 자신의 파일 경로로 바꾼다. -- 뒤에는 옵션이 아니라 경로를 적는다.

git restore --staged -- src/login.js
git status
git diff -- src/login.js

이 명령은 src/login.js의 스테이징 내용을 HEAD 기준으로 되돌린다. 편집기에서 보던 작업 파일은 그대로다. 따라서 이미 add했던 수정과 그 뒤의 수정이 모두 작업 파일에 남고, 다시 add할 때 함께 선택할 수 있다. 새 파일을 add했다가 취소했다면 파일이 사라지는 대신 untracked 상태로 돌아갈 수 있다.

반면 git restore -- src/login.js는 작업 파일을 인덱스의 내용으로 덮어쓴다. add된 내용이 있으면 그 내용은 남고, add 이후에 편집한 부분은 없어질 수 있다. ‘마지막 커밋으로 되돌린다’고 외우면 이 차이를 놓친다. restore 공식 문서에서 기본 복원 원본은 --staged 사용 시 HEAD, 그 외에는 인덱스다.

파일 하나를 마지막 커밋의 상태로 완전히 돌리려는 경우에는 원본과 대상을 모두 명시한다. git restore --source=HEAD --staged --worktree -- src/login.js는 해당 파일의 스테이징과 작업 파일을 함께 바꾼다. 이 명령은 코드 보존용이 아니다. 앞서 diff로 읽은 두 수정분을 모두 버리려는 경우에만 맞는다. 디렉터리 전체를 뜻하는 점 대신 특정 경로를 적으면 취소할 범위를 그 파일로 한정할 수 있다.

로컬 커밋을 풀어 같은 코드로 다시 만든다

아직 공유하지 않은 마지막 커밋에 로그인 수정과 테스트 수정이 함께 들어갔다고 하자. 커밋 직후 추가 수정이 없는 상태에서 git reset --soft HEAD~1을 실행하면 브랜치는 부모 커밋을 가리키고, 방금 커밋했던 내용은 스테이징에 남는다. 파일 내용은 바뀌지 않는다. 이어서 커밋하면 같은 수정 묶음을 새로운 메시지로 기록할 수 있다.

수정 묶음을 다시 나누려면 git reset --mixed HEAD~1이 편하다. 이 옵션은 스테이징을 부모 커밋에 맞추면서 작업 파일은 유지한다. 이제 필요한 파일이나 수정 조각만 골라 add할 수 있다. 다음은 마지막 로컬 커밋을 풀고 로그인 코드만 먼저 다시 커밋하는 순서다. 한 줄씩 실행하며 reset이 성공한 뒤 diff를 읽고, 스테이징에 로그인 수정만 들어 있는 것을 확인한 뒤 commit한다.

git reset --mixed HEAD~1
git diff -- src/login.js
git add -- src/login.js
git diff --staged
git commit -m "Fix login validation"

테스트 파일의 수정은 작업 파일에 남아 다음 커밋으로 만들 수 있다. 한 파일 안에 서로 다른 작업이 섞여 있으면 git add -p로 수정 조각을 선택하는 방법도 있다. 이 예제에서 mixed를 쓴 이유는 코드를 지우려는 것이 아니라, 무엇을 한 커밋에 넣을지 다시 고르기 위해서다.

커밋 뒤 도움말 수정까지 add했다면 soft reset 후 스테이징에는 로그인·테스트 수정과 도움말 수정이 함께 남는다. soft는 기존 인덱스를 유지하기 때문이다. 따라서 예제처럼 커밋 직후 다른 작업이 없는 경우와 달리, 이 상태에서 바로 commit하면 세 작업을 다시 한데 묶게 된다. reset의 옵션 설명도 soft가 인덱스와 작업 파일을 바꾸지 않는다고 명시한다.

hard는 작업 파일까지 목표 커밋에 맞춰 덮어쓴다. 커밋하지 않은 수정이 사라질 수 있고, 복원 경로를 가로막는 추적되지 않은 파일도 영향을 받을 수 있다. 코드를 남기려는 이번 작업에는 soft나 mixed를 사용한다. 또한 파일 경로를 받는 git reset -- src/login.js는 스테이징만 바꾸며 브랜치를 이동시키지 않는다. 같은 reset이라도 커밋을 지정한 형태와 파일을 지정한 형태는 결과가 다르다.

이미 공유한 커밋은 반대 변경을 새로 기록한다

팀원이 이미 받은 커밋을 reset으로 이력에서 빼면 내 브랜치와 팀원의 브랜치가 서로 다른 과거를 갖게 된다. 공유 브랜치에서 특정 변경을 취소할 때는 기존 커밋 뒤에 취소 내용을 추가하는 revert를 쓴다. Microsoft의 공유 커밋 취소 안내도 이 방식으로 기존 이력을 보존한다.

커밋 A 다음에 B에서 로그인 검사를 바꾸고, C에서 도움말을 고쳤다고 가정한다. B를 revert하면 A로 통째로 돌아가는 것이 아니다. C 위에서 B가 도입한 변경을 반대로 적용한 새 커밋 D를 만든다. 서로 독립적인 도움말 수정은 남을 수 있다. C가 B의 코드를 다시 고쳤다면 그 부분은 충돌하거나 별도 수정이 필요할 수 있다.

먼저 status가 깨끗한지 보고, 로그에서 B의 실제 해시를 찾는다. 아래 BAD_COMMIT은 그 해시로 바꿔야 하는 자리표시자다. 이어지는 세 명령은 대상 변경을 읽고 취소 커밋을 만든 뒤 결과를 확인한다.

git show BAD_COMMIT
git revert BAD_COMMIT
git show --stat HEAD

커밋 메시지 편집기가 열리면 왜 변경을 취소하는지도 적어 저장한다. 새 커밋의 실제 diff를 읽고 프로젝트 테스트를 실행한 뒤 팀의 일반 push·PR 절차로 공유한다. revert는 코드 변경을 기록하는 작업이므로 실행 중인 서비스의 배포, 이미 바뀐 데이터베이스 내용까지 자동으로 이전 상태로 돌리지는 않는다. 배포 과정이 있다면 새 커밋을 그 과정으로 반영해야 한다.

충돌이 난 revert를 계속하거나 중단한다

revert가 충돌로 멈췄다면 status에 표시된 파일을 열어 현재 코드에서 남겨야 할 결과를 만든다. 충돌 표시만 지우는 것으로 끝내지 않고, B의 효과를 제거한 뒤에도 C의 필요한 수정이 작동하는지 읽는다. 해결한 파일을 add한 다음 아래 계속 명령을 실행한다.

git add -- src/login.js
git revert --continue

취소할 대상을 잘못 골랐거나 지금 해결하기 어렵다면 git revert --abort로 진행 중인 revert를 시작 전 상태로 돌릴 수 있다. --quit는 진행 정보를 잊는 옵션이므로 같은 복구 동작으로 바꿔 쓰지 않는다. revert 문서의 진행 제어 옵션이 이 차이를 명시한다. merge 커밋을 취소하는 경우에는 어느 부모를 기준으로 삼을지와 이후 병합에 미칠 영향을 따로 판단해야 하므로, 위 단일 커밋 예제에 임의로 -m 1을 붙이지 않는다.

reset 뒤 사라진 커밋을 찾는 방법

실수로 브랜치를 옮겼다면 git reflog에서 이동 전 커밋 해시를 찾을 수 있다. reflog는 이 로컬 저장소에서 참조가 움직인 기록이다. 찾은 해시를 OLD_COMMIT 자리에 넣어 git show OLD_COMMIT으로 내용을 읽고, git branch rescue-before-reset OLD_COMMIT으로 새 브랜치 이름을 붙이면 현재 작업 파일을 건드리지 않고 그 커밋을 가리킬 수 있다. 그다음 필요한 변경을 가져올 방법을 정한다. reflog의 기록 범위에 근거한 복구 순서다.

이 방법이 복구하는 것은 Git에 남아 있는 커밋이다. 파일을 저장했더라도 커밋하거나 stash하지 않은 편집 내용을 reflog가 모두 보관하는 것은 아니다. GitHub의 변경 취소 사례도 커밋하지 않은 수정을 버린 경우의 한계를 설명한다. 커밋은 찾았지만 미완성 수정이 보이지 않는다면 편집기의 로컬 이력이나 별도로 저장한 사본에서 그 내용을 찾아야 한다.