Skip to content
Retour au blog
Tutoriels

Fins de ligne CRLF et LF : ce qui casse vraiment

Un script en CRLF s'exécute souvent sans erreur et sort en 0 : le dégât se cache dans une variable. Les quatre modes d'échec, les messages par shell et la vraie matrice core.autocrlf.

14 min de lecture

Fins de ligne CRLF et LF : ce qui casse vraiment

Entre CRLF et LF, l’écart tient à un seul octet. LF est un unique \n (0x0A) et termine les lignes sous Linux et macOS. CRLF fait deux octets, \r\n (0x0D 0x0A), et termine les lignes sous Windows.

Un script shell aux fins de ligne CRLF n’échoue pas, le plus souvent. Il s’exécute, affiche ce que vous attendez et sort en 0. Ce qui casse, c’est la valeur rangée dans une variable : l’affectation a conservé le \r final, et personne ne s’en est plaint.

Le comportement mesuré se range en quatre modes, du plus discret au plus visible :

  1. Succès silencieux : la sortie a l’air correcte, une variable transporte un \r invisible, code de sortie 0.
  2. Une erreur qui ne change rien : un : command not found sur une ligne vide, le script va jusqu’au bout, code de sortie 0.
  3. Erreur de syntaxe : if, for et les définitions de fonction cassent, code de sortie 2.
  4. bad interpreter : le shebang emporte le \r avec lui, code de sortie 126.

Avant toute chose, lancez file yourfile. Une seule ligne de sortie vous dit de quel côté vous êtes, CRLF ou LF.

Tout ce qui suit a été mesuré sur macOS (Darwin arm64) avec bash 3.2.57(1)-release, zsh 5.9, dash, git 2.55.0, node v26.7.0 et Python 3.14.6, puis recoupé sous Linux via docker run --rm bash:5, c’est-à-dire GNU bash 5.3.15(1)-release (aarch64-unknown-linux-musl).

1. CRLF, LF et CR : ce que sont vraiment ces octets

CRLF est l’abréviation de carriage return line feed, soit retour chariot et saut de ligne, deux caractères littéralement collés l’un à l’autre :

NomÉchappementOctet
Retour chariot\r0x0D
Saut de ligne\n0x0A
CRLF\r\n0x0D 0x0A

Ce que chaque plateforme écrit :

PlateformeFin de ligne
WindowsCRLF (\r\n)
Linux, macOS moderneLF (\n)
Mac Classic, OS 9 et antérieursCR (\r) tout seul

Ces noms viennent de la mécanique. Sur un téléscripteur, le retour chariot ramenait la tête d’impression contre la marge de gauche et le saut de ligne faisait avancer le papier d’une ligne. Windows a gardé les deux mouvements sous forme de deux octets ; Unix a jugé qu’un seul suffisait. Le CR isolé traîne encore dans de vieux exports, et un fichier qui n’en contient que ça ressemble à une seule ligne gigantesque pour la plupart des outils Unix.

Une seule commande vous dit ce que vous avez

file lit les octets et nomme le terminateur :

$ file d1.txt
d1.txt: ASCII text, with CRLF line terminators

$ file d2.txt
d2.txt: ASCII text no suffix means pure LF

$ file d3.txt
d3.txt: ASCII text, with CRLF, LF line terminators mixed, file lists both

Le troisième cas est celui qu’il faut retenir. Un fichier aux fins de ligne mixtes, ce sont deux outils réglés différemment qui y ont écrit tour à tour : un éditeur enregistre en LF puis un script ajoute du CRLF, ou bien une fusion recoud deux versions divergentes. od -c montre la coupure octet par octet :

$ od -c d3.txt
0000000   a  \r  \n   b  \n
0000005

Les fins de ligne vivent au niveau de l’octet, juste à côté de l’encodage des caractères ; le guide des encodages UTF-8 et UTF-16 traite de ce qui se passe à l’étage au-dessus.

2. Les quatre modes d’échec, du succès silencieux au code 126

Un même fichier en \r\n, cinq issues différentes selon ce que contient la ligne :

Contenu du scriptCe qui se passe réellementCode
echo hello et autres commandes simplesSuccès silencieux, la sortie est correcte0
Affectation X=abcSuccès silencieux, mais la valeur se termine par \r0
Lignes vides, continuations \ en fin de ligne: command not found, le script continue jusqu’au bout0
if/fi, for/do/done, f() {syntax error near unexpected token2
Ligne shebang portant un \rbad interpreter: No such file or directory126

Les deux premières lignes sont celles qui manquent presque partout ailleurs. « CRLF provoque un command not found » se répète de page en page, et ce n’est pas ce qui arrive : les commandes simples ne protestent pas du tout.

Le succès silencieux est le cas dangereux

$ printf 'echo hello\r\necho world\r\n' > win.sh && bash win.sh
hello
world
[exit=0]

Deux lignes en entrée, deux lignes en sortie, code 0. Il n’y a rien à déboguer et aucune trace suspecte à chercher dans les logs. Donnez une comparaison à faire au même script et tout bascule :

$ printf 'V=1.2.3\r\ntest "$V" = "1.2.3" && echo MATCH || echo NO-MATCH\r\n' > v.sh
$ bash v.sh
NO-MATCH
[exit=0]

$V contient 1.2.3\r, pas 1.2.3. Résultat identique sur macOS et sous Linux. Un verrou de version, un if [ "$ENV" = "prod" ], un feature flag lu dans un fichier : tous prennent la mauvaise branche sans bruit, avec un code de sortie 0. C’est le bug de fin de ligne le plus difficile à trouver en CI, parce que le build est vert et le log est propre.

L’erreur qui n’arrête rien

Dans un fichier CRLF, une ligne vide se présente comme une ligne ne contenant qu’un \r, et le shell la traite comme une commande à exécuter. Elle échoue, affiche un message, puis le script passe à la ligne suivante et se termine avec un code 0. La formulation exacte dépend de votre shell.

Les continuations par antislash en fin de ligne cassent de la même façon. Le \r se glisse entre l’antislash et le saut de ligne : la continuation cesse d’en être une, et la ligne suivante s’exécute toute seule.

Erreurs de syntaxe, ou pourquoi fi n’est pas fi

c_if.sh: line 5: syntax error: unexpected end of file
[exit=2]

Sous Linux, bash 5.3.15 en dit un peu plus sur le même fichier :

i.sh: line 5: syntax error: unexpected end of file from `if' command on line 2

Les boucles et les définitions de fonction échouent, elles, dès la ligne 1 :

c_for.sh: line 1: syntax error near unexpected token `do'
c_for.sh: line 1: `for i in 1 2; do'

c_func.sh: line 1: syntax error near unexpected token `{'
c_func.sh: line 1: `f() {'

Bash lit le mot de fermeture comme fi\r, et fi\r n’est pas le mot-clé fi : le bloc if ne se referme jamais, bash continue de lire jusqu’à épuiser le fichier, et c’est pour cela que l’erreur désigne la dernière ligne plutôt que la ligne fautive. Toute la famille se déduit de là.

bad interpreter et le code de sortie 126

$ ./s.sh
bash: ./s.sh: /bin/bash^M: bad interpreter: No such file or directory
[exit=126]

macOS et Linux affichent le même texte et sortent tous deux en 126. Lisez le chemin dans le message : /bin/bash^M. Le noyau prend tout ce qui suit #! jusqu’au saut de ligne comme chemin de l’interpréteur, et le \r en fait partie. Aucun fichier ne porte ce nom, donc l’exec échoue avant qu’une seule ligne de votre script ne s’exécute.

Le code 126 couvre aussi le cas « trouvé, mais non exécutable » : un script qui refuse de démarrer n’est donc pas automatiquement un problème de fin de ligne, et les permissions de fichiers produisent des échecs de la même famille. C’est le ^M à l’intérieur du chemin qui permet de trancher.

Pourquoi vous ne voyez jamais le \r

Lire la sortie ne sert à rien : cet octet ne vous donne rien à regarder ni à sélectionner. Il faut le forcer à apparaître :

$ bash d.sh                    # script: HOST=example.com / echo "connecting to $HOST:8080"
connecting to example.com:8080 what you get

$ bash d.sh | cat -v
connecting to example.com^M:8080^M what is actually there

$ bash d.sh | od -c
0000000   c   o   n   n   e   c   t   i   n   g       t   o       e   x
0000020   a   m   p   l   e   .   c   o   m  \r   :   8   0   8   0  \r
0000040  \n

Deux octets \r invisibles à l’affichage normal, logés tous les deux dans une chaîne qui va servir de nom d’hôte. Parfois l’octet voyage jusque dans un argument et c’est un outil sans rapport qui porte le chapeau :

$ bash v.sh          # the script pipes through: ... | head -2
head: illegal line count -- 2\r

head se comporte correctement : le script lui a passé 2\r. Tout message d’erreur contenant un \r égaré dans la valeur citée, c’est ce bug-là sous un autre nom.

3. Pourquoi votre message d’erreur ne ressemble pas à celui que vous trouvez en ligne

Un seul fichier (printf 'echo a\r\n\r\necho b\r\n', dont la ligne 2 est une ligne vide ne contenant qu’un \r), passé dans quatre shells :

ShellVersionMessage exact
bash (livré avec macOS)3.2.57(1)-releases_blank.sh: line 2: : command not found
bash (Linux grand public)5.3.15(1)-releaset.sh: line 2: $'\r': command not found
zsh5.9s_blank.sh:2: command not found: ^M
dashs_blank.sh: 2: : not found

Presque tous les résultats de recherche citent la deuxième ligne. Bash 4 et 5 affichent les caractères non imprimables avec la notation ANSI-C, ce qui transforme le retour chariot en $'\r'. Le bash livré avec macOS est un 3.2 et ne fait pas cela : il n’affiche rien à la place du caractère, d’où le deux-points tout seul. zsh affiche ^M. dash, lui, laisse carrément tomber le mot « command ».

Une seule faute, quatre messages. Si vous avez collé votre erreur exacte dans un moteur de recherche sans rien obtenir d’utile, voilà l’explication. Le : command not found tout nu et la version en $'\r' viennent du même octet.

4. Git : ce que core.autocrlf range vraiment dans votre dépôt

La plupart des explications sur git autocrlf s’arrêtent aux définitions des trois valeurs. Deux choses se mesurent pourtant très bien : ce qui finit dans le commit, et ce que récupèrent vos collègues au checkout. Relevé avec git cat-file -p HEAD:f.txt pour le blob stocké et rm f.txt && git checkout -- f.txt pour la copie de travail :

core.autocrlfFichier sourceBlob dans le dépôtCopie de travail après checkout
trueCRLFLFCRLF
trueLFLFCRLF
inputCRLFLFLF
inputLFLFLF
falseCRLFCRLFCRLF
falseLFLFLF

Trois conclusions tombent de ce tableau :

  1. true et input garantissent tous deux du LF dans le dépôt. La seule différence se joue au checkout : true reconvertit en CRLF, input laisse le fichier tranquille.
  2. Seul false fait entrer du CRLF dans un commit. Quand quelqu’un demande qui a commité les retours chariot, la réponse est dans cette ligne.
  3. La deuxième ligne est la surprise. Avec true, un fichier qui était en LF sur le disque revient en CRLF après le checkout.

Git annonce la réécriture avant qu’elle n’ait lieu, sous l’une de ces deux formes :

warning: in the working copy of 'f.txt', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'f.txt', CRLF will be replaced by LF the next time Git touches it

« Je n’ai rien changé, et git dit que tout le fichier a changé »

La deuxième ligne, encore. Avec git config core.autocrlf true, le checkout réécrit les fichiers LF en CRLF dans le répertoire de travail. Chaque ligne diffère désormais du blob d’un octet : git diff signale toutes les lignes comme modifiées et la pull request présente comme entièrement réécrit un fichier que personne n’a touché. C’est le filtre de checkout qui a fait le travail.

L’image miroir produit le même bruit : un collègue en false commite du CRLF, vous êtes en input, et des fichiers que vous n’avez jamais ouverts apparaissent dans votre diff.

5. .gitattributes règle le problème pour toute l’équipe

core.autocrlf est un réglage local à une machine, invisible pour tous les autres. .gitattributes est un fichier versionné dans le dépôt : il voyage avec chaque clone. Quand les deux se contredisent, ce sont les attributs qui gagnent. Le test porte sur quatre formes :

.gitattributescore.autocrlfSourceBlobAprès checkoutGagnant
* text=autofalseCRLFLFLFattributs
* text eol=crlfinputLFLFCRLFattributs
* -texttrueCRLFCRLFCRLFattributs
* text eol=lftrueCRLFLFLFattributs

La troisième ligne mérite d’être retenue : -text désactive complètement la conversion et l’emporte quand même sur core.autocrlf=true. C’est ainsi que l’on protège les fichiers dont les octets doivent rester intacts.

Un .gitattributes à recopier

* text=auto

*.sh      text eol=lf
*.bash    text eol=lf
Makefile  text eol=lf

*.bat     text eol=crlf
*.cmd     text eol=crlf
*.ps1     text eol=crlf

*.png     -text
*.jpg     -text
*.pdf     -text
*.zip     -text

text=auto normalise en LF, dans le dépôt, tout ce que Git détecte comme du texte. Les lignes eol=lf explicites couvrent les fichiers qui doivent être en LF quel que soit leur auteur, parce qu’un .sh en CRLF est un code de sortie 126 en sursis. Les types de scripts Windows reçoivent eol=crlf pour la raison symétrique, et les motifs binaires reçoivent -text afin que rien ne soit converti du tout.

core.safecrlf et core.eol

Deux réglages que l’on croise à côté de core.autocrlf et qui font tout autre chose :

  • core.safecrlf est un garde-fou, pas un convertisseur. Quand une conversion ne serait pas réversible, par exemple sur un fichier mixte où la normalisation perd de l’information, true refuse l’opération et warn la laisse passer avec un avertissement. Il ne change jamais les octets stockés ; il refuse seulement de faire les conversions destructrices en silence.
  • core.eol choisit la fin de ligne que Git écrit dans le répertoire de travail pour les fichiers marqués text, lorsque core.autocrlf vaut false. Les valeurs possibles sont lf, crlf et native. core.autocrlf le supplante, ce qui explique pourquoi régler core.eol sur une machine où autocrlf est resté actif donne l’impression de n’avoir servi à rien.

Modifier .gitattributes ne corrige pas les fichiers déjà commités

Les attributs s’appliquent au moment où Git écrit ou lit un fichier : le contenu existant reste tel quel tant que rien ne le réécrit. Provoquez vous-même ce passage :

$ git add --renormalize .
$ git commit -m "Normalize line endings"

Attendez-vous à un diff énorme, et c’est bien le but. Faites-le sur une branche dédiée, en un seul commit, et prévenez l’équipe avant qu’il n’atterrisse.

6. Convertir CRLF en LF, et dans l’autre sens

Quatre méthodes suppriment le \r, toutes vérifiées sous Darwin :

CommandeRésultat
tr -d '\r' < f > f.outfonctionne
perl -pi -e 's/\r\n/\n/g' ffonctionne
sed -i '' -e 's/\r$//' f (forme BSD)fonctionne
sed -i -e 's/\r$//' f (forme GNU, lancée sur macOS)fonctionne, et laisse un fichier parasite derrière elle

Le piège du sed -i GNU sur macOS

Le sed BSD exige que -i soit suivi d’un suffixe de sauvegarde. Recopiez un tutoriel Linux mot pour mot et il avalera le -e en guise de suffixe. Votre modification a bien lieu, et ceci aussi :

a5.txt
a5.txt-e

a5.txt-e est une copie de sauvegarde, créée parce que sed a lu -e comme le suffixe que vous réclamiez. La forme correcte sur macOS passe une chaîne vide explicite : sed -i '' -e 's/\r$//' f. Un dépôt qui contient des fichiers en -e commités est un dépôt où quelqu’un a lancé une ligne de commande GNU sur un Mac.

macOS n’a pas dos2unix

command -v dos2unix ne renvoie rien sur un macOS d’origine ; le binaire vient de brew install dos2unix. Voilà pourquoi la réponse la plus recopiée d’internet échoue sur la machine qu’une bonne partie des développeurs a sous les doigts. tr -d '\r' ne demande aucune installation et fait le même travail.

Dans l’autre sens, unix2dos souffre du même problème de disponibilité, et sed -e 's/$/\r/' fait très bien office de remplaçant.

Une fois le fichier revenu à du LF propre, il se traite de nouveau sans risque comme une liste de lignes. Tout ce qui compare des lignes entières y est sensible : trier des lignes de texte et supprimer les lignes en double lisent value\r et value comme deux lignes distinctes, si bien qu’un seul retour chariot égaré met discrètement en échec la déduplication.

Dans votre éditeur

VS Code affiche la fin de ligne du fichier courant, CRLF ou LF, dans la barre d’état en bas à droite, et un clic dessus bascule le fichier. Le réglage files.eol contrôle la valeur par défaut des nouveaux fichiers, et le définir par espace de travail garde cohérente une équipe hétérogène. Les autres éditeurs exposent les deux mêmes commandes sous d’autres noms. Ce que l’on oublie, c’est que l’indicateur par fichier et le réglage par défaut sont indépendants : modifier l’un ne touche pas l’autre.

7. Gérer les fins de ligne dans le code

Un seul fichier, line1\r\nline2\r\n, lu par sept points d’entrée :

Point d’entréeCe que vous obtenez\r
Node fs.readFileSync(f,"utf8")"line1\r\nline2\r\n"conservé
Node, même valeur + .split("\n")["line1\r","line2\r",""]conservé sur chaque ligne
Node readline avec crlfDelay:Infinity["line1","line2"]supprimé
Python open(f), mode par défaut'line1\nline2\n'converti
Python open(f).readlines()['line1\n','line2\n']converti
Python open(f, newline="")'line1\r\nline2\r\n'conservé
Python open(f,"rb")b'line1\r\nline2\r\n'conservé

Ce tableau tranche un grand classique des rapports de bug : ça marche en Python et ça casse en Node. Le mode texte par défaut de Python applique les universal newlines et traduit \r\n en \n avant même que vous ne le voyiez. Node vous rend les octets tels quels. Aucun des deux n’a tort, mais ils divergent dès l’instant où ils lisent le même fichier.

rstrip("\n") laisse le \r derrière lui

original: 'line1\r\n'  | rstrip("\n"): 'line1\r'  | strip(): 'line1'

rstrip("\n") retire exactement les caractères que vous avez listés, et \r n’était pas dans la liste. Le résultat ne correspond ensuite à rien de ce à quoi il devrait être égal, ce qui est la réponse honnête au « je l’ai pourtant nettoyé et ce n’est toujours pas égal ». Utilisez strip(), ou rstrip() sans argument, et tous les blancs de fin s’en vont, retour chariot compris.

Découper les lignes sans risque sur les deux plateformes

En JavaScript, découpez sur un motif qui tolère les deux fins de ligne : text.split(/\r?\n/). En Python, restez en mode texte par défaut et laissez les universal newlines s’en charger, ou appelez splitlines(), qui encaisse \r\n, \n et le \r isolé.

L’écriture est l’autre moitié du problème. Node écrit les octets que vous lui donnez : construisez donc vos chaînes avec \n et laissez .gitattributes décider de ce qui atterrit sur le disque. Le open(path, "w") de Python traduit \n vers la fin de ligne de la plateforme, sauf si vous passez newline="" (le drapeau que réclament les writers CSV, précisément pour cette raison).

Un retour chariot en fin de ligne et une marque d’ordre des octets en début de fichier sont le même genre de bug pris par les deux bouts : un octet invisible qui survit au copier-coller et fait échouer un test d’égalité. Le guide de dépannage du BOM UTF-8 s’occupe de l’autre bout.

8. Où les fins de ligne mordent aussi

CSV et Excel. La RFC 4180 impose CRLF comme séparateur d’enregistrements, ce qui fait du CSV l’un des rares endroits où CRLF est correct et non un défaut. Les parseurs écrits pour du LF seul laissent un \r sur le dernier champ de chaque ligne : si des valeurs issues d’une conversion CSV vers JSON ont l’air correctes mais ne se comparent pas comme prévu, vérifiez cet octet en premier.

Docker. Un .sh en CRLF copié dans une image, c’est le quatrième mode d’échec. COPY préserve les octets, le shebang garde son \r, et le conteneur sort en 126. Une seule ligne *.sh text eol=lf dans .gitattributes élimine toute la famille.

Diffs et pull requests. Un fichier marqué comme entièrement modifié sans la moindre édition visible, c’est le mécanisme de la section 4 qui débarque en revue de code. Comparer les deux versions avec text diff le confirme en quelques secondes, et le guide de comparaison de textes explique comment lire le résultat.

Fichiers mixtes. ASCII text, with CRLF, LF line terminators signifie deux outils d’écriture aux réglages différents. Normalisez le fichier entier plutôt que les seules lignes que vous venez de modifier, sinon le prochain diff sera tout aussi bruyant.

FAQ

Quelle est la différence entre CRLF et LF ?

CRLF fait deux octets, \r\n (0x0D 0x0A). LF n’en fait qu’un, \n (0x0A). Windows écrit du CRLF, Linux et macOS du LF, et les deux marquent la fin d’une ligne. Le texte paraît identique dans un éditeur ; la différence n’apparaît que dans les octets, les comparaisons de chaînes et les diffs.

Mon script a des fins de ligne CRLF, pourquoi n’y a-t-il aucune erreur ?

Parce que les commandes simples survivent à l’octet supplémentaire. echo hello suivi d’un \r s’exécute et sort en 0. Les erreurs n’apparaissent que là où l’analyseur y prête attention : une ligne vide, un mot-clé comme fi, ou le shebang. Les affectations sont le cas dangereux, puisqu’elles réussissent et rangent le \r dans la variable.

Que signifie $'\r': command not found, et pourquoi ne le vois-je pas ?

Cela signifie que le shell a tenté d’exécuter une ligne ne contenant qu’un retour chariot. Bash 4 et 5 affichent ce caractère avec la notation ANSI-C, d’où $'\r'. Le bash 3.2 livré avec macOS n’affiche rien entre les deux-points, et zsh affiche ^M à la place. Trois shells, trois façons d’écrire la même faute.

Faut-il mettre core.autocrlf à true, input ou false ?

Utilisez input sous Linux et macOS, true sous Windows, et préférez .gitattributes aux deux. À la mesure, true et input stockent tous deux du LF dans le dépôt, et seul false laisse entrer du CRLF dans un commit. true réécrit en plus les fichiers LF en CRLF dans votre copie de travail au checkout.

Si .gitattributes et core.autocrlf se contredisent, lequel l’emporte ?

.gitattributes l’emporte. Les quatre formes testées (* text=auto, * text eol=crlf, * -text et * text eol=lf) ont toutes écrasé la valeur locale de core.autocrlf. C’est l’argument en sa faveur : les attributs sont versionnés et s’appliquent à tout le monde, alors que core.autocrlf est un réglage par machine que vous ne pouvez ni voir ni imposer.

Comment vérifier si un fichier utilise CRLF ou LF ?

Lancez file yourfile. Le CRLF affiche ASCII text, with CRLF line terminators, le LF pur affiche ASCII text sans le moindre suffixe, et un fichier mixte affiche with CRLF, LF line terminators. Pour une certitude au niveau de l’octet, lancez od -c et cherchez le \r placé devant chaque \n.

Dans quels cas faut-il vraiment utiliser CRLF ?

Quand un format ou un protocole l’exige. La RFC 4180 définit CRLF comme séparateur d’enregistrements pour le CSV, et les en-têtes HTTP comme SMTP font de même sur le réseau. Les fichiers batch et PowerShell de Windows sont eux aussi plus sûrs en CRLF. Pour tout le reste, code source et scripts shell compris, LF.

Tags: line-endings crlf git cross-platform shell

Articles connexes

Voir tous les articles