Git 히스토리 재작성 실전 워크플로우 (rebase · filter-branch · 안전 push)
브랜치가 꼬이고, 여러 커밋 중 일부만 제거하고, author/commit date까지 정리해야 하는 상황에서 즉시 참조할 수 있는 Git 히스토리 재작성 표준 워크플로우를 정리한다. 모든 단계는 destructive(되돌리기 어려운 파괴적 작업)하므로 백업과 검증을 전제로 한다.
히스토리 재작성은 공유 브랜치에서 절대 가볍게 하지 않는다. 본 글의 force-push는 본인만 쓰는 브랜치이거나, 다른 사람이 아직 fetch하지 않은 것이 확실한 경우를 전제로 한다.
[01] 시작 전 핵심 개념 4가지
재작성 작업의 사고는 결국 “어떤 커밋이 어디에 속하는가”를 정확히 그리는 데서 출발한다.
1-1. Range query — A..B
A..B는 “A에는 없고 B에는 있는 커밋”을 의미한다.
1
2
git log A..B # A에 없고 B에 있는 커밋
git log --oneline origin/main..HEAD # 로컬 main이 origin보다 얼마나 앞서 있나
git log --oneline origin/main..HEAD 결과가 1줄이라고 해서 전체 히스토리가 1줄인 게 아니다. origin/main보다 1커밋 앞이라는 뜻일 뿐이다. 전체 히스토리는 range 없이 git log B로 본다.
1-2. Base / Ancestry
모든 커밋은 부모 커밋을 가지며 ancestry(조상 사슬)를 형성한다. “브랜치 A의 base가 브랜치 B”라는 말은 A의 ancestry 안에 B의 tip이 있다는 뜻이다.
1
2
3
4
5
# 두 브랜치의 분기점(가장 가까운 공통 조상)
git merge-base main feature/x
# base부터 branch까지 몇 커밋인지
git rev-list --count base..branch
1-3. 3-way merge / conflict marker
1
2
3
4
5
<<<<<<< HEAD ← "ours" — 현재 브랜치 (rebase 중에는 base + 이미 적용된 커밋들)
... 현재 코드 ...
=======
... 가져오려는 코드 ... ← "theirs" — incoming 커밋 (rebase 중에는 지금 적용하는 커밋)
>>>>>>> <hash> (<msg>)
여기서 핵심은 rebase 중에는 --ours/--theirs의 의미가 merge와 반대라는 점이다.
| 상황 | ours | theirs |
|---|---|---|
| merge | 현재 브랜치 | merge하려는 브랜치 |
| rebase | base + 이전 pick들 | 지금 적용하려는 커밋 |
conflict 영역은 다음으로 빠르게 찾는다.
1
grep -n -E "^<<<<<<<|^=======|^>>>>>>>" <file>
1-4. filter-branch의 $GIT_COMMIT
git filter-branch가 각 커밋을 처리할 때 $GIT_COMMIT은 rewrite 이전의 원본 SHA다. 따라서 case 문에서 원본 hash로 매칭할 수 있다. 새 SHA는 filter-branch가 끝난 뒤에야 확정된다.
[02] 현황 파악 (read-only 조사)
작업 시작 전 항상 먼저 실행한다. 백업·브랜치·remote 분포를 머릿속에 그려야 위험을 평가할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# 1) 모든 브랜치 + 마지막 커밋
git branch -v
# 2) remote tip vs 로컬 main 격차
git remote -v
git log --oneline origin/main..HEAD # 로컬 main이 origin보다 얼마나 앞
git rev-list --count upstream/main..HEAD # 커밋 개수만
# 3) 다른 브랜치의 base
git merge-base main feature/x | xargs git log -1 --format="%h %s"
# 4) 특정 hash가 만진 변경
git show --stat <hash> # 파일 + line 변동
git show <hash> -- <file> # 특정 파일의 patch
# 5) 특정 파일을 만진 모든 커밋
git log --oneline base..HEAD -- <file>
# 6) author / committer 정보
git log --reverse base..HEAD --format="%h %ai %an <%ae> %s"
# 7) author date == committer date 일치 확인
git log --format="%H %at %ct" | awk '$2!=$3 {print "MISMATCH:", $1}'
# 8) 시간순(monotonic: 과거→현재로 단조 증가) 확인
git log --reverse --format="%at %h %s" | awk \
'{ if (prev != "" && $1 < prev) print "OUT OF ORDER:", $0; prev=$1 }'
2-1. Tree 비교 — 코드 자체가 같은지
커밋 메시지가 달라도 코드(tree)는 동일할 수 있다. 중복 작업을 식별할 때 유용하다.
1
2
3
4
5
6
7
8
9
git diff A B --stat # 변경 파일 목록
git diff A B | wc -l # 변경 line 수 (0이면 tree 동일)
# 특정 커밋의 tree 해시
git rev-parse <hash>^{tree}
# 같은 tree를 가진 커밋 찾기
T=$(git rev-parse <hash>^{tree})
git log --format="%h %T" | awk -v t="$T" '$2==t {print $1}'
[03] 백업 전략
히스토리 재작성(rewrite)은 한 번 실행하면 되돌리기 어려운 파괴적(destructive) 작업이다. 작업 전 반드시 backup 브랜치를 만든다.
1
2
3
4
5
# 현재 HEAD를 가리키는 백업 브랜치
git branch backup-pre-rebase-$(date +%F)
# 작업은 임시 브랜치에서 하고, 성공 시 main으로 force-update
git checkout -b main-rebuild <starting-point>
실전에서 효과를 확인한 3중 백업 원칙:
- 작업 source 자체 (예:
feature/x— 복원용으로 유지) - main 시작 시점 백업 (예:
main-pre-rewrite-YYYY-MM-DD) - 중간 실패 시 복귀용 (예:
main-pre-rebuild-YYYY-MM-DD)
1
git fetch --all
git fetch origin upstream은 “origin과 upstream을 둘 다 fetch”가 아니다. fetch <remote> <ref> 문법으로 해석되어 “origin에서 upstream이라는 ref를 가져오기”가 된다. 모두 받으려면 git fetch --all을 쓴다.
[04] Interactive rebase — drop / reword / edit
여러 커밋 중 일부만 제거하거나 메시지를 수정할 때 쓴다.
4-1. 기본 대화형
1
2
3
4
5
git rebase --interactive <base>
# editor에 todo list가 열린다:
# pick aaa1111 commit msg
# pick bbb2222 commit msg
# 각 줄을 pick / drop / reword / edit / squash / fixup 로 변경
4-2. 비대화형 자동화 (스크립트/CI용)
GIT_SEQUENCE_EDITOR는 todo list 편집기, GIT_EDITOR는 커밋 메시지 편집기다.
1
2
3
4
5
6
7
8
9
10
# 커밋 2개 drop + 1개 reword
export GIT_SEQUENCE_EDITOR='sed -i \
-e "/^pick aaa1111 /s/^pick/drop/" \
-e "/^pick bbb2222 /s/^pick/drop/" \
-e "/^pick ccc3333 /s/^pick/reword/"'
# reword 시 커밋 메시지 자동 치환
export GIT_EDITOR='sed -i -e "s|<old-keyword>|<new-keyword>|"'
git rebase --interactive <base>
GIT_EDITOR는 reword뿐 아니라 커밋 메시지 편집이 필요한 모든 단계에서 호출된다. sed 패턴이 의도한 커밋에만 영향을 주도록 신중히 작성한다.
4-3. 진행 명령
1
2
3
git rebase --continue # conflict 해결 후 다음 커밋으로
git rebase --skip # 현재 커밋 건너뛰기 (drop 대체)
git rebase --abort # 전체 취소, base 상태로 복귀
rebase 도중 상태를 직접 확인할 수도 있다.
1
2
3
ls .git/rebase-merge # rebase 진행 중인지
cat .git/rebase-merge/done # 처리 완료된 커밋
cat .git/rebase-merge/git-rebase-todo # 남은 커밋
[05] Conflict 해결 패턴
5-1. 진단
1
2
3
git status --short # UU = both modified
grep -n -E "^<<<<<<<|^=======|^>>>>>>>" <file> # marker 위치
git show <conflicting-hash> -- <file> # 원본 커밋의 의도 확인
5-2. 해결 패턴 4가지
패턴 A — HEAD 쪽 유지 (incoming 버림)
1
2
3
4
5
6
7
git checkout --ours <file> # 파일 전체
# 또는 특정 영역만 sed로
sed -i -e '/^<<<<<<< HEAD$/d' \
-e '/^=======$/,/^>>>>>>> /d' \
<file>
# 효과: <<<<<<< HEAD 줄 + ======= 부터 >>>>>>> 줄까지 제거 = HEAD section만 유지
패턴 B — incoming 쪽 채택 (HEAD 버림)
1
2
3
4
5
git checkout --theirs <file>
sed -i -e '/^<<<<<<< HEAD$/,/^=======$/d' \
-e '/^>>>>>>> /d' \
<file>
패턴 C — 양쪽 다 채택 (marker만 제거)
1
2
3
4
sed -i -e '/^<<<<<<< HEAD$/d' \
-e '/^=======$/d' \
-e '/^>>>>>>> /d' \
<file>
HEAD가 비어 있고 incoming만 있는 경우에도 안전하다(incoming 자동 채택 효과).
패턴 D — 수동 편집 (가장 안전)
editor로 열어 conflict 영역만 의도대로 작성한다. 양쪽을 일부씩 채택하는 복잡한 케이스에는 필수다.
5-3. 해결 후 진행
1
2
3
<build-command> # 예: go build ./... / npm run build — 반드시 정상 빌드 확인(sanity check)
git add <files>
git rebase --continue
[06] Squash 전략과 trade-off
squash(스쿼시)는 여러 개의 커밋을 하나로 합쳐 히스토리를 압축하는 작업이다. 작업 중간에 쌓인 자잘한 커밋들(“오타 수정”, “리뷰 반영” 등)을 깔끔한 한 덩어리로 정리할 때 쓴다. 아래는 trade-off(장단점 절충)에 따른 세 가지 방식이다.
6-1. Heavy squash — 전부 1개로
1
2
git reset --soft <base> # 변경분은 stage에 남기고 커밋만 폐기
git commit -m "큰 통합 메시지"
6-2. reset 모드의 차이
| 모드 | HEAD | index | working dir |
|---|---|---|---|
--soft |
이동 | 유지 | 유지 |
--mixed (default) |
이동 | 이동 | 유지 |
--hard |
이동 | 이동 | 이동 — 작업 내용 소실(destructive) |
6-3. 어떤 squash를 고를까
| 방식 | 장점 | 단점 |
|---|---|---|
| Heavy (N→1) | 깨끗한 log | 단계별 작업 의도/blame 손실 |
| Medium (rebase + drop) | 원본 단위 보존 | conflict 가능 |
| Light (+1 commit) | 안전, 빠름 | “원래 X 있었구나” 히스토리 노출 |
선택 기준: 히스토리의 “왜 그렇게 되었나”가 중요하면 Medium, 외관만 중요하면 Heavy.
[07] Author / CommitDate 정리 (filter-branch)
특정 커밋의 author date나 author 정보를 일괄 보정할 때 filter-branch --env-filter를 쓴다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
FILTER_BRANCH_SQUELCH_WARNING=1 git filter-branch -f --env-filter '
case "$GIT_COMMIT" in
aaa1111*) export GIT_AUTHOR_DATE="2099-12-31T09:00:00+09:00" ;;
bbb2222*) export GIT_AUTHOR_DATE="2099-12-31T10:00:00+09:00" ;;
esac
# committer date를 author date에 동기화 (모든 커밋)
export GIT_COMMITTER_DATE="$GIT_AUTHOR_DATE"
# 특정 author만 통일
if [ "$GIT_AUTHOR_EMAIL" = "old@example.com" ]; then
export GIT_AUTHOR_NAME="NewName"
export GIT_AUTHOR_EMAIL="new@example.com"
fi
' <base>..HEAD
| 변수 | 의미 |
|---|---|
GIT_AUTHOR_DATE |
코드를 작성한 시점 |
GIT_AUTHOR_NAME / GIT_AUTHOR_EMAIL
|
작성자 |
GIT_COMMITTER_DATE |
커밋 객체가 만들어진 시점 (rebase 시 자동으로 새로 부여됨) |
$GIT_COMMIT |
처리 중인 원본 커밋 SHA (case 매칭용) |
날짜는 ISO 8601(YYYY-MM-DDTHH:MM:SS+TZ)을 권장한다. 작업 후에는 반드시 정리한다.
1
2
3
rm -rf .git/refs/original # filter-branch가 남긴 백업 ref
git reflog expire --expire=now --all
git gc --prune=now
author 변경과 CommitDate 동기화의 더 자세한 방법(--amend, rebase -i, git filter-repo 비교 포함)은 별도 글 Git History Author 변경 + CommitDate 동기화에서 다룬다. filter-branch는 git 공식적으로 deprecated이며, 복잡한 경우에는 git filter-repo가 더 빠르고 안전하다.
[08] Branch 정리
1
2
git branch -d <branch> # safe — 다른 ref에서 도달 가능할 때만 허용
git branch -D <branch> # force — 도달 불가능해도 삭제 (orphan: 어디서도 도달 못 하는 고아 커밋 발생 위험)
- 검증 끝난 임시 브랜치(예:
main-rebuild를 main으로 reset한 뒤) →-d - 도달 불가능한 오래된 백업 →
-D - 현재 active 브랜치는 삭제 불가 — 다른 브랜치로 checkout한 뒤 삭제
1
2
3
4
5
6
# main을 작업 브랜치로 교체
git checkout main
git reset --hard main-rebuild
# 백업 일괄 정리
for b in main-pre-* backup-*; do git branch -D "$b"; done
[09] 안전 push
1
2
3
git push origin main # rewrite 후엔 거부됨 (non-fast-forward: 단순 전진이 아니라서)
git push --force origin main # 위험: 남이 push한 새 커밋도 덮어씀
git push --force-with-lease origin main # 권장
--force-with-lease는 remote tip이 내가 마지막으로 fetch한 것과 다르면 거부하여 다른 사람의 작업을 보호한다.
- 안전한 경우: 본인만 쓰는 personal repo, 다른 사람이 fetch하지 않은 게 확실한 오래된(stale) remote
- 위험한 경우: 활성 공유 브랜치(main/dev), 다른 사람이 이미 그 브랜치 위에서 작업 중
[10] 시간순 정렬
rebase·cherry-pick의 default author date가 “지금”이라, 과거에 작업한 커밋이 최근 날짜로 표시되며 시간순이 깨질 수 있다. 매핑 표를 만들어 잘못된 날짜만 재배정한다.
1
2
3
4
5
6
7
8
9
10
11
12
git filter-branch -f --env-filter '
case "$GIT_COMMIT" in
hash1*) export GIT_AUTHOR_DATE="2026-05-01T09:00:00+09:00" ;;
hash2*) export GIT_AUTHOR_DATE="2026-05-01T10:00:00+09:00" ;;
esac
export GIT_COMMITTER_DATE="$GIT_AUTHOR_DATE"
' <base>..HEAD
# 검증
git log --reverse <base>..HEAD --format="%h | %ai | %ci | %s"
git log --reverse --format="%at" | awk \
'{ if (prev != "" && $1 < prev) print "OUT OF ORDER"; prev=$1 }'
시간 분배 가이드: 날짜 안에서는 30분~1시간 간격, 관련 커밋끼리 가깝게, 마지막 커밋 시각이 너무 늦지 않게.
[11] 자주 하는 실수 (Gotchas)
1) git fetch origin upstream ≠ “origin + upstream fetch” — git fetch --all 또는 git fetch origin && git fetch upstream.
2) grep no-match → exit 1 → && short-circuit
1
2
3
4
5
# 의도: 백업 없으면 만든다 — 그러나 grep no-match 시 모든 &&가 실패
git branch | grep "backup" && echo "exists" && git branch backup # 안 만들어짐
# 해결
git branch | grep -q "backup" || git branch backup
3) awk built-in match와 변수명 충돌 — match는 내장 함수이므로 변수명으로 쓰지 말고 status 등 다른 이름을 쓴다.
4) filter-branch의 .git/refs/original/ 잔재 — 정리하지 않으면 다음 실행 시 --force가 필요하다. 항상 reflog expire + gc까지 수행.
5) rebase todo의 hash는 원본 hash — 작업 중 새 SHA가 생기지만 todo list 매칭은 원본 hash 기준이다.
6) sed delimiter 충돌(/) — 경로에 /가 들어가면 sed 's|a/b|c|'처럼 delimiter를 바꾼다.
7) 부모/자식 git repo 혼동 — git rev-parse --show-toplevel로 지금 어느 repo에서 명령을 실행 중인지 항상 확인한다. 부모 디렉토리가 자식을 .gitignore 처리하는 경우 자주 헷갈린다.
[12] 실전 표준 흐름 (end-to-end)
N개 커밋에서 특정 키워드 관련 K개 제거 + upstream 추가 커밋 통합 + 일부 커밋의 author/date 정리를 한 번에 처리하는 표준 순서다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
# Step 1 — 분석
git fetch --all
git branch -v
git log --oneline upstream/main..<source-branch>
git log --oneline --all | grep -i "<keyword>"
# Step 2 — 백업 + 작업 브랜치
git branch main-pre-rewrite-$(date +%F) main
git branch main-pre-rebuild-$(date +%F) main
git checkout -b main-rebuild <source-branch>
# Step 3 — interactive rebase (drop + reword)
export GIT_SEQUENCE_EDITOR='sed -i \
-e "/^pick aaa1111 /s/^pick/drop/" \
-e "/^pick ccc3333 /s/^pick/reword/"'
export GIT_EDITOR='sed -i -e "s|<old-keyword>|<new-keyword>|"'
git rebase --interactive upstream/main
# Step 4 — conflict 해결 루프 (커밋마다)
git status --short
grep -n -E "^<<<<<<<|^=======|^>>>>>>>" <file>
<build-command>
git add <file>; git rebase --continue
# Step 5 — 검증
grep -rln -i "<removed-keyword>" --include="*.<ext>" . # 코드 잔재 0
git log <base>..HEAD --format=%B | grep -ic "<removed-keyword>" # 메시지 잔재 0
# Step 6 — main 교체
git checkout main
git reset --hard main-rebuild
# Step 7 — author/date 정리
FILTER_BRANCH_SQUELCH_WARNING=1 git filter-branch -f --env-filter '
case "$GIT_COMMIT" in
hash1*) export GIT_AUTHOR_DATE="2026-05-01T09:00:00+09:00" ;;
esac
export GIT_COMMITTER_DATE="$GIT_AUTHOR_DATE"
' upstream/main..HEAD
rm -rf .git/refs/original
git reflog expire --expire=now --all
git gc --prune=now
# Step 8 — 최종 검증
git log <base>..HEAD --format="%H %at %ct %ae %ce" | awk '
$2!=$3 {print "DATE MISMATCH:", $1}
$4!=$5 {print "EMAIL MISMATCH:", $1}
prev!="" && $2<prev {print "OUT OF ORDER:", $1}
{prev=$2}'
# Step 9 — 브랜치 정리
git branch -d main-rebuild
for b in main-pre-* backup-*; do git branch -D "$b"; done
# Step 10 — push (수동)
git push --force-with-lease origin main
[13] Quick reference card
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
=== 조사 ===
git branch -v
git log --oneline <range>
git rev-list --count <range>
git merge-base A B
git diff A B --stat
=== 백업 ===
git branch backup-$(date +%F)
git checkout -b main-rebuild <source>
=== Rebase ===
git rebase -i <base>
git rebase --continue / --skip / --abort
GIT_SEQUENCE_EDITOR='sed ...' GIT_EDITOR='sed ...' git rebase -i <base>
=== Conflict ===
git status --short
grep -n "<<<<<<<\|=======\|>>>>>>>" <file>
git checkout --ours/--theirs <file>
git add <file>; git rebase --continue
=== Squash ===
git reset --soft <base>; git commit -m "..."
=== Date/Author ===
git filter-branch -f --env-filter '
case "$GIT_COMMIT" in hash*) export GIT_AUTHOR_DATE="..." ;; esac
export GIT_COMMITTER_DATE="$GIT_AUTHOR_DATE"
' <base>..HEAD
rm -rf .git/refs/original
git reflog expire --expire=now --all && git gc --prune=now
=== Push ===
git push --force-with-lease <remote> <branch>
더 깊은 참조: git help rebase, git help filter-branch, 그리고 deprecated된 filter-branch의 대체 도구 git-filter-repo. 관련 글: Git History Author 변경 + CommitDate 동기화, git commit –amend와 force push.