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

빌드노트

에디터는 CRLF인데 Git index에는 LF가 들어갈 수 있다

에디터의 CRLF 표시와 Git index의 LF 저장은 함께 성립할 수 있다. 합성 저장소에서 add 전후 바이트, text/eol 속성, 로컬 autocrlf를 비교하고 기존 CRLF 파일 하나만 정규화한 diff와 보존 범위를 확인한다.

에디터에서 CRLF로 저장한 파일이 Git에서는 LF로 보인다면, 먼저 어느 위치의 파일을 보고 있는지 나눈다. 디스크에서 편집하는 파일은 working tree에 있고, git add가 다음 커밋을 위해 준비하는 내용은 index에 있다. 줄바꿈 변환 규칙이 적용되면 두 곳의 바이트가 달라도 정상이다. 에디터 설정을 바꾸거나 저장소 전체를 다시 add하기 전에 git ls-files --eol로 이 차이부터 확인한다.

아래 결과는 운영 장애 기록이 아니라 Windows의 새 합성 저장소에서 Git 2.45.1.windows.1로 비교한 것이다. 파일 내용은 alpha, beta 두 줄이고 마지막 줄에도 줄바꿈이 있다.

i/lf와 w/crlf가 함께 나왔다

자기 저장소에서는 추적 중인 문제 파일 경로로 memo.txt를 바꾼다. 저장소 루트에서 다음 두 명령을 실행하면 파일을 수정하지 않고 줄바꿈 상태와 적용 속성을 확인할 수 있다.

git ls-files --eol -- memo.txt
git check-attr text eol -- memo.txt

실험의 출력은 다음과 같았다.

i/lf    w/crlf  attr/text eol=crlf    memo.txt
memo.txt: text: set
memo.txt: eol: crlf

i/lf는 index의 내용이 LF, w/crlf는 현재 작업 파일이 CRLF라는 뜻이다. attr/text eol=crlf는 해당 경로에 적용되는 속성이다. ls-files의 --eol 설명 (새 탭에서 열림)은 이 세 항목을 구별한다. 여기서 i/는 마지막 커밋인 HEAD를 검사한 결과가 아니다.

이 파일에는 text가 설정돼 있어 add할 때 CRLF가 LF로 정규화된다. eol=crlf는 Git이 파일을 작업 폴더로 꺼낼 때 사용할 줄바꿈을 지정한다. eol=crlf라고 해서 index에도 CRLF를 저장하는 것은 아니다. gitattributes 문서의 text·eol 설명 (새 탭에서 열림)에 해당하는 정상적인 조합이다.

출력이 다르면 같은 결론을 적용하지 않는다. i/crlf라면 index에 CRLF가 남아 있는 상태이고, w/mixed라면 작업 파일에 LF와 CRLF가 섞여 있다. attr/-text는 Git의 줄바꿈 변환을 하지 않는 경로다. 출력 자체가 없다면 경로와 추적 여부를 먼저 확인한다. 새 파일은 기본 조회에 나오지 않으며, git ls-files --eol --others --exclude-standard -- memo.txt로 따로 볼 수 있다.

add는 작업 파일을 LF로 다시 저장하지 않았다

직접 비교하려면 Git과 Python 3가 실행되는 PowerShell에서 아래 입력을 쓴다. 명령은 하나씩 성공을 확인하며 진행한다. 현재 위치 아래 고유한 이름의 새 실습 폴더를 만들고, 그 저장소의 로컬 설정만 바꾼다. 에디터의 저장 옵션이 끼어들지 않도록 Python으로 바이트를 지정한다.

$lab = New-Item -ItemType Directory -Path ("git-eol-" + [guid]::NewGuid().ToString('N'))
Set-Location $lab.FullName
git init --quiet
git config --local core.autocrlf false
python -c "from pathlib import Path; Path('.gitattributes').write_bytes(b'memo.txt text eol=crlf\n'); Path('memo.txt').write_bytes(b'alpha\r\nbeta\r\n')"
git add -- .gitattributes memo.txt

여기서 .gitattributes의 내용은 memo.txt text eol=crlf 한 줄이다. 위에서 실행한 두 조회 명령을 새 저장소에서 다시 실행하면 같은 i/lf, w/crlf 조합을 확인할 수 있다. core.autocrlf=false여도 명시적인 text 속성에 따른 변환은 적용된다.

실제 바이트도 읽었다. 셸의 텍스트 출력 변환을 거치지 않도록 Python이 작업 파일과 Git 출력을 각각 바이트로 받는다.

python -c "from pathlib import Path; import subprocess; print('worktree:', repr(Path('memo.txt').read_bytes())); print('index:', repr(subprocess.check_output(['git', 'show', ':memo.txt'])))"
worktree: b'alpha\r\nbeta\r\n'
index: b'alpha\nbeta\n'

CR은 0d, LF는 0a 한 바이트다. 작업 파일은 두 줄의 CR을 포함해 13바이트, index blob은 11바이트였다. add 전후 작업 파일의 바이트는 같았다. :memo.txt는 index의 해당 경로를 가리키는 표기 (새 탭에서 열림)이며, add 전에는 아직 이 blob이 없어 조회가 실패한다.

별도 대조군에서는 LF 입력 파일에 text eol=lf를 적용했을 때 i/lf w/lf가 나왔다. 같은 CRLF 입력에 -text를 적용한 파일은 i/crlf w/crlf로 남았다. 따라서 모든 파일이 무조건 LF로 저장된다고 생각해서도 안 된다.

eol=lf 역시 이미 존재하는 작업 파일을 즉시 고치는 명령은 아니다. 이번에는 작업 파일을 덮어쓰지 않고 새 빈 출력 폴더에 index 내용을 꺼내 비교했다. 로컬 core.autocrlf=true에서도 eol=lf 경로는 LF, eol=crlf 경로는 CRLF로 나왔다. 현재 파일 자체를 LF로 바꿔야 한다면 에디터에서 그 파일을 LF로 저장하고 w/lf를 다시 확인한다.

속성이 비어 있으면 autocrlf의 출처도 본다

check-attr의 text: unspecified는 그 경로의 text 속성이 지정되지 않았다는 뜻이다. 줄바꿈 변환이 없다는 뜻은 아니다. 이 경우에는 core.autocrlf 설정이 변환 여부에 관여한다. 해당 저장소에서 설정값과 출처를 읽는다.

git config --show-origin --get core.autocrlf

새 실습 저장소에서는 file:.git/config와 false가 나왔다. 설정이 없으면 이 조회는 출력 없이 종료 코드 1을 반환한다. 이를 false라는 출력으로 해석하지 않는다. git-config 문서 (새 탭에서 열림)에서 true는 텍스트 자동 판별과 CRLF 출력 변환을 사용하는 설정으로 설명한다.

별도 실험에서 로컬 값을 false로 두고 속성 없는 새 CRLF 파일을 add하자 index도 CRLF였다. 값을 true로 바꾼 뒤 새로 만든 같은 내용의 다른 파일을 add하자 index는 LF, 작업 파일은 CRLF였다. 다만 먼저 CRLF로 저장했던 파일은 같은 상태로 다시 add해도 index가 CRLF로 남았다. true가 사용하는 자동 판별에는 이미 CRLF로 저장된 파일을 그대로 두는 조건이 있으므로, 설정 변경만으로 기존 파일 전체가 정규화됐다고 판단할 수 없다.

팀이 공유할 규칙은 저장소의 .gitattributes에서 확인한다. 작업 파일에도 LF가 필요하면 해당 경로에 text eol=lf, CRLF가 필요하면 text eol=crlf를 선택할 수 있다. 바이트 보존이 필요한 파일은 -text 여부를 따로 정한다. 이번 관찰에서 -text 경로는 로컬 autocrlf를 true로 바꾼 뒤에도 CRLF가 보존됐다.

루트 파일에 적힌 규칙과 check-attr 결과가 다르면 해당 파일에 가까운 하위 .gitattributes와 저장소의 info/attributes도 확인한다. 속성은 여러 파일에서 합쳐지며, 우선순위 규칙 (새 탭에서 열림)에 따라 덮어써질 수 있다. check-attr는 최종 속성값을 보여 주므로 루트 파일 한 장만 읽고 적용 결과를 단정하지 않는다.

기존 CRLF blob은 확인한 파일만 다시 정규화한다

실험에서는 legacy-a.txt, legacy-b.txt를 처음에 legacy-*.txt -text로 두고 CRLF 상태로 커밋했다. 이후 그 규칙을 legacy-*.txt text eol=lf로 바꿨다. 이때 두 파일 모두 다음처럼 나왔다.

i/crlf  w/crlf  attr/text eol=lf    legacy-a.txt
i/crlf  w/crlf  attr/text eol=lf    legacy-b.txt

새 규칙은 적용됐지만 기존 index blob에는 아직 CRLF가 남아 있다. .gitattributes를 수정하는 것만으로 기존 blob까지 다시 쓴 것은 아니다. 정규화가 필요한지는 i/ 값과 적용할 규칙을 함께 보고 판단한다.

속성을 아직 stage하지 않았다면 git check-attr --cached text eol -- legacy-a.txt와 기본 조회 결과도 다를 수 있다. 이 실험의 기본 조회는 text: set, eol: lf였지만, --cached 조회는 아직 text: unset, eol: unspecified였다. --cached 옵션 (새 탭에서 열림)은 작업 폴더의 .gitattributes 대신 index에 있는 버전을 읽는다.

자기 저장소에서는 먼저 git status --short와 git diff --cached --name-only로 진행 중인 변경을 확인한다. 기존 수정은 보존하고 정규화 작업과 분리한다. --renormalize는 현재 작업 파일을 다시 index에 넣으므로, 선택 파일에 내용 수정도 있다면 그것까지 함께 stage될 수 있다.

아래는 변경할 규칙을 검토한 뒤, 이미 추적 중인 legacy-a.txt 하나만 정규화한 명령이다. 실제 적용 시에는 그 경로를 자신의 확인된 대상 파일로 바꾼다. .gitattributes에도 이번 정책 변경만 담겼는지 확인한 상태를 전제로 한다.

git add -- .gitattributes
git add --renormalize -- legacy-a.txt
git diff --cached -- legacy-a.txt
git diff --cached --ignore-cr-at-eol -- legacy-a.txt
git ls-files --eol -- legacy-a.txt legacy-b.txt

일반 diff에는 기존 두 줄의 삭제와 같은 문구 두 줄의 추가가 나왔다. CR 차이를 제외한 diff는 비었고, index와 작업 파일의 바이트를 따로 비교해 이 실험의 변경이 CR 제거뿐임을 확인했다. --ignore-cr-at-eol (새 탭에서 열림)은 줄 끝의 CR을 무시하는 보조 비교다. 전체 diff와 실제 파일 확인을 함께 한다.

i/lf    w/crlf  attr/text eol=lf    legacy-a.txt
i/crlf  w/crlf  attr/text eol=lf    legacy-b.txt

선택한 파일의 index만 LF가 됐다. 선택하지 않은 파일의 blob과 두 작업 파일의 바이트는 그대로였다. 이 결과가 보여 주듯 정규화 범위와 디스크 파일 수정은 별개다. git add의 --renormalize 설명 (새 탭에서 열림)에 따라 변환을 다시 적용하되, 대상 경로를 명시하고 stage된 결과를 검토한다. 규칙을 바꿨다는 이유만으로 저장소 전체에 이 작업을 적용할 필요는 없다.

여기서 검증한 입력은 ASCII 두 줄의 LF·CRLF 일반 파일이며, 실행 셸은 PowerShell 7.6.6, Python은 3.12.10이었다. 혼합 줄바꿈, UTF-16, 별도 clean 필터나 Git LFS가 있는 저장소는 실행 비교하지 않았다. 그런 경로의 대량 변경을 이번 결과만으로 줄바꿈 문제라고 단정하지 말고, 적용 속성과 실제 diff에서 원인을 더 좁혀야 한다.

← 전체 글 보기