Skip to content

CRLF 與 LF:換行字元差異與 Git 設定指南

CRLF 與 LF:換行字元差異與 Git 設定指南

明明只改了一行程式碼,送出 PR 卻看到整份檔案變成紅綠一片;或是同事傳來的 .sh 腳本在自己電腦上跑不起來,錯誤訊息還帶著一個看不懂的 ^M。這些狀況的源頭幾乎都是同一件事:換行字元。這篇文章會說明 CRLF 與 LF 的差異、Git 的 core.autocrlf.gitattributes 該怎麼設,以及換行已經亂掉時要怎麼一次修好。

  • LF(\n)是 Linux、macOS 與 Git 內部儲存的標準;CRLF(\r\n)是 Windows 沿用自 DOS 的格式。
  • 問題不在編輯器怎麼顯示,而在提交進倉庫的內容到底是哪一種。
  • 正解是在 repo 根目錄放一份 .gitattributes,讓所有成員與 CI 都吃同一份規則。

還不熟悉開發環境?

可以先看 前端開發工具總覽VS Code 入門教學,再回來調整換行設定。

換行字元是什麼

換行在檔案裡並不是「空白」,而是實實在在的控制字元。這兩個字元的名字來自打字機:CR(Carriage Return,回車) 是把列印頭推回該行最左邊,LF(Line Feed,換行) 是把紙往上捲一行。要換到下一行的開頭,兩個動作都得做,所以早期的電傳打字機需要 CRLF 兩個字元。

後來 CP/M(1980 年代初微電腦的主流作業系統,MS-DOS 的前身)沿用了這個組合,DOS 又沿用 CP/M,Windows 再沿用 DOS,於是 CRLF 一路留到今天。Unix 則在設計之初就認為一個字元夠用,只保留 LF,Linux 與 macOS 都是這一脈。(早期的 classic Mac OS 曾經只用 CR,Mac OS X 之後已改為 LF,現在只會在很舊的檔案裡遇到。)

名稱跳脫序列位元組主要平台
LF\n0ALinux、macOS、Git 內部儲存
CRLF\r\n0D 0AWindows、DOS,以及 HTTP、SMTP 等網路協定
CR\r0Dclassic Mac OS,已淘汰

值得一提的是,網路協定這一側反而是 CRLF 的天下:HTTP 的標頭、SMTP 的信件格式都規定以 CRLF 斷行。只是這層由函式庫處理掉了,寫應用程式時通常感覺不到。

麻煩的地方在於,編輯器一律把這三種都畫成換行,肉眼完全看不出差別。兩個檔案實際上差了幾千個位元組,畫面上卻一模一樣。

怎麼看出檔案現在是哪一種

有三種常用查法:

  1. VS Code 右下角狀態列會直接顯示 LFCRLF,點一下就能切換目前這個檔案的換行字元(詳見 VS Code 入門教學)。

  2. file 指令(macOS、Linux、Git Bash):

    bash
    file src/main.js
    # src/main.js: JavaScript source, ASCII text, with CRLF line terminators

    輸出裡有 with CRLF line terminators 就是 CRLF,沒有這段就是 LF。

  3. git ls-files --eol 最精準,因為它同時告訴您倉庫裡與工作目錄裡分別是什麼:

    bash
    git ls-files --eol src/
    # i/lf    w/crlf  attr/text=auto     src/main.js
    # i/crlf  w/crlf  attr/              src/legacy.js

三個欄位的意思是:

  • i/(index):倉庫裡存的是什麼。理想上全部都該是 lf
  • w/(working tree):您硬碟上的檔案是什麼。這一欄是 crlf 沒有關係,只要 i/lf 即可。
  • attr/:這個檔案套用到的 .gitattributes 規則,空白代表沒有任何規則管到它。

上面第二行的 i/crlf 就是典型的問題檔案:CRLF 已經被提交進倉庫了。

為什麼會出問題

整份檔案的 diff 變成全紅

這是最常見的災情。當一位 Windows 成員把 LF 檔案存成 CRLF(或反過來),Git 會認為每一行都被改過,因為每行結尾的位元組都不同。結果是:

  • PR 顯示「修改 1,200 行」,但真正的變更只有兩行,reviewer 根本找不到重點。
  • 只要兩個人同時碰同一個檔案,就會變成整檔衝突,自動合併完全派不上用場。
  • git blame 會把每一行都歸給那次換行變動的 commit,原本的作者與日期全部被蓋掉。

Shell 腳本與工具鏈壞掉

CRLF 對 JavaScript、CSS 這類檔案通常無害,但對要被直譯器執行的檔案是致命的。當 .sh 檔第一行的 shebang 結尾多了一個 \r

bash
./deploy.sh
# bash: ./deploy.sh: /bin/bash^M: bad interpreter: No such file or directory

系統把直譯器路徑讀成了 /bin/bash 加上一個控制字元,自然找不到。node_modules/.bin/ 底下的執行檔、CI 腳本、Docker 的 entrypoint 都會踩到同一顆雷。若您是用 NVM 管理 Node.js 版本,shell 初始化腳本一旦被存成 CRLF,連 nvm 指令本身都會叫不出來。

ESLint 與 Prettier 互相打架

Prettier 的 endOfLine 預設值是 lf,ESLint 也有 linebreak-style 規則。如果編輯器持續產出 CRLF,這些工具會在每一行都報一個錯,錯誤列表瞬間上千條,真正的問題全被淹沒。

Git 的 core.autocrlf

Git 提供 core.autocrlf 做自動轉換。理解它只要抓住兩個方向:

  • checkout:倉庫到您的工作目錄(git clonegit switch 時)。
  • commit:您的工作目錄到倉庫(git add 寫進索引時)。
checkout 時commit 時適用
trueLF 轉成 CRLFCRLF 轉成 LFWindows 的傳統建議
input不轉換CRLF 轉成 LFmacOS 與 Linux 的建議
false不轉換不轉換交給 .gitattributes 決定(本文推薦)

三個值的共同點是:只要不是 false提交進倉庫的文字檔就會被正規化成 LF,差別只在硬碟上的檔案長什麼樣。這裡的「文字檔」由 Git 依內容自行判斷,被判定成二進位的檔案一律不轉換。

bash
git config --global core.autocrlf true     # Windows
git config --global core.autocrlf input    # macOS / Linux
git config --global core.autocrlf false    # 交給 .gitattributes

git config core.autocrlf                  # 查目前生效的值
git config --show-origin core.autocrlf    # 順便看這個值是哪個設定檔給的

core.safecrlf

core.safecrlf 是一道保險,用來偵測「轉換後再轉回來會與原檔不一致」的情況,例如檔案本身就混用了兩種換行:

bash
git config --global core.safecrlf warn   # 只警告,仍然提交
git config --global core.safecrlf true   # 直接拒絕提交

要注意它只負責攔截與提醒,不會幫您修好檔案,真正的修復還是得靠後面的重新正規化。

core.autocrlf 的三個侷限

  1. 它是每台機器各自的設定,沒有寫進 repo。只要團隊裡有一個人忘了設,問題就會再回來。
  2. 它靠猜判斷二進位檔。Git 以內容啟發式判斷哪些是文字檔,萬一猜錯,圖片或字型被插入 \r 就直接壞掉。
  3. 它無法逐副檔名指定.sh 必須是 LF、Windows 的 .bat 最好是 CRLF,這種需求它給不出來。

.gitattributes:更好的做法

.gitattributes 是放在 repo 根目錄、會一起提交進版控的設定檔。它解掉了上面三個侷限:

  • 設定檔跟著專案走,新成員 clone 下來就自動生效,CI 與 GitHub 網頁編輯器也吃同一份。
  • 可以逐副檔名指定不同規則。
  • 可以明確標記哪些是二進位檔,不靠 Git 自己猜。
  • 它的優先權高於 core.autocrlf,兩者同時存在時以它為準。

最小可用的版本只要一行:

* text=auto

這行的意思是「所有 Git 判斷為文字的檔案,提交時一律正規化成 LF」。

要注意它只管定了提交的方向。checkout 到工作目錄時要用哪一種,在沒有寫 eol= 的情況下仍由 core.autocrlfcore.eol 決定(core.eol 預設為 native,在 Windows 上就是 CRLF)。想讓每台機器的工作目錄也完全一致,就得像下面這樣把 eol= 明確寫出來:

# 預設:所有文字檔在倉庫中一律存成 LF
* text=auto

# 必須是 LF,否則直譯器會讀到 ^M
*.sh    text eol=lf

# 必須是 CRLF,Windows 的批次檔對換行敏感
*.bat   text eol=crlf
*.cmd   text eol=crlf

# 明確標記為二進位,不做任何轉換與 diff
*.png   binary
*.woff2 binary

各個屬性的意思:

屬性意思
text強制視為文字檔,提交時正規化成 LF
text=auto由 Git 判斷是不是文字檔,是的話才正規化
eol=lfcheckout 到工作目錄時也用 LF,不隨作業系統改變
eol=crlfcheckout 到工作目錄時用 CRLF
-text明確關閉文字處理,不做任何換行轉換
binary-text -diff 的縮寫,同時關閉轉換與 diff

只對「之後」生效

.gitattributes 要放在 repo 根目錄並提交進版控才會生效,而且它只影響之後寫入索引的內容。既有檔案不會因為加了這個檔案就自動變乾淨,需要做一次重新正規化,見後面的說明。

前端專案的建議設定

可以直接貼進專案根目錄的完整版本:

* text=auto

# 原始碼與設定檔
*.js     text eol=lf
*.ts     text eol=lf
*.vue    text eol=lf
*.css    text eol=lf
*.scss   text eol=lf
*.html   text eol=lf
*.json   text eol=lf
*.md     text eol=lf
*.yml    text eol=lf

# lock 檔跨平台一致,避免無意義的整檔變動
package-lock.json text eol=lf

# 腳本
*.sh     text eol=lf
*.bat    text eol=crlf
*.cmd    text eol=crlf

# 二進位資源
*.png    binary
*.jpg    binary
*.gif    binary
*.ico    binary
*.woff   binary
*.woff2  binary
*.pdf    binary

VS Code 與 EditorConfig 設定

.gitattributes 管的是「進版控的內容」,但新建立的檔案一開始是什麼換行,仍由編輯器決定。兩邊都設好才算完整。

VS Code

在使用者或工作區的 settings.json 加上:

json
{
  "files.eol": "\n",
  "files.insertFinalNewline": true,
  "files.trimFinalNewlines": true
}

files.eol 設為 \n 表示新檔一律用 LF(設 auto 則跟隨作業系統)。已經存在的舊檔不會被改,要改請點右下角狀態列的 CRLF 切換。更多設定可參考 VS Code 入門教學與快捷鍵

EditorConfig

如果團隊用的編輯器不只一種,.editorconfig 比 VS Code 設定更通用,同樣放在專案根目錄並提交進版控:

ini
root = true

[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = space
indent_size = 2

[*.{bat,cmd}]
end_of_line = crlf

VS Code 需要安裝 EditorConfig for VS Code 擴充套件才會讀取這個檔案,可參考 VS Code 必裝擴充套件;WebStorm 與多數 JetBrains IDE 則是內建支援。

與 Prettier、ESLint 對齊

三者要講同一種話,否則會互相覆蓋。Prettier 的設定:

json
{
  "endOfLine": "lf"
}

ESLint 若有啟用 linebreak-style,也要一併設成 unix。不過這類排版規則自 ESLint v8.53 起已標記為棄用、v9 也不再內建,新專案交給 Prettier 或 @stylistic 處理即可,不必特地加回來。

兩層各司其職

編輯器與 EditorConfig 管的是新寫出來的檔案.gitattributes 管的是進版控的檔案。只設前者,別人的機器仍可能提交 CRLF;只設後者,本機新檔的換行仍會跟著作業系統跑。兩層都要有。

已經亂掉了怎麼修:重新正規化

如果倉庫裡已經有 CRLF(git ls-files --eol 看到 i/crlf),加上 .gitattributes 並不會自動修好,要主動做一次重新正規化

動手前先確認兩件事

這個操作幾乎會碰到專案裡所有檔案,請先把進行中的 PR 都合併掉,否則每個人都會遇到大規模衝突。另外,工作目錄必須是乾淨的,未提交的改動請先 commit 或 stash。

先開一個獨立分支,並把 .gitattributes 單獨提交成一個 commit:

bash
git status                                    # 必須是乾淨的
git switch -c chore/normalize-line-endings
git add .gitattributes
git commit -m "chore: add .gitattributes"

接著執行重新正規化。git add --renormalize 會依新規則把所有檔案重新寫進索引,它只動索引、不動您的工作目錄

bash
git add --renormalize .
git status                  # 列出換行被改寫的檔案
git diff --cached --stat    # 確認只有換行變動,沒有誤含二進位檔
git commit -m "chore: normalize line endings"

合併之後,其他成員只要 git pull 就會拿到正規化後的內容,不需要額外操作。

只想處理單一檔案時,把路徑帶上即可:

bash
git add --renormalize path/to/file.js

讓 git blame 跳過這次 commit

這個 commit 會蓋掉大量的 blame 資訊。在 repo 根目錄建立 .git-blame-ignore-revs,把正規化那次的 commit hash 寫進去:

bash
git log -1 --format=%H >> .git-blame-ignore-revs
git config blame.ignoreRevsFile .git-blame-ignore-revs

GitHub 也會自動讀取這個檔案。

舊版 Git 的做法

--renormalize 需要 Git 2.16 以上。更舊的版本只能用清空索引再還原的老方法:

bash
git rm --cached -r .
git reset --hard

git reset --hard丟棄所有未提交的改動,執行前務必確認 git status 是乾淨的。能升級 Git 就不要用這一套。

常見問題

CRLF 和 LF 有什麼差別?

兩者都是檔案裡表示換行的控制字元。LF 是單一位元組 0A,用於 Linux、macOS 以及 Git 內部儲存;CRLF 是 0D 0A 兩個位元組,是 Windows 沿用自 DOS 的格式。差別只在位元組,畫面上看起來完全一樣。

core.autocrlf 應該設成什麼?

若專案已經有 .gitattributes,建議設為 false,把判斷交給版控裡的設定檔。若沒有,Windows 設 true、macOS 與 Linux 設 input,至少能避免把 CRLF 提交進倉庫。

有了 .gitattributes 還需要設 core.autocrlf 嗎?

大致上不需要。.gitattributes 的優先權高於 core.autocrlf,兩者同時存在時以前者為準;設定檔會跟著 repo 進版控,所有成員與 CI 都吃同一份,比要求每個人各自設定可靠得多。唯一的例外是只寫了 text=auto、沒寫 eol= 的檔案,它們 checkout 時仍會看 core.autocrlfcore.eol,把 eol= 補明確就不受影響。

為什麼改了設定,舊檔案的換行還是沒變?

換行設定只對之後寫入索引的內容生效,不會回頭改寫已經提交的檔案。要讓既有檔案套用新規則,必須執行 git add --renormalize . 後再提交一次。

執行 .sh 出現 bad interpreter 的 ^M 怎麼辦?

腳本第一行的 shebang 結尾多了 \r,系統把直譯器路徑讀成帶控制字元的字串因而找不到。最快的做法是用編輯器右下角切成 LF 再存檔,或用 dos2unix

bash
dos2unix deploy.sh

沒有 dos2unix 時可用 tr 刪掉 CR,它在 macOS 與 Linux 的行為一致:

bash
tr -d '\r' < deploy.sh > deploy.tmp && mv deploy.tmp deploy.sh

(常見的 sed -i 's/\r$//' 只適用 GNU sed,macOS 內建的 BSD sed 參數與跳脫寫法都不同。)

修好單一檔案之後,記得在 .gitattributes 加上 *.sh text eol=lf,才不會下次 clone 又變回來。

參考資料

延伸閱讀