Written by Bjørn Erling Fløtten, Trondheim, Norway. Updated 12 Nov 2026.
License: CC BY 4.0. Handcrafted, no AI.
This article test demonstrates some footguns (1, 2, 3), related to how git does CRLF conversions of text files. It uses practical examples and applies to git version 2.53.0.
The TL;DR is that you should
You are not necessarily synchronized even after everyone has committed, pushed and pulled. Or in other words, some git issues are local to a specific repository (to a specific computer) and not possible to fix 'centrally'.
git add --renormalize fixes what is wrong "in the git repository" (in the index).
You can grep like
git ls-files --eol | grep 'i/crlf ' git ls-files --eol | grep 'i/mixed '
to find what is wrong "in the git repository".
Deleting and git checkout fixes what is wrong "on your computer" (in the working tree).
You can grep like
git ls-files --eol | grep 'w/lf attr/text eol=crlf' git ls-files --eol | grep 'w/crlf attr/text eol=lf' git ls-files --eol | grep 'w/mixed '
to find what is wrong "on your computer".
The rest of the article gives detailed examples that you can easily run for yourself demonstrating these issues:
CR = Carriage Return, ASCII 13 (0x0D) often escaped as '\r'. LF = Line Feed, ASCII 10 (0x0A) often escaped as '\n'.
Windows uses CRLF for line endings, Linux and Mac use LF only. "Phantom diffs" related to line ending shows up when developers used different operating systems. Like saving a file on Windows leading to 0x0A being changed to 0x0D 0x0A and all lines showing as changed in the diff.
In my workplace attempts to remedy this through a .gitattributes file was initially not 100% successful. The existing sources on the Internet and the corresponding AI summaries took some time to understand. Our investigation therefore led to this article.
Before you can run the examples further below you must ensure that none of the core.autocrlf, core.eol and core.safecrlf settings are set.
The following Linux terminal commands, when run outside of a git directory, should all return nothing:
git config --show-origin core.autocrlf git config --show-origin core.eol git config --show-origin core.safecrlf
If some of these are set then you should first unset in order to follow the examples below.
If the origin is in your user settings like ~/.gitconfig then you can unset like this:
git config --unset --global core.autocrlf git config --unset --global core.eol git config --unset --global core.safecrlf
If the origin is in your system settings like /etc/gitconfig then you can unset like this:
sudo git config --unset --system core.autocrlf sudo git config --unset --system core.eol sudo git config --unset --system core.safecrlf
(Note use of sudo since this typically requires extra permissions)
This article applies to text files, not binary files. Here is a demonstration of how git autodetects what is text files. Note that this is NOT done by looking at the file extensions. (Note also that you can also explicitly state what are text files in a .gitattributes file. This is shown further below.)
Run the following commands in a Linux terminal:
mkdir repo1 cd repo1 git init printf "A" > file1 printf "A\x0A" > file2 printf "\x0A" > file3 printf "\x0D\x0A" > file4 printf "\x0D\x0A\x0A" > file5 printf "\x0D" > file6 git add -A git ls-files --eol
The response should be:
i/none w/none attr/ file1 i/lf w/lf attr/ file2 i/lf w/lf attr/ file3 i/crlf w/crlf attr/ file4 i/mixed w/mixed attr/ file5 i/-text w/-text attr/ file6
First column (i/...) shows how git has identified the file in the index. Second column (w/...) shows how git has identified the file in the working tree (the local working directory). Since we do not yet use any conversions, the two columns are identical.
file2, 3, 4 and 5 are considered as text files (file5 with mixed line endings) . Git was unable to conclude for file1 but concluded that file6 is a binary file ('-text' means 'negative text' / 'not text').
If you run
printf "\x0D\x0A" > file6 git ls-files --eol
then the response should be:
... i/-text w/crlf attr/ file6
This shows that file6 is now recognized in the working tree as a text file, but not yet in the index. Adding the file by
git add -A git ls-files --eol
gives the following response:
... i/crlf w/crlf attr/ file6
hexdump is a very useful utility for analyzing line endings in a file. Just remember to use the -C (canonical) option in order to get a byte by byte output:
hexdump -C file2
Response:
00000000 41 0a |A.| 00000002
Note: This is included for completeness only. core.autocrlf true was earlier recommended for some Windows users. A more modern approach is to use a .gitattributes file. This is described later in this article.
Run the following commands:
cd .. mkdir repo2 cd repo2 git init printf "A\x0D\x0A" > file1 printf "A\x0D\x0A" > file2 git add -A printf "A\x0D\x0A" > file3 git config core.autocrlf true git add -A git ls-files --eol
The last part of the response should be:
i/crlf w/crlf attr/ file1 i/crlf w/crlf attr/ file2 i/lf w/crlf attr/ file3
indicating that file3 was autoconverted to LF instead of CRLF (because we added it after settinggit config core.autocrlf true). file1 and file2 were not autoconverted because they were added to the index before setting of core.autocrlf true
If we do some changes to file1 and add it again:
printf "B\x0D\x0A" > file1 git add -A git ls-files --eol
then we will still see:
i/crlf w/crlf attr/ file1 i/crlf w/crlf attr/ file2 i/lf w/crlf attr/ file3
It does not matter if we commit the file either. git simply does not change the line endings in the index by itself after a file has been initially added to the index.
This can be a bit non-intuitive.
If we change the file "manually" to LF then it will also change in the index:
printf "C\x0A" > file1 git add -A git ls-files --eol
Response:
i/lf w/lf attr/ file1 i/crlf w/crlf attr/ file2 i/lf w/crlf attr/ file3
The recommended method is to use git add --renormalize:
git add --renormalize -A git ls-files --eol
Response:
i/lf w/lf attr/ file1 i/lf w/crlf attr/ file2 i/lf w/crlf attr/ file3
We see that file2 is now changed to LF (but only in the index, git add --renormalize does not change the working tree).
For the sake of coherence, lets change file1 back to CRLF in the working tree:
printf "D\x0D\x0A" > file1 git add -A git ls-files --eol
Response:
i/lf w/crlf attr/ file1 i/lf w/crlf attr/ file2 i/lf w/crlf attr/ file3
file1 still being LF in the index reflects the setting core.autocrlf true. In other words, when it was copied to the index from the working tree it was converted to LF.
Git will give a warning if a file has other line endings in the working tree then what will result from a git clone or git checkout. This can be demonstrated as follows:
printf "B\x0A" > file2 printf "B\x0A" > file3 git add -A
Reponse:
warning: in the working copy of 'file2', LF will be replaced by CRLF the next time Git touches it warning: in the working copy of 'file3', LF will be replaced by CRLF the next time Git touches it
This is due to core.autocrlf true which means "LF in index should correspond to CRLF in working tree". We can check with:
git ls-files --eol
Response:
i/lf w/crlf attr/ file1 i/lf w/lf attr/ file2 i/lf w/lf attr/ file3
The meaning of 'touches it' in warning: in the working copy of 'file2', CRLF will be replaced by LF the next time Git touches it can be confusing. It is only meant git clone or git checkout. In other words it does NOT apply when you add the file INTO the index, only when you get it FROM the index.
ALSO, if you simply checkout the file again without it having been changed, nothing happens:
git checkout file2 git ls-files --eol
Response:
i/lf w/crlf attr/ file1 i/lf w/lf attr/ file2 i/lf w/lf attr/ file3
One could argue that git checkout file touched file2 but as just seen nothing really happened.
However, if you change it, and then undo that change by checking out, then you get a changed file:
printf "C\x0A" > file2 git checkout file2 git ls-files --eol
Response:
i/lf w/crlf attr/ file1 i/lf w/crlf attr/ file2 i/lf w/lf attr/ file3
This can be a bit non-intuitive.
Another possibility is to remove the file and check it out again (assuming it has been committed): rm file2 && git checkout file2. Please also note that you could not have used git add --renormalize as that never changes the working tree, only the index.
If we unset core.autocrlf true then changed files will be added to the index again "as-is":
git config --unset core.autocrlf printf "E\x0D\x0A" > file1 git add -A git ls-files --eol
Response:
i/crlf w/crlf attr/ file1 i/lf w/crlf attr/ file2 i/lf w/lf attr/ file3
Likewise we can renormalize all the other files:
git add --renormalize -A git ls-files --eol
Response:
i/crlf w/crlf attr/ file1 i/crlf w/crlf attr/ file2 i/lf w/lf attr/ file3
Note that in the absence of core.autocrlf true renormalize simply sets the index identical to the working tree.
A .gitattributes file goes inside the repo, thus ensuring that all developers get the same configuration. This example follows the same steps as the previous example (the functionality is very much similar).
Run the following commands:
cd .. mkdir repo3 cd repo3 git init printf "A\x0D\x0A" > file1 printf "A\x0D\x0A" > file2 git add -A printf "* text=auto\x0A" > .gitattributes printf "A\x0D\x0A" > file3 printf "\x0D" > file4 git add -A git ls-files --eol
NOTE: Using attribute text=auto may in some situations be a bad idea. See footgun 3 for an example. An alternative is to use text eol=lf.
The last part of the response should be:
warning: in the working copy of 'file3', CRLF will be replaced by LF the next time Git touches it i/lf w/lf attr/text=auto .gitattributes i/crlf w/crlf attr/text=auto file1 i/crlf w/crlf attr/text=auto file2 i/lf w/crlf attr/text=auto file3 i/-text w/-text attr/text=auto file4
indicating as in the previous example that file3 was autoconverted to LF instead of CRLF (the warning is explained in the next paragraph). file1 and file2 were not autoconverted because they were added to the index before creation of .gitattributes. The main difference is that git now shows the attributes a they are set on each file. In this case we used the same text attribute for all files but you can also set for individual file types.
Note that the attribute text is also set for file4 even though this file was identified as a binary file. This is logical (the attribute applies to all files) but it can look a bit confusing at first glance.
We also now immediately get warning: in the working copy of 'file3', CRLF will be replaced by LF the next time Git touches it (assumed this is run on a Linux or macOS system). This is because file3 does not correspond to attribute text=auto which for Linux / macOS means "LF in the index should correspond with LF in the working tree" (on Windows you would not get this warning because for that system index LF and working tree CRLF is the "correct" situation).
As mentioned in the previous example the warning can be confusing. See later on for how we resolve it.
If we change file1 (but not its line endings) and add it again:
printf "B\x0D\x0A" > file1 git add -A git ls-files --eol
then we will still see (as in the previous example):
i/lf w/lf attr/text=auto .gitattributes i/crlf w/crlf attr/text=auto file1 i/crlf w/crlf attr/text=auto file2 i/lf w/crlf attr/text=auto file3 i/-text w/-text attr/text=auto file4
As in the previous example it does not matter if we commit the file either. git simply does not change the line endings in the index by itself after a file has been initially added to the index.
If we change the file "manually" to LF then it will also change in the index:
printf "C\x0A" > file1 git add -A git ls-files --eol
Response:
i/lf w/lf attr/text=auto .gitattributes i/lf w/lf attr/text=auto file1 i/crlf w/crlf attr/text=auto file2 i/lf w/crlf attr/text=auto file3 i/-text w/-text attr/text=auto file4
The recommended method is (as in the previous previous example) to use git add --renormalize:
git add --renormalize -A git ls-files --eol
Response:
i/lf w/lf attr/text=auto .gitattributes i/lf w/lf attr/text=auto file1 i/lf w/crlf attr/text=auto file2 i/lf w/crlf attr/text=auto file3 i/-text w/-text attr/text=auto file4
We see that file2 is now changed to LF (but only in the index, as already mentioned git add --renormalize does not change the working tree).
For the sake of coherence, lets change file1 back to CRLF in the working tree:
printf "D\x0D\x0A" > file1 git add -A git ls-files --eol
Response:
i/lf w/lf attr/text=auto .gitattributes i/lf w/crlf attr/text=auto file1 i/lf w/crlf attr/text=auto file2 i/lf w/crlf attr/text=auto file3 i/-text w/-text attr/text=auto file4
file1 still being LF in the index reflects the attribute text=auto. In other words, when it was copied to the index from the working tree it was converted to LF.
File1, 2 and 3 are now in an unnatural state for a Linux system. They are associated with a corresponding warning: in the working copy of 'fileX', CRLF will be replaced by LF the next time Git touches it. As shown in the previous example it is not possible to resolve this with a simple git checkout. In that example we changed the file and then did git checkout. Here we choose to commit, remove and checkout the files instead:
git commit -m "X" rm file* git checkout . git ls-files --eol
Response:
i/lf w/lf attr/text=auto .gitattributes i/lf w/lf attr/text=auto file1 i/lf w/lf attr/text=auto file2 i/lf w/lf attr/text=auto file3 i/-text w/-text attr/text=auto file4
This can be a bit non-intuitive.
Please also note that you could not have used git add --renormalize as that never changes the working tree, only the index.
If we remove the .gitattributes file then the changed files will be added to the index again "as-is":
rm .gitattributes printf "E\x0D\x0A" > file1 git add -A git ls-files --eol
Response:
i/crlf w/crlf attr/ file1 i/lf w/lf attr/ file2 i/lf w/lf attr/ file3 i/-text w/-text attr/ file4
file1 was added "as-is" to the index. Note also that the attributes are now gone since we deleted .gitattributes.
This time there is nothing to renormalize, since the index corresponds to the working tree and there are no attributes specifying otherwise
Scenario: A Windows .txt text file should have CRLF instead of LF but the fix is impossible to commit.
Run the following commands:
cd .. mkdir repo4 cd repo4 git init printf "A\x0A" > file.txt printf "*.txt text eol=crlf\x0A" > .gitattributes git add -A git commit -m "Init repo" git ls-files --eol
Response:
... i/lf w/lf attr/ .gitattributes i/lf w/lf attr/text eol=crlf file.txt
The developer realises that file should have had CRLF instead of LF and attempts to fix it by doing:
printf "A\x0D\x0A" > file.txt git commit -a -m "Fix line endings in file.txt"
Response:
... nothing to commit, working tree clean
The change is "impossible" to commit although git status confirms that there are indeed 'Changes not staged for commit':
git status
Response:
... Changes not staged for commit: (use "git add..." to update what will be committed) (use "git restore ..." to discard changes in working directory) modified: file.txt no changes added to commit (use "git add" and/or "git commit -a")
(If you try the more verbose git status -v -v git will not give any additional information. This is because there really is no diff to show for file.txt.)
In this case it is sufficient to fix the situation through a simple git checkout:
git checkout file.txt git status git ls-files --eol
Response:
... nothing to commit, working tree clean i/lf w/lf attr/ .gitattributes i/lf w/crlf attr/text eol=crlf file.txt
NOTE: In the process that led up to the decision to write this article we experienced a broken repo state with a similar phantom change that we could not get rid of by any means whatever. Neither git checkout file.txt (or rm file.txt && git checkout file.txt) nor git stash worked. It was simply impossible to get rid of what git insisted was 'Changes not staged for commit'. The phantom change reoccurred in connection with switching branches. The only solution was to delete the whole repo and git clone again. Unfortunately we were unable to reproduce that issue when writing this article.
Scenario: For a .Net C# project we want (for some inexplicit reason) to standardize on CRLF for .cs files. If we mistakenly set it also for .sh files then they will be broken, but not necessarily "immediately" for all developers.
In this scenario there are three developers: A, B and C. Developer A does the initial buggy setup. Developer B experiences nothing wrong but developer C encounters the problem.
This shows that you are not necessarily synchronized even after everyone has committed, pushed and pulled. Or in other words, some git issues are local to a specific repository (to a specific computer) and not possible to fix 'centrally'.
Note: All the scripts below, as both developer A, B and C, can be run locally straight from your terminal.
Developer A does the initial buggy setup by running the following commands:
cd .. mkdir repo5A cd repo5A git init printf "Console.WriteLine(\x22Hello, World\x22);\x0A" > Program.cs printf "#\x21/usr/bin/env bash\x0Adotnet run Program.cs\x0A" > run.sh chmod +777 run.sh git add -A git commit -m "Init repo" git ls-files --eol
The last part of the response should be:
i/lf w/lf attr/ Program.cs i/lf w/lf attr/ run.sh
(If you have .Net installed you can now run the C# program by ./run.sh but that is not necessary in order to follow the example.)
Developer B then clones the repo
cd .. git clone repo5A repo5B
Developer A mistakenly sets CRLF for all files (instead of only *.cs files which was the original intent):
cd repo5A printf "* text eol=crlf\x0A" > .gitattributes git add -A git ls-files --eol
Response:
warning: in the working copy of 'Program.cs', LF will be replaced by CRLF the next time Git touches it warning: in the working copy of 'run.sh', LF will be replaced by CRLF the next time Git touches it warning: in the working copy of '.gitattributes', LF will be replaced by CRLF the next time Git touches it i/lf w/lf attr/text eol=crlf .gitattributes i/lf w/lf attr/text eol=crlf Program.cs i/lf w/lf attr/text eol=crlf run.sh
The warning says that the local state (the working tree) for developer A is now "wrong". But it is only a warning and it only applies to what is stored locally for Developer A. The developer can still commit (and push to some remote repo). Strictly speaking, what gets committed (and pushed) is after all correct.
So developer A commits the buggy setup (in a real setup read this as "developer A pushes ...") and developer B pulls it:
git commit -m "Add .gitattributes" cd ../repo5B git pull git ls-files --eol
What developer B sees:
i/lf w/crlf attr/text eol=crlf .gitattributes i/lf w/lf attr/text eol=crlf Program.cs i/lf w/lf attr/text eol=crlf run.sh
.gitattributes was autoconverted according to itself (it is not recommended to have CRLF in it, but it still works and is understood by git). For Program.cs and run.sh there were no changes and therefore git did not "touch" these files. Since everything still works for Developer B they are unaware of any issues.
A new developer C gets hired and clones the repo:
cd .. git clone repo5A repo5C cd repo5C git ls-files --eol
What developer C sees:
i/lf w/crlf attr/text eol=crlf .gitattributes i/lf w/crlf attr/text eol=crlf Program.cs i/lf w/crlf attr/text eol=crlf run.sh
Only developer C gets a non-working configuration, a run.sh script file with CRLF instead of LF. This will typically break the shebang at the start of the file:
./run.sh
Response:
env: $'bash\r': No such file or directory env: use -[v]S to pass options in shebang lines
Not a very helpful error message. hexdump -C clarifies a little bit
hexdump -C run.sh
Response:
00000000 23 21 2f 75 73 72 2f 62 69 6e 2f 65 6e 76 20 62 |#!/usr/bin/env b| 00000010 61 73 68 0d 0a 64 6f 74 6e 65 74 20 72 75 6e 20 |ash..dotnet run | 00000020 50 72 6f 67 72 61 6d 2e 63 73 0d 0a |Program.cs..| 0000002c
The '\r' in the script error message is the CR part of CRLF. It corresponds to the first '0d' byte seen in the hexdump). So the system tries to run a program called 'bash\r' instead of 'bash'. This is the same error as discussed earlier in this article.
Assumed that the investigation points in the right direction, developer A now fixes the root cause of the problem:
cd ../repo5A printf "*.cs text eol=crlf\x0A" > .gitattributes git commit -a -m "Restrict CRLF to .cs files only" git ls-files --eol
Response:
i/lf w/lf attr/ .gitattributes i/lf w/lf attr/text eol=crlf Program.cs i/lf w/lf attr/ run.sh
The index is now correct but Program.cs is still wrong in the working tree. Unfortunately the original warning is also "gone" (the CRLF warnings are transient notices only given in connection with commands like git add). You can grep like this however in order to find all files for which the CRLF warning applies:
git ls-files --eol | grep 'w/lf attr/text eol=crlf'
Response:
i/lf w/lf attr/text eol=crlf Program.cs
Based on this you can do
rm Program.cs git checkout Program.cs git ls-files --eol
Response:
i/lf w/lf attr/ .gitattributes i/lf w/crlf attr/text eol=crlf Program.cs i/lf w/lf attr/ run.sh
It is necessary but not sufficient for developer B to pull A's changes:
cd ../repo5B git pull git ls-files --eol
Response:
i/lf w/lf attr/ .gitattributes i/lf w/lf attr/text eol=crlf Program.cs i/lf w/lf attr/ run.sh
Developer B can fix Program.cs in the same manner as developer A did since the issue is with the same file Program.cs.
Developer C must first pull:
cd ../repo5C git pull git ls-files --eol
Response:
i/lf w/lf attr/ .gitattributes i/lf w/crlf attr/text eol=crlf Program.cs i/lf w/crlf attr/ run.sh
For developer C the file Program.cs is in the correct state but not run.sh. The grep to identify the problem is also different because of two reasons:
git ls-files --eol | grep 'w/crlf attr/ '
Response:
i/lf w/crlf attr/ run.sh
Developer C can then delete and checkout in the same manner as for developer A and B
rm run.sh git checkout run.sh git ls-files --eol
Response:
i/lf w/lf attr/ .gitattributes i/lf w/crlf attr/text eol=crlf Program.cs i/lf w/lf attr/ run.sh
Scenario: Changing the line endings in a C# source code file changes the line endings for literal strings inside that file. If these again define autogenerated script files these script files may break. This can for instance happen when using a .gitattributes file with text=auto (instead of text eol=lf).
Note: This issues applies differently to other programming languages. This article only analyses C#. You need to have .Net installed in order for the examples to work.
Run the following commands:
cd .. mkdir repo6 cd repo6 printf "System.IO.File.WriteAllText(\x0A\x22run.sh\x22, \x22\x22\x22\x0A#\x21/usr/bin/env bash\x0Adotnet run Program.cs\x0A\x0A\x22\x22\x22);\x0A" > Program.cs dotnet run Program.cs echo -e "\nProgram.cs:" cat Program.cs echo -e "\nrun.sh:" cat run.sh chmod +777 run.sh ./run.sh
Response (no errors):
Program.cs: System.IO.File.WriteAllText( "run.sh", """ #!/usr/bin/env bash dotnet run Program.cs """); run.sh: #!/usr/bin/env bash dotnet run Program.cs
If the Program.cs file now changes from LF to CRLF then the literal string also changes, meaning that run.sh will break as shown earlier. This could happen through some git issues as shown initially in this article or simply by using a .gitattributes file with text=auto (instead of text eol=lf). This is because text=auto means that Linux and Mac users will get different results from Windows users.
For simplicity's sake we will change it directly here (from LF to CRLF):
printf "System.IO.File.WriteAllText(\x0D\x0A\x22run.sh\x22, \x22\x22\x22\x0D\x0A#\x21/usr/bin/env bash\x0D\x0Adotnet run Program.cs\x0D\x0A\x0D\x0A\x22\x22\x22);\x0D\x0A" > Program.cs dotnet run Program.cs ./run.sh
Response (the shebang is now broken in the script file):
env: $'bash\r': No such file or directory env: use -[v]S to pass options in shebang lines
hexdump -C run.sh
Response:
00000000 23 21 2f 75 73 72 2f 62 69 6e 2f 65 6e 76 20 62 |#!/usr/bin/env b| 00000010 61 73 68 0d 0a 64 6f 74 6e 65 74 20 72 75 6e 20 |ash..dotnet run | 00000020 50 72 6f 67 72 61 6d 2e 63 73 0d 0a |Program.cs..| 0000002c
The '\r' in the script error message is the CR part of CRLF. It corresponds to the first '0d' byte seen in the hexdump). So the system tries to run a program called 'bash\r' instead of 'bash'. This is the same error as discussed earlier in this article.
Contact: