배경

개인 프로젝트를 오픈소스로 공개하려는데, 커밋 히스토리에 개인정보가 남아있는 경우가 있다.

처음부터 오픈소스를 염두에 두고 만든 프로젝트라면 문제가 없겠지만, 원래 개인용으로 시작한 프로젝트는 사정이 다르다. 기획 문서, 혹은 주석에 습관처럼 적어둔 이름과 날짜 같은 것들이 히스토리 곳곳에 묻혀있다.

// 메시지 예시) 박현호 2023.05.12 페이커 파이팅!
function parseMessage(raw: string): Message {
  // ...
}

이런 주석은 현재 코드에서 지우면 그만이지만, Git 히스토리에는 여전히 남아있다. git log -p로 과거 커밋을 뒤져보면 전부 보인다.

나도 kakao-history-packager를 오픈소싱하면서 이 상황을 마주했다. 로컬 Git으로만 관리하던 프로젝트라 기획 문서나 개인정보를 별 생각 없이 커밋해뒀고, 주석에도 이름을 넣어두는 습관이 있었다. 코드에서 지우는 건 쉽지만, 히스토리까지 깨끗하게 만들려면 단순한 커밋으로는 불가능하다.

이때 발견한 것이 git filter-repo다.

git filter-repo란

git filter-repo는 Git 히스토리를 다시 쓰는(rewrite) 도구다. 특정 파일을 모든 커밋에서 삭제하거나, 텍스트를 치환하거나, 경로를 재배치하는 등의 작업을 전체 히스토리에 걸쳐 일괄 수행한다.

Git에는 원래 git filter-branch라는 내장 명령이 있었다. 하지만 이 명령은 느리고, 사용법이 복잡하며, 예상치 못한 부작용이 발생하기 쉬웠다. Git 공식 문서에서도 filter-branch 대신 git filter-repo를 사용하라고 권장하고 있다.

인용

git filter-branch has a plethora of pitfalls… Please use an alternative history filtering tool such as git filter-repo.
- git-filter-branch Documentation

git filter-repo의 주요 장점:

  • 속도 - filter-branch 대비 10배 이상 빠르다
  • 안전성 - 원본을 건드리기 전에 fresh clone을 요구하여 실수를 방지한다
  • 간결한 인터페이스 - 복잡한 쉘 스크립트 없이 플래그 하나로 대부분의 작업이 가능하다

설치

Python 3.6 이상이 필요하다.

# pip
pip install git-filter-repo
 
# macOS (Homebrew)
brew install git-filter-repo

설치 후 git filter-repo --version으로 확인한다.

사용법

사전 준비 - fresh clone

git filter-repo는 기본적으로 fresh clone(방금 clone한 상태)에서만 동작한다. 기존 작업 디렉토리에서 실행하면 거부된다.

git clone https://github.com/user/repo.git repo-clean
cd repo-clean

주의

반드시 원본 레포의 백업을 유지한 상태에서 작업한다. 히스토리 rewrite는 되돌리기 어렵다.

텍스트 치환 - --replace-text

히스토리 전체에서 특정 문자열을 치환한다. expressions 파일을 만들어서 사용한다.

expressions.txt
박현호==>AUTHOR
my-secret-api-key==>REDACTED
git filter-repo --replace-text expressions.txt

각 줄의 형식은 원본==>대체다. 이 명령을 실행하면 모든 커밋의 모든 파일에서 해당 문자열이 치환된다.

정규식도 사용할 수 있다:

expressions.txt
regex:박현호\s*\d{4}==>AUTHOR
regex:// .+ \d{4}\.\d{2}\.\d{2}==>// (redacted)

regex: 접두사를 붙이면 해당 줄을 정규식으로 해석한다.

파일 삭제 - --path--invert-paths

특정 파일이나 디렉토리를 히스토리에서 완전히 제거한다.

# 특정 파일 삭제
git filter-repo --path docs/planning.md --invert-paths
 
# 특정 디렉토리 삭제
git filter-repo --path secrets/ --invert-paths
 
# 여러 경로를 한 번에
git filter-repo --path .env --path config/credentials.json --invert-paths

--path는 유지할 경로를 지정하는 것이 기본 동작이다. --invert-paths를 붙이면 반대로 해당 경로를 제거한다.

파일명 패턴 - --filename-callback

파일명 기반으로 더 유연한 필터링이 필요할 때 Python 콜백을 사용한다.

# 특정 확장자를 가진 파일 전부 삭제
git filter-repo --filename-callback '
    if filename.endswith(b".secret"):
        return None
    return filename
'

커밋 메시지 수정 - --message-callback

커밋 메시지에 남아있는 민감 정보도 정리할 수 있다.

git filter-repo --message-callback '
    return message.replace(b"박현호", b"author")
'

조합 사용

여러 옵션을 동시에 적용할 수도 있다.

git filter-repo \
    --replace-text expressions.txt \
    --path docs/internal/ --invert-paths

주의사항

remote가 삭제된다

git filter-repo는 실행 후 remote 설정을 자동으로 삭제한다. 이는 의도된 동작으로, 실수로 rewrite된 히스토리를 원본 remote에 push하는 것을 방지하기 위해서다.

작업 후 remote를 다시 추가해야 한다:

git remote add origin https://github.com/user/repo.git

force push가 필요하다

히스토리가 완전히 다시 쓰여졌으므로, 일반 push로는 반영할 수 없다. --force가 필수다.

git push origin --force --all
git push origin --force --tags

협업 레포에서는 신중해야 한다

당연하겠지만..,,

GitHub 캐시

GitHub에 이미 push된 커밋은 히스토리에서 지워도 SHA로 직접 접근하면 한동안 캐시가 남아있을 수 있다. 완전한 삭제가 필요하면 GitHub Support에 캐시 삭제를 요청해야 한다.

나의 경우 한 번도 Remote에 올린 프로젝트가 아니라 상관 없었다.

--force 플래그

fresh clone이 아닌 기존 레포에서 실행해야 하는 상황이라면 --force 플래그로 제한을 우회할 수 있다. 하지만 원본 백업 없이 이렇게 하면 문제가 생겼을 때 복구가 불가능하므로, 가능하면 fresh clone에서 작업하는 것을 권장한다.

대안 비교

git filter-repogit filter-branchBFG Repo-Cleaner
속도빠름느림빠름
Git 공식 권장OX (deprecated)X
설치pip/brew내장 (구버전)Java 필요
텍스트 치환O쉘 스크립트로 가능O
파일 삭제OOO
유지보수활발중단비활발

BFG Repo-Cleaner는 대용량 파일 제거나 비밀번호 치환에 특화된 도구다. 단순한 작업에는 여전히 유효하지만, 텍스트 치환이나 경로 기반 필터링 같은 세밀한 작업에서는 git filter-repo가 더 유연하다.

결론적으로, 2024년 이후 기준으로 히스토리 정리가 필요하다면 git filter-repo가 사실상 유일한 선택지다.