↓快轉到主要內容
  1. 教學文章/

Git attributes 實戰:LF/CRLF、Binary 與可審核正規化

·7 分鐘· loading · loading · ·
Git Gitattributes LF CRLF Binary Renormalize Developer-Tools
每日拍拍
作者
每日拍拍
科學家 X 科技宅宅
目錄
版本控制: Git - 本文屬於一個選集。
§ 20: 本文

一. 前言:只改一行,怎麼整份檔案都紅了?
#

拍拍君最怕看到這種 PR:功能只動三行,diff 卻有三千行。 不是程式突然長大,而是編輯器把 LF 換成了 CRLF。 更麻煩的是,兩位同事各自設定 core.autocrlf,同一份檔案在不同電腦上就有不同待遇。

解法不是叫每個人手動背一套設定,而是把哪些路徑如何處理寫進版本控制:.gitattributes。 這篇不談漂亮的 formatter,也不重講 rerere 的衝突記憶。 我們要完成一個小型遷移:訂換行契約、保留 binary 原樣、稽核正規化,最後用新 clone 證明 checkout 結果。

二. 先拆成兩層:儲存的位元組與取出的位元組
#

Git 不是單純把編輯器畫面存起來。 對啟用文字正規化的路徑,git add 可以把 CRLF 轉成 LF 再寫入 index;commit 使用 index 裡的內容。 checkout 則依路徑規則,把 blob 轉成工作樹需要的換行。

路徑規則 正規化後的 index checkout 後的工作樹
text eol=lf LF LF
text eol=crlf LF CRLF
-text 不做換行轉換 不做換行轉換
text=auto 偵測為文字才處理 另看 eol 與設定

第二列是重點:要求 Windows batch 使用 CRLF,不代表 repository blob 也必須存 CRLF。 既有已追蹤檔案可能仍保存歷史上的 CRLF;寫了規則,不會追溯修改舊 commit。 text=auto 也不能當作所有既有 blob 已經乾淨的證明。

三. 安裝與實驗環境:只在拋棄式 Repo 操作
#

需要 Git 與 Python 3;以下命令使用 Bash 或 zsh,不是 PowerShell 語法。 本文以 Git 2.50.1(Apple Git)實跑範例,通過 23 項 byte、規則優先權與撤回檢查。 Windows 可以在 Git Bash 執行,Python 命令名稱則依安裝調整。 先看版本:

git --version
python3 --version

建立新目錄;若 attributes-lab 已存在,請換一個名稱,別拿正式 repo 練習。

mkdir attributes-lab
cd attributes-lab
git init -b main
git config user.name "拍拍君"
git config user.email "pypy@example.invalid"
git config core.autocrlf false
git config core.eol lf
git config core.safecrlf false

這些都是 repo-local 設定,目的是控制實驗條件,不是叫你關閉團隊所有換行檢查。 core.safecrlf 可以檢查某些不可逆換行轉換,但不是完整的檔案完整性驗證器。 若它擋住正式遷移,先理解警告,不要直接改全域設定。

用 Python 寫出確定的位元組,避免編輯器偷偷幫忙:

python3 - <<'PY'
from pathlib import Path
Path("src").mkdir()
Path("assets").mkdir()
Path("src/app.py").write_bytes(b'print("pypy")\r\n')
Path("run.cmd").write_bytes(b'@echo off\r\necho pypy\r\n')
Path("assets/sample.bin").write_bytes(b'\x00PYPY\r\n\xff')
Path("README.md").write_bytes(b'# Pypy\n')
PY
git add .
git commit -m "record legacy bytes"

這個起點故意保存 CRLF blob;sample.bin 裡的 CRLF 是資料,不該被改掉。 檢查目前兩層狀態:

git ls-files --eol

此時 src/app.py 與 run.cmd 都應出現 i/crlf w/crlf。 i/ 代表 index,w/ 代表 working tree;兩個欄位不是同義詞。

四. 寫一份小而明確的路徑契約
#

在 repo 根目錄建立:

cat > .gitattributes <<'EOF'
* text=auto
.gitattributes text eol=lf
*.py text eol=lf
*.md text eol=lf
*.sh text eol=lf
*.cmd text eol=crlf
*.bat text eol=crlf
*.png binary
*.zip binary
*.bin binary
EOF

這份規則採取「未知檔案先自動判斷,已知格式明確指定」的策略。 若團隊要求所有偵測為文字的檔案在工作樹也用 LF,可改成 * text=auto eol=lf,再用後面的 batch 規則覆蓋。

幾個容易混淆的差別:

  • text 強制當文字;不要把它無差別套在所有路徑。
  • -text 禁止換行轉換,但不等於關閉文字 diff 或 merge。
  • binary 是內建 macro,等價於 -diff -merge -text。
  • -merge 不會替你安全合併 binary;衝突仍可能需要人工選擇。

binary 也不是 LFS,更不是忽略檔案;它不會縮小 repository 或停止追蹤。 SVG、JSON、lockfile 通常是有意義的文字,別只因它們是「產出檔」就一律標 binary。

五. check-attr 看政策,ls-files --eol 看現況
#

檔名看起來符合規則,不代表最後生效值就是你想的。 先問 Git:

git check-attr text eol diff merge -- src/app.py run.cmd assets/sample.bin
git check-attr --all -- assets/sample.bin

對這個範例,應看到:

src/app.py: text: set
src/app.py: eol: lf
run.cmd: text: set
run.cmd: eol: crlf
assets/sample.bin: text: unset
assets/sample.bin: diff: unset
assets/sample.bin: merge: unset

這是挑選過的輸出;實際命令還會列出未指定的欄位。 set、unset、unspecified 是不同狀態,後者不等於強制關閉。 若某 attribute 被指定字串,例如 eol=lf,輸出的是該字串。

規則為什麼被蓋掉?
#

同一檔案中,後面匹配的規則可以逐個 attribute覆蓋前面的規則。 子目錄的 .gitattributes 對該目錄範圍有更近的優先權;.git/info/attributes 的優先權更高,但它不會隨 clone 分享。 全域與系統 attributes 則是較低優先層。

git config --show-origin --get-all core.attributesFile
git config --show-origin --get-all core.autocrlf
git check-attr --cached text eol -- src/app.py

第一個命令在沒有設定時可能回傳非零狀態,這不代表 repo 壞了。 --cached 讀 index 版 attributes;尚未 git add .gitattributes 時,它可以和工作樹查詢不同。

另外,attributes 不是 .gitignore:不支援用 !pattern 表示否定路徑;!text 則是把 attribute 重設為 unspecified 的另一回事。 要遞迴指定資料夾內容,使用 fixtures/** -text,不要期待 fixtures/ -text 自動套到所有子檔案。

六. 正規化:獨立 Commit,別混著修功能
#

先確認工作樹只有這次預期的新 attributes。 正式專案則應從乾淨分支開始,先提交或另外保存原有工作。

git status --short
git add .gitattributes
git add --renormalize .
git diff --cached --stat
git ls-files --eol

--renormalize 重新對已追蹤檔案套用 clean 處理,隱含 -u;它不會幫你加入任意 untracked files。 所以新的 .gitattributes 必須單獨加入。 而且 clean 處理不只換行:若專案還設定了自訂 filter,也要先檢查其行為與工具版本。

在範例裡,src/app.py 應變成 i/lf w/crlf;run.cmd 則是 i/lf w/crlf。 第一個工作樹尚未重寫,第二個工作樹本來就符合 CRLF 契約。 git add --renormalize 更新 index,不保證立刻替你重寫工作樹。

七. 不只藏起空白:真的稽核位元組
#

先做一般 review:

git diff --cached -- .gitattributes
git diff --cached --numstat
git diff --cached --ignore-cr-at-eol -- src/app.py run.cmd
git diff --cached --check

忽略 CR 的 diff 沒東西,只能當輔助訊號。 不要把 -w 當成「肯定沒有功能變更」:它可能一起隱藏具有語意的空白。 對這個教學 repo,再跑一個有範圍限制的 byte audit:

python3 - <<'PY'
import subprocess

def git(*args):
    return subprocess.check_output(["git", *args])

expected = {b".gitattributes", b"src/app.py", b"run.cmd"}
changed = set(git("diff", "--cached", "--name-only", "-z").split(b"\0")) - {b""}
if changed != expected:
    raise SystemExit(f"unexpected staged paths: {changed!r}")
for path in ("src/app.py", "run.cmd"):
    before = git("show", f"HEAD:{path}")
    after = git("show", f":{path}")
    if after != before.replace(b"\r\n", b"\n") or b"\r" in after:
        raise SystemExit(f"not a pure CRLF-to-LF change: {path}")
before_binary = git("show", "HEAD:assets/sample.bin")
after_binary = git("show", ":assets/sample.bin")
if before_binary != after_binary:
    raise SystemExit("binary bytes changed")
print("PASS: only declared EOL changes; binary unchanged")
PY

這不是通用換行修復程式,而是本次小型遷移的精確驗收條件。 真實專案要先盤點並明列預期路徑;新增、刪除、rename、submodule、filter 與編碼轉換都應另訂規則。 單獨的 CR 不會被 --renormalize 自動當成 CRLF 清掉,混合換行也值得先人工確認。

確認後才提交:

git commit -m "define attributes and normalize tracked text"

八. 新 Clone 驗證:不要拿舊工作樹猜 Checkout
#

舊檔案還在磁碟上,不能用它證明未來 checkout 的結果。 在實驗 repo 裡建立一份新的本機 clone;明確設定 clone 時的 Git 選項:

git -c core.autocrlf=false -c core.eol=lf clone --no-hardlinks . ../attributes-check

這不會修改全域設定;以下直接用 Python 比較新 clone 與原 repo 的 staged blob:

python3 - <<'PY'
from pathlib import Path
import subprocess
root = Path("../attributes-check")
assert (root / "src/app.py").read_bytes() == b'print("pypy")\n'
assert (root / "run.cmd").read_bytes() == b'@echo off\r\necho pypy\r\n'
assert (root / "assets/sample.bin").read_bytes() == b'\x00PYPY\r\n\xff'
blob = subprocess.check_output(["git", "show", "HEAD:run.cmd"])
assert blob == b'@echo off\necho pypy\n'
print("PASS: LF blob, path-specific checkout, binary preserved")
PY
git -C ../attributes-check ls-files --eol

應得到 Python 的 i/lf w/lf,以及 batch 的 i/lf w/crlf。 這裡是同一平台上的 byte-level 實驗,不代表已跑過 Windows、macOS、Linux 三套完整 CI。 正式專案仍應在真正支援的平台執行測試,特別是 shell script、打包工具與第三方讀檔程式。

九. 想撤回?先辨認你在哪個階段
#

還沒 commit:先備份你想保留的 .gitattributes,再針對本次路徑取消 staging。

git restore --staged -- .gitattributes src/app.py run.cmd

這不會刪掉工作樹的新 attributes,也不會把檔案內容復原。 若想完全取消政策,把新檔移出 repo 保存;若 attributes 原本就存在,應還原它的原始內容,而不是整份移走。 再檢查 git status、git check-attr 與工作樹位元組,避免「取消 staging 了,規則卻還生效」。

已經共享 commit:以新的 revert commit 撤銷,不重寫大家的歷史。 假設正規化就是最新且獨立的 commit,在乾淨工作樹執行:

git revert HEAD

有後續 commit 時,先找出正確的正規化 commit ID,不能照抄 HEAD。 revert 後同樣檢查 attributes 與實際 bytes;遇到衝突需要 review,不能只看退出碼。 復原工具的完整區分可看 Git reset、restore、revert 實戰。

十. 團隊導入:讓政策能被重現
#

拍拍君會把這次 PR 切成單一換行遷移,並在描述裡留下四份證據:

  1. 規則:哪些格式固定 LF、哪些工作樹用 CRLF、哪些不轉換。
  2. 範圍:預期變更清單,排除其他功能修改。
  3. 稽核:blob bytes、binary hash 或精確比較,以及 staged diff。
  4. 重現:乾淨 clone 的 checkout 與平台測試結果。

編輯器可以用 .editorconfig 配合,但它不是 Git attributes 的替代品。 Pre-commit hooks 可以檢查檔案品質,但也不能代替 checkout 的路徑契約。 若還有 Git LFS、加解密或自訂 clean/smudge filter,先在獨立 repo 驗證它們和換行的交互作用,再安排遷移。

結語
#

.gitattributes 最有價值的地方,是讓「我的電腦剛好沒問題」變成「這個路徑有可驗證的規則」。 記住三件事:index 與工作樹分開看、binary 位元組不亂動、正規化獨立提交並稽核。 下次整份檔案突然變紅,先別怪同事;打開 check-attr 與 ls-files --eol,讓 Git 把政策和現況都交代清楚。

延伸閱讀
#

版本控制: Git - 本文屬於一個選集。
§ 20: 本文

相關文章

Git maintenance 實戰:用 Commit-Graph、MIDX 與排程加速大型 Repo
·10 分鐘· loading · loading
Git Git Maintenance Commit-Graph Multi-Pack-Index Performance Developer-Tools
Git sparse-checkout 實戰:大型 Monorepo 的部分取出與安全切換
·7 分鐘· loading · loading
Git Sparse-Checkout Monorepo Cone Mode Partial Clone Worktree Developer-Tools
Git Changelog 自動化:從 Commit、PR 到 Release Notes
·5 分鐘· loading · loading
Git GitHub Changelog Release Notes Automation GitHub Actions Developer-Tools
Git tag 與 Release 實戰:版本標記、SemVer 與安全發佈流程
·8 分鐘· loading · loading
Git Tag Release SemVer Versioning GitHub Developer-Tools
Git rerere 實戰:記住衝突解法,讓 Rebase 與 Merge 不再重做
·8 分鐘· loading · loading
Git Rerere Merge-Conflict Rebase Merge Version-Control Developer-Tools
Git Partial Clone 實戰:blob:none、按需下載與離線驗證
·8 分鐘· loading · loading
Git Partial Clone Blob:none Promisor Remote Lazy Fetch Offline Developer-Tools