git diff가 비었다고 새 파일도 없는 건 아니다

변경 파일 목록이 필요했다.
다음 command를 실행했다.
git diff --name-only HEAD
출력이 없었다.
“변경 파일 없음”이라고 적을 뻔했다.
그때 저장소에는 새 초안이 하나 있었다.
Git이 놓친 것이 아니었다.
내 reader가 untracked file을 읽지 않았다.
diff는 두 상태를 비교한다
현재 git-diff(1) manual은 git diff <commit> 형태를 working tree와 지정한 commit 사이의 변경을 보는 command로 설명한다.
HEAD를 주면 최신 commit과 working tree를 비교한다.
이 비교는 Git이 추적하는 path의 차이를 다룬다.
아직 index에 한 번도 들어오지 않은 untracked file은 비교할 이전 blob이 없다.
그래서 이 command는 tracked 변경을 찾는 reader로는 유용하지만 untracked 목록까지 대답하지 않는다.
git diff --name-only HEAD --
빈 출력은 “HEAD와 다른 tracked path 이름을 이 reader가 출력하지 않았다”는 뜻이다.
“working tree에 새 파일도 없다”는 뜻이 아니다.
untracked에는 다른 reader가 있다
현재 git-ls-files(1) manual은 --others를 untracked file을 출력하는 option으로 설명한다.
--exclude-standard는 repository와 사용자 설정의 표준 ignore 규칙을 적용한다.
git ls-files --others --exclude-standard
이 command는 보통 commit 후보에서 제외하기로 한 ignored file을 빼고, 아직 추적되지 않은 path를 보여 준다.
두 reader는 질문이 다르다.
git diff HEAD: 일반 tracked path가 HEAD와 어떻게 다른가?
git ls-files --others: 아직 추적되지 않은 path는 무엇인가?
이 두 scope를 함께 말하려면 두 답을 합친다.
알려진 새 파일로 틀린 검사를 깨 봤다
2026년 8월 26일의 local fixture에는 검사 전에 존재를 확인한 untracked 초안 하나가 있었다.
공개 글에서는 실제 path 대신 <known-untracked-file>로 부른다.
먼저 incomplete reader가 그 파일을 찾는다고 가정했다.
target_file=<known-untracked-file>
tracked_names=$(git diff --name-only HEAD -- "$target_file")
[[ "$tracked_names" == "$target_file" ]]
검사는 exit 1로 실패했다.
같은 path에 git status --porcelain=v1을 적용하면 ?? 상태였다.
git ls-files --others --exclude-standard는 그 path를 출력했다.
fixture가 실제로 untracked branch를 만들 수 있다는 뜻이다.
실패를 관찰하지 않았다면 빈 diff를 읽는 validator도 계속 통과했을 것이다.
두 목록을 합친다
HEAD가 존재하는 일반적인 repository에서 일반 tracked 변화와 non-ignored untracked path를 함께 보려면 다음처럼 쓸 수 있다.
{
git diff --name-only HEAD --
git ls-files --others --exclude-standard
} | sort -u
첫 reader는 기본 submodule 제외 규칙이 적용된 상태에서 HEAD와 다른 일반 tracked path를 낸다.
두 번째 reader는 표준 ignore 규칙을 적용한 untracked path를 낸다.
sort -u는 결과를 안정적으로 정렬하고 중복을 제거한다.
같은 known-untracked fixture에서 이 union은 대상 path를 포함했고 assertion은 통과했다.
bare git diff와도 구분한다
argument가 없는 git diff는 working tree와 index 사이의 차이를 보여 준다.
즉 이미 staged된 변경은 기본 diff에서 빠질 수 있다.
그래서 “현재 working tree가 HEAD에서 어떻게 달라졌는가”를 묻는 목록에는 base를 명시한다.
git diff --name-only HEAD --
이 command도 untracked file은 포함하지 않는다.
base를 명시하는 일과 untracked reader를 합치는 일은 서로 다른 보정이다.
scope를 먼저 이름 붙인다
“변경 파일”이라는 말은 생각보다 모호하다.
다음 중 무엇을 뜻하는지 정해야 한다.
unstaged tracked paths
staged tracked paths
all tracked paths different from HEAD
untracked non-ignored paths
ignored paths
submodule state changes
이 글의 union은 다음 scope를 목표로 한다.
기본 submodule 제외 규칙 아래 HEAD와 다른 일반 tracked paths
+ standard ignore rules를 적용한 untracked paths
ignored file까지 포함하는 forensic inventory가 아니다.
현재 git-diff(1)이 설명하는 기본 --ignore-submodules=all 동작에서는 submodule change가 숨겨질 수 있다.
submodule state까지 inventory하려면 그 scope를 위한 option과 reader를 별도로 검증해야 한다.
질문이 달라지면 reader도 달라져야 한다.
HEAD가 없는 저장소는 별도 경로다
아직 첫 commit이 없는 unborn branch에서는 HEAD 비교가 성립하지 않는다.
이 글의 verified fixture에는 HEAD가 있었다.
그러니 위 union을 모든 repository state에 그대로 일반화하지 않는다.
초기 저장소를 지원해야 한다면 먼저 HEAD 존재 여부를 판정하고 index와 untracked inventory를 별도 경로로 처리해야 한다.
여기서는 그 branch를 검증하지 않았으므로 구현 예시까지 넓히지 않는다.
실전 규칙
git diff --name-only의 빈 출력은 clean worktree 증명이 아니다.
tracked diff와 untracked inventory는 다른 reader다.
일반 tracked 변화와 non-ignored untracked path를 함께 주장하려면 scope를 적고 두 결과를 합친다.
known-untracked fixture로 incomplete reader를 먼저 실패시킨다.
그 뒤 union이 같은 file을 읽는지 확인한다.
Git이 아무것도 보여 주지 않았다는 말보다 먼저 물어야 한다.
내가 어떤 Git 상태를 읽으라고 시켰는가?
다음 글도 받아보세요.
글을 끝까지 읽으셨다면, 다음 글은 받은편지함이나 RSS 리더에서 만나보세요.