[에러 해결] [Git] git-filter-repo로 파일 영구 삭제 안 되는 원인과 완벽 해결 방법

Git 프로젝트를 진행하다 보면 예상치 못한 파일이 저장소에 커밋되어 난감한 상황에 처할 때가 있습니다. 특히 .gitignore 파일에 명시되었어야 할 파일들이 실수로 Git 추적 대상에 포함되어 버리면, 나중에 아무리 .gitignore에 추가해도 Git은 이미 추적 중인 파일들을 무시하지 않습니다. 이럴 때 Git 저장소의 기록에서 해당 파일들을 영구적으로 삭제하기 위해 git-filter-repo와 같은 강력한 도구를 사용하게 됩니다.

하지만 이 Stack Overflow 질문의 개발자처럼, git-filter-repo를 이용한 스크립트를 실행해도 파일들이 완전히 사라지지 않고 다시 나타나는 문제에 부딪히는 경우가 있습니다. “분명 삭제했다고 했는데, 다시 실행하면 똑같은 파일 목록이 나와요!” 이러한 답답한 상황의 원인과 완벽한 해결 방법을 자세히 알아보겠습니다.

1. 에러 발생 상황

문제는 개발자가 Git 저장소에서 특정 파일들을 영구적으로 제거하기 위해 Python 스크립트를 사용했을 때 발생했습니다. 이 스크립트는 .gitignore에 명시된 파일들을 찾아 git-filter-repo 명령어를 이용해 Git 기록에서 삭제하는 기능을 수행합니다.


#!/usr/bin/env python3
import os
import subprocess
import sys
import pathspec

def get_current_origin(target_dir):
    """Safely retrieves the remote origin URL if it exists."""
    try:
        result = subprocess.run(
            ["git", "remote", "get-url", "origin"],
            check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, cwd=target_dir
        )
        return result.stdout.strip()
    except subprocess.CalledProcessError:
        return None # No remote origin configuration set up

def get_ignored_paths(target_dir):
    gitignore_path = os.path.join(target_dir, '.gitignore')
    if not os.path.exists(gitignore_path):
        print(f"Error: No .gitignore file found in {target_dir}")
        sys.exit(1)
        
    with open(gitignore_path, 'r') as f:
        lines = f.read().splitlines()
        
    spec = pathspec.PathSpec.from_lines('gitwildmatch', lines)
    ignored_paths = []
    
    for root, dirs, files in os.walk(target_dir):
        if '.git' in dirs:
            dirs.remove('.git')
            
        for name in files + dirs:
            full_path = os.path.join(root, name)
            rel_path = os.path.relpath(full_path, target_dir)
            
            if rel_path == '.':
                continue
                
            if spec.match_file(rel_path):
                if os.path.isdir(full_path) and not rel_path.endswith('/'):
                    rel_path += '/'
                ignored_paths.append(rel_path)
                
    return sorted(list(set(ignored_paths)))

def run_filter_repo():
    if len(sys.argv) < 2:
        print("Usage: ./purge_external.py /path/to/your/git/project")
        sys.exit(1)
        
    target_dir = os.path.abspath(sys.argv[1])
    
    if not os.path.exists(os.path.join(target_dir, '.git')):
        print(f"Error: {target_dir} is not a valid Git repository.")
        sys.exit(1)
        
    paths_to_remove = get_ignored_paths(target_dir)
    
    if not paths_to_remove:
        print("No files or folders currently found matching your .gitignore rules.")
        return

    # STEP 1: Grab the remote origin URL before git-filter-repo destroys it
    origin_url = get_current_origin(target_dir)

    print(f"Target Project: {target_dir}")
    if origin_url:
        print(f"Detected Remote Origin: {origin_url}")
    
    print("\nThe following paths match your .gitignore and will be PERMANENTLY deleted from history:")
    for p in paths_to_remove:
        print(f"  - {p}")
        
    confirm = input("\nAre you absolutely sure you want to purge these? (yes/no): ")
    if confirm.lower() != 'yes':
        print("Operation cancelled.")
        return

    # STEP 2: Execute git-filter-repo with the --force flag included
    cmd = ["git", "filter-repo", "--invert-paths", "--force"]
    for path in paths_to_remove:
        cmd.extend(["--path", path])
        
    print("\nRunning git-filter-repo...")
    try:
        subprocess.run(cmd, check=True, cwd=target_dir)
        print("Success! History rewritten locally.")
        
        # STEP 3: Automatically rebuild and restore the origin remote configuration
        if origin_url:
            print(f"Restoring remote origin connection to: {origin_url}")
            subprocess.run(["git", "remote", "add", "origin", origin_url], check=True, cwd=target_dir)
            print("Origin successfully restored.")
            print("\n👉 Next Step: Run 'git push origin --force --all' to sync changes with your remote server.")
        else:
            print("\nNo remote origin was set up previously. No restoration needed.")
            
    except subprocess.CalledProcessError as e:
        print(f"\nError encountered during execution: {e}")

if __name__ == "__main__":
    run_filter_repo()

개발자는 스크립트를 실행했을 때, 처음에는 무시된 파일 목록을 보여주고 삭제를 확인한 후, “삭제되었다”고 출력하는 것을 확인했습니다. 하지만 동일한 저장소에서 스크립트를 다시 실행하면, 이전에 삭제되었다고 주장한 파일들이 다시 목록에 나타나는 문제가 반복되었습니다. 또한, 이 파일들이 재생성될 때 Git의 스테이징 영역에서도 다시 나타나는 것을 발견했습니다.

2. 명확한 발생 원인

이 문제의 핵심은 git-filter-repo의 작동 방식과 Git의 ‘작업 디렉토리’, ‘인덱스’, ‘저장소 기록’ 간의 차이점을 명확히 이해하지 못한 데 있습니다.

  • git-filter-repo의 역할: 이 명령어는 Git의 커밋 기록(commit history)을 재작성하는 강력한 도구입니다. 즉, 과거의 커밋들을 수정하여 특정 파일의 흔적을 기록에서 완전히 지우는 역할을 합니다. 하지만 git-filter-repo는 현재 사용자의 작업 디렉토리(working directory)에 있는 파일들을 자동으로 삭제하지 않습니다.
  • 스크립트의 get_ignored_paths 함수: 제공된 Python 스크립트의 get_ignored_paths 함수를 살펴보면, 이 함수는 os.walk(target_dir)를 사용하여 현재 작업 디렉토리를 탐색하고, .gitignore 규칙에 일치하는 파일들을 찾아 목록을 만듭니다.

따라서, git-filter-repo가 성공적으로 Git 기록에서 파일을 제거했더라도, 만약 해당 파일들이:

  1. 물리적으로 작업 디렉토리에 여전히 남아 있거나 (git-filter-repo가 작업 디렉토리를 정리하지 않으므로),
  2. 새로 재생성되었다면,

스크립트는 get_ignored_paths 함수를 통해 이 파일들을 ‘존재하는’ 것으로 계속 감지하고 목록에 표시하게 됩니다. 개발자가 “파일들이 재생성될 때 스테이징 영역에서도 나타난다”고 언급한 부분에서 이 원인이 명확히 드러납니다. 즉, Git 기록은 깨끗해졌을 수 있지만, 작업 디렉토리의 파일은 그대로 있거나 다시 생겨나서 혼란을 주는 것입니다.

3. 해결 방법 및 코드 예시

근본적인 해결책은 git-filter-repo를 통해 기록을 정리한 후, 작업 디렉토리 또한 깨끗하게 정리하는 것입니다. 아래 단계를 따라 문제를 해결할 수 있습니다.

3.1. 기존 스크립트 실행 후 수동으로 작업 디렉토리 정리 (권장)

먼저 기존 Python 스크립트를 실행하여 Git 기록에서 파일들을 삭제합니다. 스크립트가 성공적으로 완료된 후, Git 저장소 디렉토리로 이동하여 다음 명령어를 실행합니다.


# 1. 로컬 브랜치의 HEAD를 가장 최신 (재작성된) 커밋으로 재설정하고 작업 디렉토리 업데이트
git reset --hard HEAD

# 2. Git이 추적하지 않는 파일 및 디렉토리 강제 삭제 (.gitignore에 명시된 파일 포함)
git clean -fdx

# 3. Git이 참조하지 않는 오래된 객체를 즉시 정리 (선택 사항이지만 권장)
git gc --prune=now
git reflog expire --expire=now --all

# 4. (필수) 원격 저장소에 변경 사항 강제 푸시
#    🚨 이 명령어는 원격 저장소의 기록을 변경하므로, 다른 팀원과 협업 시 주의가 필요합니다.
#    반드시 팀원들과 상의 후 실행하거나, 개인 프로젝트에서만 사용하세요.
git push origin --force --all
  • git reset --hard HEAD: 이 명령어는 현재 브랜치의 HEAD를 가장 최근 커밋으로 리셋하고, 인덱스(staging area)와 작업 디렉토리를 해당 커밋의 스냅샷과 일치시킵니다. git-filter-repo로 인해 변경된 기록에 따라 작업 디렉토리가 동기화됩니다.
  • git clean -fdx: -f는 강제 삭제, -d는 디렉토리도 삭제, -x는 .gitignore에 의해 무시되는 파일까지 삭제합니다. 이 명령어를 사용하면 git-filter-repo가 기록에서 제거한, 그러나 작업 디렉토리에 남아있던 파일들이 완전히 사라지게 됩니다.
  • git gc --prune=now / git reflog expire --expire=now --all: 이 명령들은 Git이 더 이상 참조하지 않는 오래된 객체들을 즉시 정리하여 저장소 크기를 줄이는 데 도움을 줍니다.
  • git push origin --force --all: git-filter-repo는 저장소의 기록을 완전히 재작성했으므로, 이 변경사항을 원격 저장소에 반영하려면 반드시 강제 푸시를 해야 합니다. 이는 매우 파괴적인 작업이므로 신중하게 사용해야 합니다.

3.2. 스크립트에 작업 디렉토리 정리 로직 추가 (선택적)

Python 스크립트 내부에 git-filter-repo 실행 후 작업 디렉토리 정리 명령을 추가하여 자동화할 수도 있습니다. run_filter_repo 함수 내의 subprocess.run(cmd, check=True, cwd=target_dir) 라인 다음에 다음 코드를 추가합니다.


        # 작업 디렉토리 정리
        print("Cleaning up working directory...")
        subprocess.run(["git", "reset", "--hard", "HEAD"], check=True, cwd=target_dir)
        subprocess.run(["git", "clean", "-fdx"], check=True, cwd=target_dir)
        print("Working directory cleaned.")

        # Git 객체 정리 (선택적)
        print("Running Git garbage collection...")
        subprocess.run(["git", "gc", "--prune=now"], check=True, cwd=target_dir)
        subprocess.run(["git", "reflog", "expire", "--expire=now", "--all"], check=True, cwd=target_dir)
        print("Git objects pruned.")

이 코드를 추가하면 스크립트가 git-filter-repo 실행 후 자동으로 작업 디렉토리까지 정리해 주므로, 개발자가 수동으로 명령어를 입력할 필요가 없어집니다.

4. 향후 예방을 위한 팁

이와 같은 문제를 방지하고 Git을 더 효율적으로 사용하기 위한 몇 가지 팁입니다.

  • .gitignore 파일의 중요성: 프로젝트 시작 단계부터 .gitignore 파일을 신중하게 작성하고 관리하세요. 빌드 결과물, IDE 설정 파일, 비밀 키 파일 등 Git에 추적될 필요가 없는 파일은 초기부터 `.gitignore`에 추가해야 합니다.
  • 실수 커밋 즉시 처리: 만약 실수로 파일을 커밋했다면, 해당 파일을 `.gitignore`에 추가한 후 git rm --cached <file> 명령어로 Git 추적을 중지시키고 새 커밋을 생성하세요. 이렇게 하면 기록 재작업이라는 번거로운 과정을 피할 수 있습니다.
  • Git의 3가지 영역 이해: Git은 ‘작업 디렉토리(Working Directory)’, ‘스테이징 영역(Staging Area/Index)’, ‘로컬 저장소(Local Repository/History)’의 세 가지 주요 영역으로 나뉘어 작동합니다. 각 명령어가 어떤 영역에 영향을 미치는지 정확히 이해하는 것이 중요합니다. git-filter-repo는 주로 ‘로컬 저장소’에 영향을 미치며, ‘작업 디렉토리’는 별도로 관리해야 합니다.
  • 백업의 생활화: git-filter-repo와 같이 저장소 기록을 재작성하는 강력한 도구를 사용할 때는 항상 작업 전에 저장소 백업본을 만들어두는 습관을 들이세요. 혹시 모를 문제 발생 시 되돌릴 수 있는 안전장치가 됩니다.
  • Git 상태 확인 습관: git status, git ls-files 등의 명령어를 자주 사용하여 현재 Git의 상태를 정확히 파악하고, 불필요한 파일이 추적되고 있지 않은지 확인하는 습관을 들이세요.

git-filter-repo는 강력한 도구이지만, 그만큼 신중한 사용이 필요합니다. Git의 내부 작동 방식에 대한 이해를 바탕으로 올바르게 활용한다면, 깔끔하고 효율적인 저장소를 유지할 수 있을 것입니다.

댓글 남기기