이력 강제 덮어쓰기
고친 사람 github-actions[bot]
이력 강제 덮어쓰기는 여럿이 함께 쓰는 브랜치의 지난 기록을 고쳐 쓴 뒤 원격 저장소에 억지로 올려서 생기는 사고입니다. 원격에 있던 옛 커밋이 고친 커밋으로 갈려 나갑니다. 옛 커밋을 받아 간 동료의 저장소는 원격과 어긋납니다. 그사이 동료가 올린 작업이 브랜치에서 빠지기도 합니다.
쉽고 빠른 이해
이력 강제 덮어쓰기는 원격 저장소에 이미 올라간 커밋을 내 손으로 고친 커밋으로 바꿔 끼워서 생기는 사고입니다. 커밋은 저장소에 쌓이는 변경 기록 한 건입니다. 커밋이 줄지어 쌓인 것을 이력이라고 부릅니다. 어제 올린 커밋 메시지의 오타를 고쳐 다시 올리는 장면이 흔한 예입니다.
따로 이름이 붙은 까닭은 그 커밋을 나만 가진 게 아니기 때문입니다. 동료들은 옛 커밋을 이미 받아 갔습니다. 원격만 바뀌면 동료 저장소와 원격이 서로 다른 이력을 갖게 됩니다. 아무도 받아 가지 않은 커밋이라면 고쳐 올려도 문제가 없습니다.
어떻게 터지나:
- 함께 쓰는 브랜치에 올린 커밋을 고쳐 씁니다. 고친 커밋은 옛 커밋과 다른 새 커밋이 됩니다
- 원격은 이미 가진 커밋이 사라지는 올림을 받지 않습니다. 이 확인을 건너뛰고 강제로 올립니다
- 원격에서 옛 커밋이 빠집니다. 그사이 동료가 올린 커밋도 함께 빠질 수 있습니다
무엇이 나빠지나 — 동료가 다음에 받아 합칠 때 옛 커밋과 새 커밋이 둘 다 이력에 섞입니다. 남이 올린 작업은 그 사람이 다시 올리기 전까지 브랜치에서 사라집니다.
상세
이 절은 커밋이 서로 어떻게 이어져 있는지부터 봅니다. 그 위에서 고쳐 쓴 이력이 원격에 덮어씌워질 때 누구의 무엇이 사라지는지를 한 장면으로 보입니다.
이력 강제 덮어쓰기는 이미 원격에 올라가 여럿이 나눠 가진 커밋을 고쳐 쓰는 데서 시작합니다. 그 고친 커밋으로 원격 브랜치를 갈아 끼우면 이 사고가 납니다. 어제 main 에 올린 커밋 메시지의 오타를 오늘 고쳐 다시 올리면 이 일이 일어납니다. 원격이 평소에 막아 두는 덮어쓰기를 억지로 뚫기 때문에 「강제」가 붙습니다.
커밋이 앞 커밋을 가리키는 방식
커밋은 어느 순간의 파일 전체를 찍어 둔 기록입니다. 커밋마다 바로 앞 커밋을 가리킵니다. 이 앞 커밋을 부모 커밋이라고 부릅니다.
부모의 부모를 계속 따라가면 처음 커밋까지 한 줄로 거슬러 올라갑니다. 이렇게 거슬러 올라가며 닿는 커밋을 모두 그 커밋의 조상이라고 부릅니다. A 다음에 B, B 다음에 C 를 만들었다면 A 와 B 가 C 의 조상입니다. 원격이 푸시를 받을지 말지가 뒤에서 이 관계로 갈립니다.
커밋에는 이름표로 쓰는 커밋 해시가 붙습니다. Git 같은 분산 버전관리 도구에서는 커밋 내용에 부모의 해시까지 넣어 이 값을 계산합니다. 그래서 내용이 한 글자만 바뀌어도 해시가 바뀝니다. 부모가 바뀌어도 해시가 바뀝니다.
브랜치는 커밋 하나를 가리키는 이름입니다. 새 커밋을 만들면 브랜치가 그 커밋으로 한 칸 옮겨 갑니다. 브랜치는 끝 커밋 하나만 가리킵니다. 나머지 커밋은 부모를 따라가서 찾습니다.
고쳐 쓴 커밋은 새 커밋이다
이미 만든 커밋을 고치는 방법은 여럿입니다. 그중 하나가 마지막 커밋의 메시지나 내용을 바꾸는 커밋 고쳐 쓰기입니다. 커밋 메시지의 오타를 고칠 때 이것을 씁니다.
여러 커밋을 한꺼번에 손보는 방법으로는 리베이스가 있습니다. 리베이스는 커밋들을 다른 커밋 위로 옮겨 다시 쌓는 일입니다.
둘 다 옛 커밋을 손보는 것처럼 보입니다. 하지만 해시가 내용과 부모로 정해지므로 옛 커밋은 바꿀 수 없습니다. 도구는 새 커밋을 만듭니다. 그리고 브랜치를 그쪽으로 옮깁니다.
오타가 있는 커밋을 B, 오타를 고친 새 커밋을 B' 라고 부르겠습니다. 둘 다 바로 앞 커밋 A 를 부모로 둡니다. 아래 그림에서 화살표는 부모에서 다음 커밋으로 향합니다. 점선은 브랜치가 가리키는 커밋입니다.
flowchart TD
A["커밋 A"]
B["커밋 B · 오타 있음"]
B2["커밋 B' · 오타 고침"]
M["내 main"]
A --> B
A --> B2
M -.->|고치기 전| B
M -.->|고친 뒤| B2
A 아래에 B 와 B' 가 나란히 매달립니다. 둘은 부모가 같고 내용이 다른 별개의 커밋입니다. 내 저장소의 main 은 B' 로 옮겨 갑니다. B 는 이제 어느 브랜치도 가리키지 않습니다.
B 뒤에 다른 커밋이 이어져 있었다면 그 커밋들도 전부 새로 만들어집니다. 부모의 해시가 바뀌었으니 뒤 커밋의 해시도 따라 바뀌기 때문입니다.
내 저장소 안에서는 B 가 바로 지워지지 않습니다. 브랜치가 거쳐 간 커밋을 적어 두는 리플로그로 한동안 되찾을 수 있습니다. 리플로그는 저장소마다 따로 쌓입니다. 남의 저장소로는 전해지지 않습니다.
원격이 막아 두는 덮어쓰기
푸시는 내 커밋을 원격 저장소로 보내고 원격 브랜치를 옮기는 일입니다. 원격은 이 옮김을 아무 때나 받지 않습니다. 원격 브랜치가 가리키던 커밋이 내가 보내는 커밋의 조상일 때만 받습니다.
이 조건이 맞는 옮김을 패스트 포워드라고 부릅니다. 브랜치를 앞으로 당기기만 하니 원격에 있던 커밋은 하나도 빠지지 않습니다.
앞 그림의 main 은 내 저장소의 브랜치입니다. 원격 저장소의 main 은 아직 B 를 가리킵니다. B 는 B' 의 조상이 아니므로 B' 를 보내면 거절됩니다.
강제 푸시는 이 확인을 건너뛰고 원격 브랜치를 내가 보낸 커밋으로 바꿔 끼웁니다. Git 에서는 git push --force 입니다. 이 순간 원격에서 B 를 가리키던 이름이 사라집니다. 함께 쓰는 커밋을 고쳐 쓴 뒤라면 이 한 번의 푸시로 이력 강제 덮어쓰기가 일어납니다.
재현되는 조건
셋이 다 서면 이 사고가 납니다.
- 원격 브랜치를 둘 이상이 함께 씁니다
- 그 브랜치에 이미 올린 커밋을 내가 고쳐 썼습니다
- 그 상태에서 강제 푸시를 합니다
하나만 서지 않아도 이 사고는 안 납니다. 혼자 쓰는 브랜치라면 덮이는 커밋도 내 것뿐입니다. 고쳐 쓴 커밋이 없으면 원격에서 사라질 옛 커밋도 없습니다. 강제 푸시를 하지 않으면 원격이 거절하고 끝납니다.
동료 저장소에서 벌어지는 일
고쳐 쓴 B' 가 동료 쪽에서 어떻게 보이는지 봅니다. 풀은 원격의 새 커밋을 받아 와서 내 브랜치와 합치는 데까지 한 번에 하는 명령입니다.
sequenceDiagram
participant 나
participant 원격
participant 동료
나->>원격: B 를 올린다
동료->>원격: 받아 간다
원격-->>동료: B 를 준다
Note over 나: B 를 B' 로 고쳐 쓴다
나->>원격: B' 를 강제로 올린다
동료->>원격: 풀 한다
원격-->>동료: B' 를 준다
Note over 동료: B 와 B' 가 함께 합쳐진다
동료->>원격: 합친 결과를 올린다
Note over 원격: 지웠던 B 가 이력에 돌아온다
동료의 저장소는 B' 를 B 와 다른 커밋으로 봅니다. 해시가 다르기 때문입니다. 그래서 풀은 B 쪽 갈래와 B' 쪽 갈래를 병합 커밋으로 합칩니다.
합친 이력에는 같은 변경이 두 번 들어 있습니다. B 와 B' 가 같은 줄을 서로 다르게 바꿨다면 병합 충돌도 납니다. 동료가 이 결과를 올리면 내가 지운 B 가 원격 이력에 다시 들어옵니다.
명령으로 본 한 장면
내 쪽에서 친 명령 셋을 순서대로 놓았습니다. 오른쪽 주석이 각 명령의 결과입니다.
git commit --amend -m "고침" # B' 생김
git push # 거절된다
git push --force # 원격이 B'로
--amend 는 Git 에서 커밋 고쳐 쓰기를 하는 옵션입니다. 둘째 줄은 패스트 포워드가 아니라서 거절됩니다. 셋째 줄은 확인을 끕니다. 그래서 원격 main 이 B' 로 옮겨 갑니다.
동료가 다음에 치는 명령 둘은 이렇습니다.
git pull # B 와 B' 가 합쳐진다
git push # B 가 원격에 돌아온다
동료 쪽에서는 아무 경고도 없이 앞 그림의 뒷부분이 진행됩니다. 동료가 무엇이 덮였는지 모르면 합친 결과를 손대지 않고 올리게 됩니다.
안 받은 커밋이 함께 빠지는 피해
원격 브랜치 끝이 내 브랜치 끝의 조상이 아니게 되는 길은 하나 더 있습니다. 동료가 그사이 올린 커밋을 내가 아직 안 받았을 때입니다. 아래에서는 앞의 경우를 「고쳐 쓴 길」, 이 경우를 「안 받은 길」이라고 부릅니다.
「안 받은 길」만으로는 고쳐 쓴 이력이 없으니 이 표제어가 아닙니다. 남의 커밋을 지우는 평범한 강제 푸시 실수입니다. 다만 두 길이 한꺼번에 오기도 합니다. 그러면 이 사고에 동료 커밋이 빠지는 피해가 함께 옵니다.
| 원격 끝이 조상이 아니게 된 길 | 원격에서 빠지는 커밋 | 곤란해지는 사람 |
|---|---|---|
| 고쳐 쓴 길 — 내가 올린 커밋을 고쳐 썼다 | 고치기 전의 내 커밋 | 그 커밋을 받아 간 동료 |
| 안 받은 길 — 남이 올린 커밋을 안 받았다 | 동료가 올린 커밋 | 그 커밋을 올린 동료 |
내가 B 를 B' 로 고쳐 쓰는 사이 동료가 B 위에 커밋을 하나 올렸다고 합시다. 이 동료 커밋을 D 라고 부르겠습니다. 두 길이 겹친 이 장면을 강제 푸시 전과 뒤로 나눠 그리면 이렇습니다.
flowchart TD
subgraph S1["강제 푸시 전 · 원격"]
A1["커밋 A"] --> B1["커밋 B"] --> D1["커밋 D · 동료"]
R1["원격 main"] -.-> D1
end
subgraph S2["강제 푸시 전 · 내 저장소"]
A2["커밋 A"] --> B2["커밋 B' · 나"]
M2["내 main"] -.-> B2
end
subgraph S3["강제 푸시 뒤 · 원격"]
A3["커밋 A"] --> B3["커밋 B"] --> D3["커밋 D · 동료"]
A3 --> C3["커밋 B' · 나"]
R3["원격 main"] -.-> C3
end
S1 ~~~ S2
S2 ~~~ S3
D 는 B' 에서 부모를 거슬러 올라가도 닿지 않습니다. 그래서 원격 main 이 B' 로 옮겨 가면 B 와 D 둘 다 어느 브랜치도 가리키지 않게 됩니다. 강제 푸시 한 번에 두 줄의 피해가 같이 납니다.
덮이는 커밋을 가진 사람
강제 푸시로 원격 브랜치를 덮어쓰는 일 자체는 흔합니다. 그 덮어쓰기가 이력 강제 덮어쓰기라는 사고가 되는지는 덮이는 커밋을 누가 갖고 있느냐로 갈립니다. 아래 표는 흔한 상황 넷을 그 기준으로 나눈 것입니다.
| 상황 | 덮이는 커밋을 가진 사람 | 덮어쓰면 생기는 일 |
|---|---|---|
| 아직 푸시 안 한 커밋 | 나뿐 | 원격에 덮일 커밋이 없다 |
| 나만 쓰는 작업 브랜치 | 나뿐 | 내 저장소 밖에서는 아무것도 안 바뀐다 |
| 리뷰를 받는 작업 브랜치 | 나와 리뷰어 | 리뷰어 저장소에 옛 커밋이 남는다 |
main 처럼 여럿이 쓰는 브랜치 |
받아 간 모두 | 동료 저장소가 어긋나고 남의 커밋이 빠질 수 있다 |
위 두 줄은 덮어쓰기일 뿐 이 사고가 아닙니다. 고쳐 써도 혼자 다시 맞추면 끝납니다. 아래 두 줄에서는 받아 간 사람마다 자기 저장소를 다시 맞춰야 합니다. 그래서 흔히 쓰는 규칙은 「남에게 보낸 커밋은 고쳐 쓰지 않는다」입니다.
덮어쓰지 않고 고치는 방법
피하는 길은 둘로 갈립니다. 애초에 이력을 고치지 않는 길과, 고치더라도 남의 커밋을 지우지 않게 막는 길입니다.
| 수단 | 하는 일 |
|---|---|
| 되돌리기 | 잘못된 커밋을 지우지 않고, 그 변경을 거꾸로 적용한 새 커밋을 덧붙인다 |
--force-with-lease |
원격 브랜치가 내가 마지막으로 본 커밋을 아직 가리킬 때만 덮어쓴다 |
| 보호된 브랜치 | 원격 저장소가 정해 둔 브랜치에는 강제 푸시를 받지 않는다 |
| 리플로그 | 덮인 커밋을 가진 저장소에서 그 커밋을 다시 찾아 브랜치를 돌려놓는다 |
되돌리기는 새 커밋을 뒤에 붙이기만 하므로 패스트 포워드입니다. 강제할 필요가 없습니다. 동료 저장소도 어긋나지 않습니다. 대신 잘못된 커밋과 그것을 되돌린 커밋이 둘 다 이력에 남습니다.
--force-with-lease 는 Git 의 푸시 옵션입니다. 「안 받은 길」, 곧 그사이 남이 올린 커밋을 지우는 일을 막습니다. 「고쳐 쓴 길」은 막지 못합니다. 내가 고쳐 쓴 옛 커밋을 동료가 이미 받아 갔다는 사실은 이 확인에 안 잡히기 때문입니다.
이 옵션이 비교하는 「마지막으로 본 커밋」은 원격 추적 브랜치에 적힌 값입니다. 원격 추적 브랜치는 원격 브랜치의 위치를 내 저장소가 마지막으로 받아 적어 둔 사본입니다.
이 사본은 페치할 때 새로 적힙니다. 페치는 원격의 새 커밋을 내 저장소로 받아 오기만 하는 명령입니다. 내 브랜치와 합치지는 않습니다.
자동 페치 기능을 켠 편집기는 내가 모르는 사이 뒤에서 페치를 돌립니다. 그러면 남의 커밋을 눈으로 본 적이 없어도 원격 추적 브랜치는 이미 그 커밋을 가리킵니다. 이 상태에서 --force-with-lease 로 올리면 확인을 통과합니다. 결국 남의 커밋이 지워집니다. 아래 그림은 이 순서를 그린 것입니다.
sequenceDiagram
participant 나
participant 편집기
participant 원격
participant 동료
동료->>원격: D 를 올린다
편집기->>원격: 뒤에서 페치한다
원격-->>편집기: 끝은 D
Note over 나,편집기: 원격 추적 브랜치가 D 로 바뀐다. 나는 D 를 본 적이 없다
나->>원격: --force-with-lease 로 올린다
Note over 원격: 마지막으로 본 값과 같아 받는다. D 가 빠진다
덮어써도 남는 비밀값
비밀값 커밋은 비밀번호나 접속 키를 커밋에 넣어 올린 실수입니다. 이것을 지우려고 이력을 고쳐 쓰고 강제 푸시하는 일이 흔합니다.
원격 브랜치에서는 그 커밋이 빠집니다. 하지만 이미 받아 간 저장소에는 그대로 남습니다. 그래서 새어 나간 비밀값은 이력을 고치는 것과 따로 새 값으로 바꿔야 합니다.
관련 항목
이력 강제 덮어쓰기를 일으키는 이력 고쳐 쓰기 작업
리베이스 · 스쿼시 · 커밋 고쳐 쓰기 · 대화형 리베이스 · 체리픽
이력 강제 덮어쓰기가 거치는 원격 동기화 명령
이력 강제 덮어쓰기에서 덮이는 이력 구성 요소
커밋 · 부모 커밋 · 커밋 해시 · 브랜치 · 원격 추적 브랜치 · HEAD · 커밋 그래프 · DAG
덮어쓰기 여부를 가르는 합치기 방식
패스트 포워드 · 병합 · 병합 커밋 · 세 갈래 병합 · 공통 조상
이력 강제 덮어쓰기를 막거나 되돌리는 장치
보호된 브랜치 · 브랜치 보호 규칙 · 되돌리기 · 리플로그 · 풀 리퀘스트
이력 강제 덮어쓰기와 함께 터지는 버전관리 문제
병합 충돌 · 비밀값 커밋 · 큰 이진 파일 · 분리된 HEAD · 십자 병합
이력 강제 덮어쓰기가 속하는 상위 분류
다른 이름: rewriting published history · rewriting public history · 이력 덮어쓰기 · 공개 이력 고쳐 쓰기