본문 바로가기
빌드노트 검색

빌드노트

git diff --name-only가 한글 파일명을 따옴표와 숫자로 출력할 때

git diff --name-only의 한글·특수문자 인용 출력을 실제 경로로 쓰면 파일 조회가 실패할 수 있다. Windows와 WSL에서 core.quotePath=false의 한계와 -z의 NUL 구분 출력을 비교하고, 바이트로 받은 목록을 Python에서 소비하는 방법을 설명한다.

git diff --name-only가 내보낸 한 줄을 파일 경로로 사용했는데, 실제로 있는 한글.txt를 찾지 못할 수 있다. 출력이 "\355\225\234\352\270\200.txt"라면 Git이 파일명을 인용해 표시한 것이다. 자동화에서 사용할 경로는 --name-only -z로 받고 NUL 바이트로 나눈다. 화면에 보이는 따옴표를 지워 실제 이름을 복구하려고 할 필요가 없다.

파일이 바뀐 것이 아니라 표시가 달라졌다

먼저 문제가 난 저장소의 루트에서 아래 두 명령을 비교한다. Git이 실행되는 PowerShell이나 Bash에서 사용할 수 있다. 두 명령은 같은 변경을 조회하며, 둘째 명령의 -c는 그 호출에만 설정을 전달한다. Git의 명령별 설정 옵션 (새 탭에서 열림)

git diff --name-only --
git -c core.quotePath=false diff --name-only --

이번에는 Windows 11의 새 독립 저장소에 plain.txt, has space.txt, 한글.txt를 만들었다. 내용이 before인 상태를 인덱스에 추가하고 작업 파일만 after로 수정했다. 따라서 위 명령은 인덱스와 작업 파일 사이의 변경을 본다. 아래 출력은 실제 업무의 장애 로그가 아니라 Git 2.45.1.windows.1에서 따로 재현한 결과다.

기본 설정인 첫 명령에서는 다음 세 줄이 나왔다.

has space.txt
plain.txt
"\355\225\234\352\270\200.txt"

마지막 줄의 숫자는 한글 이름을 이루는 바이트를 8진수 escape로 표시한 것이다. 따옴표와 역슬래시까지 포함한 출력 문자열을 그대로 경로로 조회하자 파일을 찾지 못했다. 같은 조회에서 앞의 두 파일은 찾았다. 특히 공백만 있는 has space.txt는 인용되지 않았다. 출력이 셸의 인자 목록이라고 생각해 공백으로 나누면 이 이름도 둘로 갈라진다.

core.quotePath=false를 준 둘째 명령에서는 마지막 줄이 한글.txt로 바뀌었고, 그 줄을 UTF-8로 읽어 경로로 사용하면 파일을 찾았다. 자기 결과도 이렇게 달라진다면 경로를 고치는 대신 목록을 읽는 코드부터 확인한다. 변경 목록이 아예 비었다면 인용 문제를 관찰한 것이 아니다. 일반 git diff가 보는 작업 파일 변경이 있는지 먼저 확인한다. stage된 변경을 조회하는 목적이었다면 두 비교 명령에 모두 --cached를 넣어야 한다. git diff의 비교 대상 (새 탭에서 열림)

한글이 보이게 됐다고 해서 줄별 출력을 모든 파일명에 사용할 수 있는 것은 아니다. core.quotePath 공식 설명 (새 탭에서 열림)에 따르면 기본값은 true이며, false로 바꿔도 큰따옴표·역슬래시·제어문자는 계속 escape한다. 보통 공백은 이 인용의 대상이 아니다.

이를 비교하기 위해 WSL의 Ubuntu 24.04에서 탭·개행·큰따옴표·역슬래시가 들어간 이름도 추가했다. Git 2.43.0에서 core.quotePath=false를 주어도 탭과 개행이 든 두 파일은 아래처럼 나왔다. 일곱 출력 중 두 줄만 발췌했다.

"has\ttab.txt"
"has\nline.txt"

여기서 \t와 \n은 Git의 출력 표기다. 실제 이름에는 각각 탭과 개행 문자가 하나씩 들어 있다. 두 줄을 문자 그대로 경로로 조회하면 둘 다 실패했다. 바깥 따옴표만 지워도 내부 escape는 남는다. 이 실험의 WSL 경로는 D: 드라이브를 연결한 DrvFs 마운트였으며, Windows용 파일 API에서도 같은 이름을 사용할 수 있다고 확인한 것은 아니다.

-z를 붙인 뒤에는 줄 대신 NUL로 나눈다

-z는 단순히 한글을 보기 좋게 만드는 옵션이 아니다. --name-only와 함께 쓰면 경로를 인용·escape하지 않고 각 경로 끝에 NUL, 즉 값이 0인 바이트를 붙인다. git diff의 -z 계약 (새 탭에서 열림)에 맞춰 소비하는 쪽도 경계를 바꿔야 한다. -z만 추가하고 기존의 줄 나누기나 공백 나누기를 유지하면 파일 목록을 제대로 복원할 수 없다.

아래 Python 3 코드는 현재 저장소의 변경 목록을 바이트로 받아 NUL로 나눈 뒤, 각 경로에 실제 파일이 있는지 확인한다. 저장소 루트에서 실행하는 Python 코드다. Python에서 git을 찾을 수 있어야 한다. 파일명의 UTF-8 인코딩을 전제로 하며, Windows에서는 Python 3.12.10, WSL에서는 Python 3.12.3으로 실행했다.

import subprocess
from pathlib import Path

raw = subprocess.run(
    ["git", "diff", "--name-only", "-z", "--"],
    check=True, stdout=subprocess.PIPE,
).stdout

for item in raw.split(b"\0"):
    if not item:
        continue
    name = item.decode("utf-8")
    print(repr(name), Path(name).is_file())

Windows의 세 파일에서는 다음 결과가 나왔다.

'has space.txt' True
'plain.txt' True
'한글.txt' True

subprocess의 출력에 텍스트 모드를 지정하지 않아 raw는 바이트다. 먼저 b"\0"로 경계를 나누고, 그 안의 경로만 디코딩했다. 마지막 NUL 뒤의 빈 조각은 건너뛴다. 출력 스트림의 인코딩·줄바꿈 변환을 생략하는 동작은 Python subprocess 문서 (새 탭에서 열림)에 설명돼 있다.

출력에 다시 따옴표가 보이는 것은 마지막 줄에서 진단용으로 사용한 Python의 repr() 때문이다. Path()에 넘기는 name에는 그 표시용 따옴표가 없다. True는 해당 경로가 현재 일반 파일로 확인됐다는 뜻이다. False가 나온다면 삭제된 파일이 목록에 포함됐는지, 루트에서 실행했는지부터 확인한다. diff 목록에는 삭제된 경로도 들어갈 수 있으므로 모든 항목이 현재 존재한다고 가정하면 안 된다.

같은 코드를 WSL의 일곱 파일에도 실행하자 모두 True였다. 탭·개행은 repr() 화면에 \t, \n으로 보이지만 경로 안에서는 원래 문자로 유지됐다. 별도의 내용 읽기에서도 일곱 파일 모두 수정 후의 after를 읽었다. 두 환경 모두 core.quotePath를 생략하거나 명령별로 true·false를 줘도 -z 출력 바이트는 같았다.

경계와 인코딩은 따로 결정한다

-z는 파일명의 문자 인코딩까지 지정하지 않는다. 위 코드의 decode("utf-8")는 이번에 만든 이름과 확인한 환경에 맞춘 선택이다. Git 공식 문서도 내부 경로를 NUL이 아닌 바이트의 열로 다룬다는 점 (새 탭에서 열림)과 플랫폼 사이의 인코딩 제약을 설명한다. 다른 인코딩의 경로에서 디코딩 오류가 나면 대체 문자로 억지 변환하지 말고, 그 저장소와 파일 API가 사용하는 인코딩부터 확인한다.

이번 검증은 Windows의 NTFS에서 한글·공백 이름 세 개, WSL의 DrvFs에서 그 이름과 제어문자 등을 합친 일곱 개를 대상으로 했다. Linux의 ext4, macOS, UTF-8이 아닌 경로 바이트, --name-status처럼 상태 필드까지 들어가는 출력은 실행 비교하지 않았다. 파일 목록을 받는 다른 도구에 연결할 때도 NUL 경계를 그대로 받을 수 있는지 확인한다. 사람이 읽을 때의 core.quotePath=false와, 프로그램이 경로를 받을 때의 --name-only -z를 목적에 맞게 선택하면 된다.

← 전체 글 보기