← posts/b.log()

blog92@web:~$ cat posts/github-07-rebase-cherry-pick-and-bisect.md

GIT4 min read

GitHub 완전 정복 7편 — rebase -i, cherry-pick, bisect와 히스토리를 다루는 도구들

interactive rebase로 커밋을 정리하고 cherry-pick으로 골라 가져오고 bisect로 버그 커밋을 이진 탐색하는 법, worktree·submodule·subtree·얕은 클론·LFS 같은 저장소 운영 도구, 그리고 히스토리에서 민감 정보를 제거하는 절차와 Git Hooks를 다룹니다.

이전 편: 6편 — reset·revert·reflog로 되돌리는 안전한 복구의 기술

5.1 interactive rebase — 히스토리 편집기

bash
git rebase -i HEAD~5        # 최근 5개 커밋 편집
git rebase -i abc1234       # 특정 커밋 이후 전부
git rebase -i --root        # 최초 커밋부터

에디터가 열립니다.

text
pick abc1234 feat: 로그인 폼
squash def5678 fix: 오타
reword ghi9012 feat: 인증 로직
drop jkl3456 wip
edit mno7890 refactor: 정리
명령동작
pick (p)그대로 사용
reword (r)메시지만 수정
edit (e)이 커밋에서 멈춤 (내용 수정 가능)
squash (s)이전 커밋과 합침, 메시지 둘 다 유지
fixup (f)이전 커밋과 합침, 메시지 버림 ★
drop (d)커밋 삭제
exec (x)임의 명령 실행 (예: 테스트)

줄 순서를 바꾸면 커밋 순서가 바뀝니다. 줄을 지우면 커밋이 사라집니다.

자동 fixup 워크플로:

bash
git commit --fixup=abc1234       # "fixup! ..." 커밋 생성
git rebase -i --autosquash HEAD~5  # 자동으로 제자리에 배치

interactive rebase는 push 전 내 브랜치를 정리하는 용도입니다. 공유 브랜치에서는 쓰지 마세요. 꼬였다면 언제든 git rebase --abort.

5.2 cherry-pick — 커밋만 골라 가져오기

bash
git cherry-pick abc1234              # 커밋 1개 가져오기
git cherry-pick abc1234 def5678      # 여러 개
git cherry-pick abc1234..def5678     # 범위 (abc 제외)
git cherry-pick -n abc1234           # 커밋 없이 스테이징만
git cherry-pick -x abc1234           # 원본 해시를 메시지에 기록 ★
git cherry-pick --abort              # 취소 ★

hotfix를 여러 릴리스 브랜치에 전파할 때 주로 씁니다. -x를 붙이면 "cherry picked from commit abc1234"가 메시지에 남아 추적이 쉬워집니다.

남용하면 같은 변경이 서로 다른 해시로 여러 브랜치에 흩어져서 나중에 머지할 때 충돌이 납니다. 정상 경로는 merge, cherry-pick은 예외 수단으로 두세요.

5.3 bisect — 이진 탐색으로 버그 커밋 찾기

커밋 1000개 중 어디서 버그가 들어왔는지 찾을 때, 10번만 테스트하면 됩니다.

bash
git bisect start
git bisect bad                  # 현재는 버그 있음
git bisect good v1.0.0          # 이 버전은 멀쩡했음
# → Git이 중간 커밋으로 이동시킴
 
# 테스트 후
git bisect good     # 또는 git bisect bad
# → 반복
 
git bisect reset    # 종료, 원래 위치로 ★

자동화가 진짜 강력합니다.

bash
git bisect start HEAD v1.0.0
git bisect run npm test      # 테스트 스크립트가 exit 0 = good, 그 외 = bad
# → 커피 마시고 오면 범인 커밋이 나와 있음

5.4 worktree — 한 저장소, 여러 작업 디렉토리

bash
git worktree add ../hotfix hotfix/urgent    # 새 폴더에 브랜치 체크아웃
git worktree add -b feature/new ../new      # 브랜치 생성과 동시에
git worktree list                           # 목록
git worktree remove ../hotfix               # 제거
git worktree prune                          # 정리

stash → 브랜치 이동 → 작업 → 복귀 사이클 없이, 동시에 두 브랜치를 열어둘 수 있습니다. 긴급 hotfix가 들어왔는데 현재 작업을 건드리기 싫을 때, 또는 두 브랜치의 동작을 나란히 비교할 때 유용합니다. clone을 또 받는 것보다 디스크와 시간이 절약됩니다(.git을 공유).

5.5 submodule vs subtree

Submodule — 참조만 저장

bash
git submodule add https://github.com/user/lib.git libs/lib
git submodule update --init --recursive     # 클론 후 필수 ★
git clone --recurse-submodules <url>        # 한 번에
git submodule update --remote               # 최신으로 갱신

부모 저장소는 서브모듈의 특정 커밋 해시만 기록합니다. 코드는 들어있지 않습니다. 그래서 팀원이 --init을 안 하면 폴더가 비어있는 사고가 흔합니다.

Subtree — 코드를 통째로 포함

bash
git subtree add --prefix=libs/lib https://github.com/user/lib.git main --squash
git subtree pull --prefix=libs/lib https://github.com/user/lib.git main --squash
git subtree push --prefix=libs/lib https://github.com/user/lib.git main
SubmoduleSubtree
저장커밋 참조실제 파일
클론추가 명령 필요그냥 됨
저장소 크기작음큼
사용자 학습 비용높음낮음
원본에 기여쉬움번거로움

실무 조언: 요즘은 둘 다 피하고 패키지 매니저(npm/pip/maven)나 모노레포 도구를 쓰는 편이 낫습니다. 서브모듈은 사내 공통 라이브러리를 소스 레벨로 공유해야 하는 폐쇄망 같은 제약 환경에서 여전히 쓰입니다.

5.6 대용량 저장소 다루기

bash
# 얕은 클론 — 최근 커밋만 (CI에서 매우 유용)
git clone --depth 1 <url>
git fetch --unshallow          # 나중에 전체 히스토리 받기
 
# 특정 브랜치만
git clone --single-branch --branch main <url>
 
# 필요한 디렉토리만 체크아웃
git clone --filter=blob:none --sparse <url>
cd repo
# Git 2.37부터 cone 모드가 기본값이라 --cone은 붙이지 않아도 됩니다(그 아래 버전에서는 필요합니다).
git sparse-checkout set src/ docs/
git sparse-checkout list
git sparse-checkout disable

git sparse-checkout은 Git 문서상 아직 실험적(EXPERIMENTAL) 명령으로 표시돼 있어 동작이 바뀔 수 있습니다.

Git LFS — 대용량 바이너리

bash
git lfs install
git lfs track "*.psd"
git lfs track "*.mp4"
git add .gitattributes         # 반드시 커밋할 것 ★
git lfs ls-files

Git은 바이너리에도 압축을 시도하지만 .psd·.mp4처럼 이미 압축된 포맷은 버전 간 차이가 거의 잡히지 않아, 100MB짜리 파일을 10번 수정하면 저장소가 1GB 가까이 불어납니다. LFS는 실제 파일을 별도 서버에 두고 포인터만 커밋합니다. (GitHub Free·Pro는 LFS 저장 10 GiB와 월 다운로드 대역폭 10 GiB가 포함되고, 초과분은 사용량 기반으로 과금됩니다. 예전의 선불 데이터팩 방식은 폐지됐습니다.)

5.7 히스토리에서 민감 정보 제거

API 키를 실수로 커밋했다면:

bash
# 1순위 — 키를 즉시 폐기하고 재발급 ★★★
# 히스토리를 지워도 누군가 이미 clone/fork 했을 수 있습니다
 
# 2순위 — 히스토리 정리 (Git 공식 문서가 filter-branch 사용을 권장하지 않고 git-filter-repo를 안내합니다)
pip install git-filter-repo
 
# filter-repo는 "갓 clone한 사본"이 아니면 실행 자체를 거부합니다. HEAD reflog 항목이 2개 이상이면 걸리므로
# 한 번이라도 커밋하고 push한 작업 저장소는 거의 전부 해당됩니다. 그래서 새로 clone한 사본에서 시작합니다.
# 에러 메시지가 안내하는 --force는 이 안전장치를 끄는 옵션입니다. 따로 백업해 둔 사본이 아니면 붙이지 마세요.
git clone https://github.com/user/repo.git repo-clean
cd repo-clean
git filter-repo --path .env --invert-paths          # 파일 제거
git filter-repo --replace-text secrets.txt          # 문자열 치환
 
# 3순위 — 강제 푸시 (팀 전원에게 사전 공지 필수 ⚫)
# filter-repo는 실수로 되밀지 못하게 origin을 지웁니다. 다시 등록하고 fetch부터 해야 합니다.
git remote add origin https://github.com/user/repo.git
git fetch origin
# 히스토리를 통째로 다시 쓴 직후에는 --force-if-includes가 반드시 거부합니다(원격의 옛 끝점이
# 새 히스토리에 통합된 적이 없기 때문). 이 한 번은 --force-with-lease까지만 붙입니다.
git push --force-with-lease --all
git push --force-with-lease --tags

평소 force push의 기본값은 5편이 권하는 --force-with-lease --force-if-includes 그대로 두세요. 예외는 방금처럼 히스토리를 통째로 다시 쓴 직후 한 번뿐이고, 그때는 --force-if-includes만 뺍니다. --force로 내려가는 것이 아닙니다.

이 작업은 모든 커밋 해시를 바꿉니다. 팀원들은 기존 로컬을 버리고 다시 clone 해야 합니다. 작업 전에 반드시 공지하고, 진행 중인 PR이 없는 시점을 고르세요.

GitHub의 Push protection은 알려진 형식의 토큰이 들어간 push 자체를 차단합니다. 개인 계정에는 2024년 2월부터 사용자 단위로 기본 활성화돼 있고, 개인 소유의 새 공개 저장소는 2024년 3월부터 저장소 단위로도 기본 활성화됩니다. 반면 조직 소유 저장소·기존 저장소·비공개 저장소는 직접 켜야 합니다(비공개 저장소는 GitHub Secret Protection이 필요합니다). 사후 대응보다 이쪽이 훨씬 낫습니다.

5.8 Git Hooks

.git/hooks/에 있는 스크립트로, 특정 시점에 자동 실행됩니다.

훅시점용도
pre-commit커밋 직전린트, 포맷, 테스트
commit-msg메시지 작성 후메시지 형식 검증
pre-pushpush 직전테스트 실행
post-merge머지 후의존성 재설치

.git/hooks/는 커밋되지 않으므로 팀 공유가 안 됩니다. 그래서 도구를 씁니다.

bash
# Node.js — husky + lint-staged
npm install -D husky lint-staged
npx husky init
echo "npx lint-staged" > .husky/pre-commit   # init이 만들어둔 기본 내용(npm test)을 덮어씁니다
json
{
  "lint-staged": {
    "*.{ts,tsx}": ["eslint --fix", "prettier --write"],
    "*.{json,md}": ["prettier --write"]
  }
}
bash
# 언어 무관 — pre-commit (Python 기반)
pip install pre-commit
pre-commit install
yaml
# .pre-commit-config.yaml
repos:
  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v6.0.0
    hooks:
      - id: trailing-whitespace
      - id: end-of-file-fixer
      - id: check-added-large-files
      - id: detect-private-key      # 키 커밋 방지 ★

rev는 pre-commit autoupdate로 최신 태그에 맞춰 갱신할 수 있습니다.

훅은 우회 가능하다는 점(--no-verify)을 기억하세요. 훅은 편의 장치이고, 진짜 강제는 CI와 브랜치 보호 규칙으로 해야 합니다.

더 깊이

COMMENTS (…)

댓글을 불러오는 중이에요.

NEW COMMENT0 / 1000
⌘↵ 전송

blog92@web:~$ cd ..