[Git CI] "Permission denied"로 임시 디렉터리 정리가 안 되던 문제 — 그리고 그 뒤에 숨어있던 진짜 원인
상황
dev로 push할 때마다 pre-receive 훅이 CI(pytest/mypy)를 돌리는데, 검사가 끝난 임시 디렉터리를 정리하는 단계에서 계속 이런 에러가 났다.
remote: [CI] ▶ 임시 디렉터리 정리 중...
remote: rm: cannot remove '.../_ci_worktrees/<커밋해시>/.pytest_cache/README.md': Permission denied
remote: rm: cannot remove '.../_ci_worktrees/<커밋해시>/.pytest_cache/.gitignore': Permission denied
remote: rm: cannot remove '.../_ci_worktrees/<커밋해시>/.pytest_cache/CACHEDIR.TAG': Permission denied
지금 계정 권한으로는 삭제가 안 돼서, 매번 서버에 SSH로 접속해 sudo rm으로 지워주고 있었다. 이걸 계속 수작업으로 해야 하나 싶어서 원인을 짚어봤다.
원인 — 정리하는 계정과 파일을 만든 계정이 다름
pre-receive 훅에서 pytest/mypy는 docker exec로 컨테이너 안(대개 root)에서 실행된다. 그 과정에서 생성되는 .pytest_cache/* 같은 파일들은 컨테이너 쪽 소유(root)로 호스트 마운트 경로에 남는다.
그런데 정리(rm -rf)는 push를 받는 호스트 쪽 계정으로 실행됐다. 파일을 만든 쪽(컨테이너, root)과 지우는 쪽(호스트 계정)이 달라서, 호스트 계정에는 그 파일들을 지울 권한이 없었던 것. 매번 sudo로 지워야 했던 이유가 이거였다.
해결 — 정리도 컨테이너 안에서
파일을 만든 쪽이 직접 지우면 권한 문제가 없다. cleanup_wt() 같은 헬퍼 함수를 만들어서, 컨테이너가 떠 있으면 정리도 docker exec로 컨테이너 안에서 먼저 지운 뒤 호스트 쪽 잔여물을 정리하도록 훅 스크립트를 고쳤다.
cleanup_wt() {
if [ "$CONTAINER_RUNNING" = "1" ]; then
docker exec "$CONTAINER" rm -rf "$CONTAINER_WT_DIR" 2>/dev/null
fi
rm -rf "$WT_DIR"
}
모든 정리 지점(사전 정리, archive/tar 실패 시, 컨테이너 미실행 시, 최종 정리)에 동일하게 이 헬퍼를 적용했다.
함정 1 — 훅 스크립트 수정본은 git push로 반영되지 않는다
로컬 저장소 안의 scripts/pre-receive는 훅의 원본 소스일 뿐, 실제로 실행되는 훅이 아니다. 실제 설치 위치는 NAS 안의 .../project/.git/hooks/pre-receive이고, git hook은 저장소 커밋 대상이 아니라서 push한다고 자동으로 반영되지 않는다. 파일을 직접 복사해줘야 한다.
# 1. 밀린 잔여물부터 정리 (이번 한 번만 sudo 필요 — 이미 root 소유로 남아있던 것들)
ssh <nas-host> "sudo rm -rf /volume1/docker/django/<project>-dev/_ci_worktrees/*"
# 2. 수정한 pre-receive를 NAS로 직접 복사 (git push 아님, 그냥 파일 전송)
scp scripts/pre-receive <nas-host>:/volume1/docker/django/<project>-dev/<project>/.git/hooks/pre-receive
# 3. 실행 권한 부여 (git hook은 실행 비트 없으면 아예 안 돌아감)
ssh <nas-host> "chmod +x /volume1/docker/django/<project>-dev/<project>/.git/hooks/pre-receive"
# 4. 확인
ssh <nas-host> "ls -l /volume1/docker/django/<project>-dev/<project>/.git/hooks/pre-receive"
함정 2 — Permission denied는 잡음이었고, 진짜 원인은 따로 있었다
훅을 고치고 나서도 push할 때 여전히 같은 Permission denied 로그가 찍혀서 "고친 게 반영이 안 됐나?" 싶었는데, 확인해보니 완전히 다른 문제였다.
remote: [CI] ▶ mypy 실행 중...
remote: apps/products/views.py:425: error: Argument 1 to "list" has incompatible type
"QuerySet[ModuleMaster, TypedDict(...)]"; expected "Iterable[dict[str, Any]]" [arg-type]
remote: Found 1 error in 1 file (checked 199 source files)
remote: [CI] ❌ mypy 실패 (refs/heads/master)
remote: [CI] ▶ 임시 디렉터리 정리 중...
remote: rm: cannot remove '.../.pytest_cache/README.md': Permission denied
...
remote: [CI] ❌ 검사 실패 — push가 거부되었습니다.
로그를 순서대로 보면: mypy가 실제로 실패했고, 그 다음에 오는 정리 단계에서 (아직 서버에 반영 안 된 이전 훅이 돌고 있어서) 권한 에러 로그가 또 찍힌 것뿐이었다. push를 막은 건 권한 문제가 아니라 mypy 실패였다 — FAILED 플래그는 pytest/mypy 결과로만 세팅되고, 정리 단계의 rm 에러는 그 판정에 영향을 주지 않는다.
즉 두 문제가 같은 로그 화면에 겹쳐 찍히다 보니 "권한 문제 때문에 계속 막히는구나"라고 착각하기 쉬웠던 것. 실제로는:
- 정리 실패(Permission denied) → 이미 고쳐진 문제, 서버에 새 훅을 반영하면 사라질 잡음
- push 거부의 진짜 원인 → mypy 타입 에러 (별도 코드 문제)
mypy 에러 자체는 타입 추론 불일치
원인 코드는 특정 API 뷰에서 .values("id", "name")의 반환 타입을 list[dict[str, Any]]로 미리 선언해뒀는데, django-stubs가 그 반환 타입을 TypedDict로 추론하면서 선언한 타입과 충돌한 것이었다. list[dict[str, Any]] 대신 list[Any]로 완화해서 해결했다.
정리
- CI 임시 디렉터리 정리 권한 문제: 컨테이너(root)가 만든 파일은 컨테이너 안에서 지워야 한다 — 만든 쪽과 지우는 쪽 계정을 맞추는 게 핵심
- 훅 스크립트는 git push가 아니라 직접 파일 복사(scp)로 반영해야 한다 — 저장소 안의 원본 소스와 실제 설치된 훅은 별개
- 같은 로그 화면에 여러 에러가 겹쳐 찍힐 수 있다 — 눈에 띄는 에러(권한 문제)가 항상 진짜 원인은 아니고, FAILED 판정이 어디서 세팅되는지부터 추적해야 진짜 원인을 찾을 수 있다