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 상태를 읽으라고 시켰는가?