The previous lesson settled which files must not go into the repository. This one is about the complementary and less well known problem: the files that do go in, but that Git should not treat all in the same way.
And it starts with a very specific problem that Carla has been dragging along since module 1. Every time she opens styles.css in her Windows 11 editor, makes a one-line change and runs git diff, this is what appears:
styles.css | 486 +++++++++++++++++++++++++------------------------- 1 file changed, 243 insertions(+), 243 deletions(-)
243 lines modified for changing one. The whole file. And when she commits it, Ana and Bruno receive an unreviewable PR, git blame is ruined (lesson 06-03) and every merge generates conflicts that mean nothing. The culprit is line endings, and the definitive solution is .gitattributes.
This file is the least known and most useful tool in Git's repertoire. It lets you tell Git how to treat each type of file: how to normalise it when storing it, whether it is text or binary, how to show its differences, how to merge it and whether it should be included when exporting.
Contents
- What
.gitattributesis and how it differs from.gitignore - Syntax and precedence
- The line endings problem
text,eolandtext=auto.gitattributesversuscore.autocrlf- Renormalising a project that already has the problem
- Marking binaries with
-textandbinary - Custom diffs:
diff=<driver> - Making
@@show the function name textconv: seeing the text of binary files- Merge drivers and the
merge=unioncase export-ignoreandgit archive- Other attributes:
filter,linguist-*,export-subst - Summary table
- What
.gitattributes is and how it differs from .gitignore
.gitattributes is and how it differs from .gitignoreThe two files look alike — they live in the repository, they use file patterns, they are versioned — and they do completely different things:
.gitignore |
.gitattributes |
|
|---|---|---|
| Answers | Should I version this file? | How should I treat this file? |
| Affects | Untracked files | Versioned files |
| When it acts | git add, git status |
checkout, commit, diff, merge, archive |
| If you get it wrong | You push rubbish, or you fail to push something that was needed | Illegible diffs, absurd conflicts, corrupted files |
| Syntax | pattern |
pattern attribute1 attribute2 ... |
The shape of a .gitattributes line is always the same:
*.js text eol=lf
*.png binary
*.md diff=markdown merge=union
tests/ export-ignore
*.pdf -text diff=pdfThe four possible states of an attribute:
| Form | Meaning |
|---|---|
attribute |
Set (value true) |
-attribute |
Unset (value false) |
attribute=value |
With a specific value |
!attribute |
Unspecified: as if there were no rule |
- Syntax and precedence
The patterns are the same as in .gitignore (lesson 08-03): *, ?, **, the leading slash that anchors to the root, the trailing slash that restricts to directories. Everything you learnt there applies here.
The differences are in where Git looks and who wins:
| Location | Is it versioned? | Scope |
|---|---|---|
.gitattributes in the repository |
Yes | Its directory and subdirectories; it travels with the clone |
.git/info/attributes |
No | Only your copy of that repository |
The path in core.attributesFile |
No | All your repositories |
$(prefix)/etc/gitattributes |
No | The whole system |
Precedence, from lowest to highest: system → global → .git/info/attributes → the root .gitattributes → the .gitattributes files of deeper subdirectories. And within a single file, the last matching line wins, just as in .gitignore.
The row that matters is the first one: .gitattributes is versioned and travels with the repository. That property, which looks like an administrative detail, is exactly what solves Carla's problem, as we will see in section 5.
To find out which attributes actually apply to a file:
# One particular attribute, across several files
git check-attr text -- app.js images/logo.png
# Everything versioned, in one go
git ls-files | git check-attr --stdin -agit check-attr is to .gitattributes what git check-ignore -v is to .gitignore: the diagnostic tool. Use it whenever something does not behave as you expected.
- The line endings problem
To solve Carla's problem you have to understand its origin, which is historical and ridiculous.
A line break is encoded in two different ways depending on the system:
| System | Sequence | Name | Bytes |
|---|---|---|---|
| Linux, macOS, Unix | LF |
Line Feed | 0x0A (\n) |
| Windows, DOS | CRLF |
Carriage Return + Line Feed | 0x0D 0x0A (\r\n) |
It comes from typewriters: the carriage return brought the head back to the beginning and the line feed moved the paper down. Unix decided that one character was enough; DOS kept both. Fifty years later we are still paying for it.
As far as Git is concerned, \n and \r\n are different bytes, so a line that differs only in its line ending is a different line. Hence Carla's 243 insertions, 243 deletions: her editor saved the whole file with CRLF, and the 243 lines really did change, even though visually none of them did.
flowchart LR
A["Ana / Ubuntu<br/>saves with LF"] --> R[("Repository")]
B["Bruno / macOS<br/>saves with LF"] --> R
C["Carla / Windows<br/>saves with CRLF"] --> R
R --> D["Whole-file diffs<br/>Absurd conflicts<br/>blame ruined"]
The typical symptoms, all with the same cause:
git diffshows the whole file as modified with no visible change.git statusmarks as modified files you have not touched.- Merge conflicts on every line of a file that only one person touched.
git blameattributes everything to whoever last saved from a different system.- A shell script versioned from Windows fails on Linux with
bad interpreter: /bin/bash^M.
The conceptual solution is a convention that Git implements natively:
In the repository, everything is stored with
LF. In the working copy, everybody has what their system needs.
The conversion happens on commit (normalisation) and on checkout (denormalisation), and it is controlled by the text attribute.
text, eol and text=auto
text, eol and text=autoThe text attribute
| Declaration | On commit (add) |
On checkout |
|---|---|---|
text |
Converts CRLF → LF |
Converts LF → according to core.eol (by default, the system's native ending) |
text=auto |
Converts CRLF → LF only if Git detects that it is text |
The same, only if it is text |
-text |
Touches nothing | Touches nothing |
text eol=lf |
Converts to LF |
Always LF, on every system |
text eol=crlf |
Converts to LF |
Always CRLF, on every system |
The two important subtleties:
eol=lfdoes not mean "store LF in the repository": in the repository there is alwaysLFwhen the file istext.eolcontrols what gets written to your disk on checkout.text=autodecides by heuristic: Git examines the first 8,000 bytes and, if it finds a null byte, it considers the file binary. It is a good heuristic, but not infallible (section 7).
The recommended .gitattributes
This is the starting point that works for almost any project:
# Normalise to LF in the repository everything Git detects as text.
# It is the general safety net.
* text=autoAnd this is the explicit version, which is the one really worth having, because it does not depend on any heuristic:
# ============================================================
# General rule: normalise text, leave binaries alone
# ============================================================
* text=auto
# ============================================================
# Source code: LF in the repository and on disk too
# ============================================================
*.js text eol=lf
*.mjs text eol=lf
*.json text eol=lf
*.css text eol=lf
*.html text eol=lf
*.md text eol=lf
*.yml text eol=lf
*.yaml text eol=lf
*.svg text eol=lf
# ============================================================
# Files that REQUIRE LF even if you are on Windows
# ============================================================
*.sh text eol=lf
*.bash text eol=lf
Dockerfile text eol=lf
Makefile text eol=lf
.gitattributes text eol=lf
.gitignore text eol=lf
# ============================================================
# Files that REQUIRE CRLF even if you are on Linux
# ============================================================
*.bat text eol=crlf
*.cmd text eol=crlf
*.ps1 text eol=crlf
# ============================================================
# Binaries: do not touch under any circumstances
# ============================================================
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.webp binary
*.ico binary
*.pdf binary
*.zip binary
*.woff binary
*.woff2 binary
*.ttf binaryThe two middle sections are the ones that prevent real failures:
*.sh text eol=lf: a shell script withCRLFdoes not start on Linux. The interpreter looks for/bin/bashand finds/bin/bash\r. With this rule, even if Carla edits it on Windows, she will haveLFon her disk and it will work in the container.*.bat text eol=crlf: old Windows.batfiles needCRLF. Even if Ana edits them on Ubuntu, they will land on Carla's disk withCRLF.
.gitattributes versus core.autocrlf
.gitattributes versus core.autocrlfIn lesson 01-06 we saw core.autocrlf, which solves the same problem from the other side. It is time to compare the two approaches and explain why one of them wins.
core.autocrlf is machine configuration (lesson 01-05), with three values:
| Value | On commit | On checkout | Recommended for |
|---|---|---|---|
true |
CRLF → LF |
LF → CRLF |
Windows |
input |
CRLF → LF |
No conversion | Linux and macOS |
false |
Nothing | Nothing | The default; it leaves the problem as it is |
# What Carla configured back in the day
git config --global core.autocrlf true
# What Ana and Bruno configured
git config --global core.autocrlf inputIt works, and if everybody has it properly configured the problem disappears. The problem is that "if everybody has it properly configured".
core.autocrlf |
.gitattributes |
|
|---|---|---|
| Where it lives | In each machine's .gitconfig |
In the repository |
| Does it travel with the clone? | No | Yes |
| Who applies it? | Each person, if they remember | Everybody, automatically |
| Granularity | All or nothing, for every repository | Per file pattern |
Can it force LF on Windows? |
No | Yes, with eol=lf |
Can it force CRLF on Linux? |
No | Yes, with eol=crlf |
| If somebody has it wrong | It contaminates the repository for everybody | It does not matter: the attributes rule |
| Outside contributor (Diego) | Depends on his configuration | It applies to him as soon as he clones the fork |
| Priority | Lower | Higher: it always wins |
The last row is the technical key: when a file has the text attribute defined, core.autocrlf is ignored entirely for that file. The attributes win.
And the two rows above it are the practical ones. When Diego, the outside contributor, clones his fork of task-manager, nobody can ask him to configure his core.autocrlf before touching anything. With .gitattributes there is no need: the policy travels inside the repository and applies to him on its own.
The rule:
.gitattributesfor the project's policy;core.autocrlfas a personal safety net in repositories that do not have one. If you have.gitattributes,core.autocrlfis superfluous.
There is also a useful setting that catches the problem before it gets in:
# Refuse to commit a file that mixes CRLF and LF
git config --global core.safecrlf true
# Warn only, without blocking
git config --global core.safecrlf warn
- Renormalising a project that already has the problem
Adding the .gitattributes does not fix the past. The files already committed with CRLF still have CRLF inside the repository, and until somebody touches them the problem persists. You have to renormalise, and Git has a command for exactly that.
# 1. Make sure there are no uncommitted changes. This is non-negotiable.
git status
git stash push -u # if you need to set something aside (lesson 05-04)
# 2. Create or update the .gitattributes and commit it separately
git add .gitattributes
git commit -m "chore: define the line ending policy in .gitattributes"
# 3. Renormalise the whole repository
git add --renormalize .
# 4. Look at what is going to change
git status --short
git diff --cached --stat
# 5. Commit it in its OWN commit, isolated
git commit -m "chore: renormalise the line endings to LF
Applies the .gitattributes policy to the already versioned content.
This commit does not change a single line of code: it only replaces
CRLF with LF in the stored content. It is recorded in
.git-blame-ignore-revs.
Refs: GT-190"What git add --renormalize does
It rewrites the content of every tracked file in the index, applying the .gitattributes filters to the version held in the repository, without touching your working copy. It is the safe way of saying "apply the new policy to everything that is already inside".
What comes out of it is exactly the case in section 11 of lesson 08-02: a gigantic commit that does not change a single comma of behaviour and that ruins git blame. So it gets the same treatment:
# Record it so that blame sees through it
git rev-parse HEAD >> .git-blame-ignore-revs
git add .git-blame-ignore-revs
git commit -m "chore: record the renormalisation in blame-ignore-revs"Coordinating with the team
Renormalising touches every file, so any branch open at that moment will hit a massive conflict when it is merged. The sensible sequence:
- Give notice in advance and set a time.
- Have everybody merge or close their branches before that time.
- One person does the renormalisation in a PR of its own, with nothing else in it.
- It is merged as soon as it is approved, without leaving it open for days.
- Everybody runs
pulland, if they had live branches, rebases them onto the renormalisation commit. - If somebody has conflicts that are purely line endings, they are resolved in bulk:
# Keep the version from main and renormalise
git checkout --ours -- . # or --theirs, depending on the case
git add --renormalize .A very useful check beforehand, to see how much damage there is before you start:
i/lf w/crlf attr/text=auto styles.css i/crlf w/crlf attr/ index.html i/lf w/lf attr/text=auto app.js
It reads: i/ is the line ending in the index (the repository) and w/ the one in your working copy. The index.html line with i/crlf is the one that reveals the problem: there is CRLF inside the repository, which is what has to be renormalised.
- Marking binaries with
-text and binary
-text and binaryA binary file must never be converted. If Git applies line ending conversion to a PNG, it corrupts it: any 0x0D 0x0A byte pair inside the image data becomes 0x0A and the file stops being valid.
text=auto detects most binaries by the null-byte heuristic, but it fails in real cases: compressed files with no null bytes in the first 8 KB, mixed data formats, font files with a text header. Declaring them explicitly is cheaper than discovering the problem.
binary is a macro attribute: it is exactly equivalent to writing
That is, three things at once:
| Attribute | Effect |
|---|---|
-text |
Do not convert line endings (prevents the corruption) |
-diff |
git diff does not try to show the content; it says Binary files differ |
-merge |
In a conflict, it does not try to merge line by line |
The effect of -merge is worth explaining, because it changes what you see in a conflict. Without it, Git would try to blend two versions of a PNG and would produce a corrupt file with <<<<<<< markers inside it. With it, the conflict is presented as a choice between the two whole versions:
warning: Cannot merge binary files: images/logo.png (HEAD vs. design-branch) Auto-merging images/logo.png CONFLICT (content): Merge conflict in images/logo.png
And it is resolved by choosing one of the two, as we saw in lesson 03-05:
git checkout --ours images/logo.png # the one from my branch
git checkout --theirs images/logo.png # the one from the other branch
git add images/logo.pngWhen to use plain -text instead of binary
When the file is not normalisable text but you do want to be able to see it in a diff. The typical case is a large SVG or a data file with a fixed encoding:
And there is a special case worth knowing about: a text file in a two-bytes-per-character encoding, such as UTF-16, contains null bytes and Git will classify it as binary. It is fixed with working-tree-encoding:
Git will store the content as UTF-8 inside the repository (where diffs work) and will write it out as UTF-16LE on your disk.
- Custom diffs:
diff=<driver>
diff=<driver>Git knows how to show differences in plain text. With diff=<driver> you can give it context about the type of file, and the result is far better.
The built-in drivers
Git ships with function recognisers for many languages. All you have to do is declare them:
*.js diff=javascript
*.ts diff=typescript
*.css diff=css
*.html diff=html
*.md diff=markdown
*.py diff=python
*.java diff=java
*.rb diff=ruby
*.php diff=php
*.go diff=golang
*.rs diff=rust
*.tex diff=tex
*.json diff=jsonThe complete list is in git help attributes. There are drivers for Ada, Bash, C/C++, C#, Dts, Elixir, Fortran, Fountain, Kotlin, MATLAB, Objective-C, Perl, Scheme and a few more.
- Making
@@ show the function name
@@ show the function nameHere is the concrete benefit. Remember from lesson 02-05 that every hunk of a diff starts with an @@ line:
Without a driver:
@@ -142,7 +142,7 @@
const labels = task.labels || [];
- if (labels.includes(filter)) {
+ if (labels.some(l => l.toLowerCase() === filter.toLowerCase())) {
return true;
}With *.js diff=javascript:
@@ -142,7 +142,7 @@ function matchesFilter(task, filter) {
const labels = task.labels || [];
- if (labels.includes(filter)) {
+ if (labels.some(l => l.toLowerCase() === filter.toLowerCase())) {
return true;
}Git has added function matchesFilter(task, filter) at the end of the @@ line. Now you know which function you are reading without opening the file. Multiplied by the forty hunks of a review, it is the difference between reviewing with context and reviewing blind.
That context appears automatically in git diff, git show, git log -p and in the review platforms. And it also works with the search-by-function of lesson 02-05:
Without the driver, -L :name: cannot find the function.
Defining your own recogniser
If your language has no driver, or the built-in one does not recognise your style, you define one with a regular expression. There are two pieces: the declaration in .gitattributes (versioned) and the definition in Git's configuration (local, because .gitattributes cannot contain commands).
# Define which lines count as a "function header"
git config --local diff.jsmodern.xfuncname \
'^[[:space:]]*((export[[:space:]]+)?(async[[:space:]]+)?function[[:space:]]+[A-Za-z_$][A-Za-z0-9_$]*|const[[:space:]]+[A-Za-z_$][A-Za-z0-9_$]*[[:space:]]*=[[:space:]]*(async[[:space:]]*)?\().*$'The expression recognises function name(, export function name(, async function name( and const name = ( or const name = async (, which covers the modern JavaScript style.
Remember the underlying limitation: .gitattributes declares which driver to use; the definition of the driver lives in git config and is not versioned. Document it in the README.md or in the repository's bootstrap script, alongside core.hooksPath and blame.ignoreRevsFile from the previous lessons.
textconv: seeing the text of binary files
textconv: seeing the text of binary filesThere are binaries that contain text: a PDF, a .docx, a spreadsheet, an image file with metadata. By default, git diff on them says Binary files differ, which is true and useless.
textconv gives Git a command that turns the binary into text, and Git compares that text:
# Definitions (local, not versioned)
git config --local diff.pdf.textconv "pdftotext -layout"
git config --local diff.word.textconv "pandoc --to=plain"
git config --local diff.exif.textconv "exiftool"
# Cache the result: the conversion is expensive
git config --local diff.pdf.cachetextconv trueEach command receives the path of the file as an argument and must write text to standard output.
Now, if Bruno changes the user manual:
diff --git a/manual.pdf b/manual.pdf
index 3a4b5c6..7d8e9f0 100644
--- a/manual.pdf
+++ b/manual.pdf
@@ -12,7 +12,7 @@
To create a task, click the "New task" button.
-The title is required and accepts up to 80 characters.
+The title is required and accepts up to 200 characters.
You can assign labels separated by commas.What you have to understand clearly: textconv affects display only. The stored content is still the complete binary PDF, and merges still cannot be done. It is a reading lens, not a change of format.
A practical requirement: the external tool (pdftotext, pandoc, exiftool) has to be installed. If it is not, git diff fails with an error. That is why textconv is a good candidate for the local .git/info/attributes of whoever has those tools, rather than for the shared .gitattributes.
- Merge drivers and the
merge=union case
merge=union caseJust as with diffs, you can change how a type of file is merged.
The problem
Every week, Ana, Bruno and Carla add a line to the CHANGELOG.md, each on their own branch, always at the top:
On merging, a guaranteed conflict, three times a week. And it is a false conflict: all three lines should stay, there is no decision to be made.
merge=union
union is a built-in driver (nothing has to be defined) which, faced with a conflict, keeps the lines from both sides, with no markers. In this order: first those of the base branch, then those of the other side.
With this, the merge produces:
## Unreleased
- Add the filter by label (GT-134)
- Store the tasks in IndexedDB (GT-137)
- Fix the order of overdue tasks (GT-141)No conflict and no intervention.
When to use it: files that are cumulative lists and where the order is not semantic.
| Good candidate | Bad candidate |
|---|---|
CHANGELOG.md, AUTHORS, CONTRIBUTORS |
Any code file |
| Log files that get versioned | package.json (two versions of the same dependency = invalid JSON) |
| Lists of rules where the order does not matter | Any structured configuration file |
A serious warning:
merge=unionmust never be applied to code. It combines the two versions of a function and produces something syntactically broken without warning you about anything. Its silence is both its advantage and its danger: it removes the conflict, it does not resolve it.
The other built-in drivers
| Driver | Behaviour |
|---|---|
merge=text |
The normal three-way merge behaviour (lesson 03-03) |
merge=binary |
It does not try to merge; a conflict so you choose one whole version |
merge=union |
Keeps the lines from both sides, with no markers |
-merge |
Equivalent to merge=binary |
A driver of your own
For cases such as "in a conflict over a generated file, keep mine and regenerate it":
git config --local merge.npm-lock.name "Regenerate the npm lockfile"
git config --local merge.npm-lock.driver \
'npm install --package-lock-only --silent && cp package-lock.json %A'Git substitutes the placeholders in the command: %O is the common base version, %A is yours (and where the result must end up), %B the one from the other side and %L the size of the conflict marker. The driver must return 0 if it resolved things and non-zero if they have to be resolved by hand.
And a warning: if Diego clones the fork and does not have that local configuration, Git will use the default driver. Your own drivers degrade silently, so they must not be the only line of defence.
export-ignore and git archive
export-ignore and git archivegit archive packages the content of a commit into a .tar or a .zip, without the .git directory. It is the standard way of producing a distributable package:
git archive --format=zip --output=/tmp/task-manager-1.5.0.zip v1.5.0
git archive --format=tar.gz --prefix=task-manager/ -o /tmp/pkg.tar.gz HEADThe export-ignore attribute marks what must not go into that package. It is different from .gitignore: these files are versioned; they are simply of no interest to whoever downloads the package.
# None of this makes sense in a distributable package
.gitattributes export-ignore
.gitignore export-ignore
.github/ export-ignore
.githooks/ export-ignore
tests/ export-ignore
docs/internal/ export-ignore
.editorconfig export-ignore
CONTRIBUTING.md export-ignore
.git-blame-ignore-revs export-ignoreCheck the result before publishing:
A very practical use: hosting platforms automatically generate the .zip and the .tar.gz for each tag using git archive, so export-ignore controls what your release packages contain without you having to do anything else.
export-subst
It substitutes placeholders with information about the commit on export:
When you run git archive, the file in the package comes out with the values filled in:
It is the clean way for a distributed package to know which commit it came from, and it combines nicely with the annotated tags of lesson 05-05. Careful: the placeholders are only substituted in the package, not in your working copy.
- Other attributes:
filter, linguist-*, export-subst
filter, linguist-*, export-substfilter: transforming on the way in and on the way out
The filter attribute defines a pair of conversions: clean on commit (from your disk to the repository) and smudge on checkout (from the repository to your disk).
flowchart LR
W["Working copy"] -->|"clean filter<br/>(git add)"| R[("Repository")]
R -->|"smudge filter<br/>(git checkout)"| W
Its most important use by a long way is Git LFS, which stores large files outside the repository and leaves a text pointer inside:
*.psd filter=lfs diff=lfs merge=lfs -text
*.mp4 filter=lfs diff=lfs merge=lfs -text
*.zip filter=lfs diff=lfs merge=lfs -textThose lines are written for you by git lfs track. The filter=lfs replaces the file with a pointer on commit and recovers it on checkout. Git LFS is the content of lesson 10-03, where the full mechanism, the storage server and its implications are explained; here all that matters is knowing that it is .gitattributes that activates it.
An example of your own, to understand the mechanism (although in practice it is better not to use it):
git config --local filter.trim-trailing-space.clean "sed 's/[[:space:]]*$//'"
git config --local filter.trim-trailing-space.smudge catBe careful with your own filters. If the filter is not idempotent, or if somebody clones without the configuration, the content goes out of step: git status marks files as modified without anybody having touched them. It is a classic source of confusion and that is why it is best to reserve them for cases where there is no alternative.
linguist-*: how the platform classifies you
Hosting platforms use a library called Linguist to work out which languages your project is written in and what to show in the diff view. It is tuned with these attributes:
# Do not count this in the language statistics
vendor/* linguist-vendored
docs/examples/* linguist-documentation
# Do count it, even though it is in a directory that looks third-party
lib/own.js -linguist-vendored
# Correct a wrong detection
*.inc linguist-language=PHP
# Collapse by default in the diff view
*.min.js linguist-generated
package-lock.json linguist-generatedlinguist-generated is the most useful one day to day: it makes generated files appear collapsed in reviews, which instantly removes the noise of an 8,000-line package-lock.json in a PR. It links directly to what we said about PR size in lesson 07-02.
These attributes do not affect Git: they are conventions interpreted by the platforms. If your git.example.com server does not use Linguist, they do nothing.
- Summary table
| Attribute | What it does | Example |
|---|---|---|
text |
Normalises to LF in the repository |
*.js text |
text=auto |
Normalises only if it detects text | * text=auto |
-text |
Never convert | *.png -text |
eol=lf |
On disk, always LF |
*.sh text eol=lf |
eol=crlf |
On disk, always CRLF |
*.bat text eol=crlf |
binary |
Macro: -text -diff -merge |
*.pdf binary |
diff=<driver> |
Function context in @@, or textconv |
*.css diff=css |
-diff |
Do not show the content in diffs | *.zip -diff |
merge=union |
Keeps the lines from both sides | CHANGELOG.md merge=union |
merge=<driver> |
Your own merge strategy | *.lock merge=lock |
-merge |
No automatic merge: choose one version | *.png -merge |
filter=<f> |
Transforms on the way in and on the way out | *.psd filter=lfs |
export-ignore |
Exclude from git archive |
tests/ export-ignore |
export-subst |
Substitute $Format:...$ on export |
VERSION.txt export-subst |
working-tree-encoding |
A different encoding on disk | *.rc working-tree-encoding=UTF-16LE |
whitespace |
What counts as a whitespace error | *.py whitespace=tab-in-indent |
linguist-generated |
Collapse in the diff view | *.min.js linguist-generated |
linguist-vendored |
Exclude from the statistics | vendor/* linguist-vendored |
The .gitattributes of task-manager
# ============================================================
# Line endings (resolves GT-190)
# ============================================================
* text=auto
*.js text eol=lf diff=javascript
*.css text eol=lf diff=css
*.html text eol=lf diff=html
*.json text eol=lf
*.md text eol=lf diff=markdown
*.yml text eol=lf
*.sh text eol=lf
*.bat text eol=crlf
# ============================================================
# Binaries
# ============================================================
*.png binary
*.jpg binary
*.webp binary
*.ico binary
*.woff2 binary
*.pdf binary diff=pdf
# ============================================================
# Special merging
# ============================================================
CHANGELOG.md merge=union
# ============================================================
# Diff view on the platform
# ============================================================
package-lock.json linguist-generated
*.min.js linguist-generated
*.min.css linguist-generated
# ============================================================
# Out of the distributable package
# ============================================================
.gitattributes export-ignore
.gitignore export-ignore
.git-blame-ignore-revs export-ignore
.github/ export-ignore
.githooks/ export-ignore
tests/ export-ignore
CONTRIBUTING.md export-ignoreCommon Mistakes and Tips
Mistake 1: creating the .gitattributes and thinking that is that. It does not fix content that is already committed. You need git add --renormalize . in a separate commit.
Mistake 2: renormalising mixed with other changes. It produces an illegible commit which, on top of that, cannot be recorded in .git-blame-ignore-revs, because ignoring it would also hide the functional change. Isolate it, always.
Mistake 3: renormalising without telling the team. Every open branch hits a massive conflict. Coordinate it, do it in its own PR and merge it quickly.
Mistake 4: relying on core.autocrlf alone. It does not travel with the repository. It is enough for Diego not to have it configured for the problem to come back.
Mistake 5: applying merge=union to code. It combines the two versions of a function and produces broken code without warning. Only for cumulative lists.
Mistake 6: not marking the binaries. text=auto gets it right nearly always, but when it fails it corrupts the file. Declaring them costs one line.
Mistake 7: forgetting that driver definitions are not versioned. .gitattributes says diff=jsmodern; the definition lives in git config and does not travel. Document it in the README.md.
Mistake 8: export-ignore on files that people do need. Always check the result with git archive --format=tar HEAD | tar -t.
Tip 1: * text=auto in the first commit of every new project. One line that avoids years of problems.
Tip 2: explicit eol=lf on scripts. *.sh text eol=lf avoids the bad interpreter: /bin/bash^M that shows up the first time a script edited on Windows reaches a container.
Tip 3: git check-attr -a <file> whenever something does not add up. And git ls-files --eol to see the real state of the repository's line endings.
Tip 4: declare the diff drivers for your languages. It is the cheapest readability improvement in reviews: the function name on every @@.
Tip 5: linguist-generated on the lockfiles. It collapses 8,000 lines of noise in every PR.
Tip 6: for large binaries, .gitattributes is not the answer. Marking them as binary avoids corrupting them, but they still take up the same space in the history. That is solved by Git LFS (lesson 10-03) and by the tips in the next lesson.
Exercises
Exercise 1: reproducing and fixing Carla's problem
- Create a repository with a 10-line
styles.css, saved withLF, and commit it without a.gitattributes. - Simulate saving from Windows by converting the file to
CRLF:sed -i 's/$/\r/' styles.css - Run
git diff --statand check that the 10 lines appear as modified. - Check the real state with
git ls-files --eol. - Add a
.gitattributeswith*.css text eol=lf, commit it, and show that the diff is still just as broken until you renormalise. - Renormalise properly and verify with
git ls-files --eolthat the index ends up withi/lf.
Exercise 2: diffs with function context
- Create an
app.jswith three functions of about five lines each and commit it. - Modify a line inside the second function and run
git diff. Look at the@@line. - Add
*.js diff=javascriptto the.gitattributesand repeat thegit diff. Compare. - Try
git log -L :theFunctionName:app.jsand explain what it does. - Add a
*.pdf diff=pdfwith atextconv(usepdftotextif you have it, or simulate one with a script of your own that converts a made-up format into text) and show thatgit diffdisplays the content.
Exercise 3: merge=union and export-ignore
- Create a repository with a
CHANGELOG.mdthat has a heading and one entry. - Create two branches and add a different entry in each one in the same position, at the top of the list.
- Merge the second into the first and check the conflict.
- Abort the merge, add
CHANGELOG.md merge=unionto the.gitattributes, commit it and repeat the merge. Verify the result. - Explain why this same configuration on
app.jswould be dangerous, with a concrete example of broken code. - Add
tests/ export-ignoreand.gitattributes export-ignore, and show withgit archive --format=tar HEAD | tar -tthat they do not appear in the package.
Solutions
Solution 1:
mkdir /tmp/practice-eol && cd /tmp/practice-eol && git init -b main
printf '.task {\n color: #333;\n padding: 8px;\n}\n.task.done {\n opacity: 0.5;\n}\n.task.overdue {\n border-left: 3px solid #c00;\n}\n' > styles.css
git add . && git commit -m "feat(css): base styles"Ten lines modified without anything visible having changed. Exactly Carla's problem.
i/lf: the index has LF. w/crlf: the disk has CRLF. attr/ empty: there is no rule being applied.
# 5. The .gitattributes on its own fixes nothing
echo "*.css text eol=lf" > .gitattributes
git add .gitattributes && git commit -m "chore: line ending policy"
git diff --statStill the same. The attribute will apply from now on, but the content on disk is still CRLF and Git still sees a difference.
git commit -m "chore: renormalise the line endings to LF"
git checkout -- . # force the disk to be rewritten according to the attributes
git ls-files --eolIndex LF, disk LF, attribute applied. git diff is clean again and, from now on, even if Carla saves with CRLF, Git will normalise it on commit.
Solution 2:
mkdir /tmp/practice-diff && cd /tmp/practice-diff && git init -b main
cat > app.js <<'EOF'
function createTask(title, labels) {
if (!title) throw new Error("The title is required");
const id = Date.now().toString(36);
return { id, title, labels: labels || [], done: false };
}
function matchesFilter(task, filter) {
if (!filter) return true;
const labels = task.labels || [];
return labels.includes(filter);
}
function countPending(tasks) {
return tasks.filter(t => !t.done).length;
}
EOF
git add . && git commit -m "feat(app): base functions"# 2. Without a driver
sed -i 's/return labels.includes(filter);/return labels.some(l => l.toLowerCase() === filter.toLowerCase());/' app.js
git diff@@ -7,7 +7,7 @@
function matchesFilter(task, filter) {
if (!filter) return true;
const labels = task.labels || [];
- return labels.includes(filter);
+ return labels.some(l => l.toLowerCase() === filter.toLowerCase());
}The @@ line is bare. Here the function happens to be visible because the hunk is small; with three lines of context inside a sixty-line function, it would not be.
Git has added the function signature. A check that the attribute is being applied:
# 4. The history of one function
git add . && git commit -m "refactor(filters): compare labels case-insensitively"
git log -L :matchesFilter:app.jsIt shows only the evolution of that function throughout the history, with the diff of every change that touched it. To locate the function, -L :name: uses the same recogniser that diff=javascript declares; without it, searching by name does not work reliably.
# 5. textconv with a made-up format
cat > /tmp/show-made-up.sh <<'EOF'
#!/usr/bin/env bash
# Turns a fictitious binary format into text: base64 inside the file
base64 -d "$1" 2>/dev/null || echo "(not readable)"
EOF
chmod +x /tmp/show-made-up.sh
echo "*.inv diff=madeup" >> .gitattributes
git config --local diff.madeup.textconv /tmp/show-made-up.sh
git config --local diff.madeup.cachetextconv true
echo "Task manager manual, version 1" | base64 > manual.inv
git add . && git commit -m "docs: add the manual"
echo "Task manager manual, version 2" | base64 > manual.inv
git diff manual.invdiff --git a/manual.inv b/manual.inv
--- a/manual.inv
+++ b/manual.inv
@@ -1 +1 @@
-Task manager manual, version 1
+Task manager manual, version 2Git compares the output of the command, not the bytes. The stored content is still the complete original file.
Solution 3:
mkdir /tmp/practice-union && cd /tmp/practice-union && git init -b main
cat > CHANGELOG.md <<'EOF'
# Changelog
## Unreleased
- Release the initial version (GT-100)
EOF
git add . && git commit -m "docs: add the changelog"# 2. Two branches, two entries in the same position
git switch -c GT-134
sed -i '4i - Add the filter by label (GT-134)' CHANGELOG.md
git commit -am "docs: record GT-134"
git switch main && git switch -c GT-137
sed -i '4i - Store the tasks in IndexedDB (GT-137)' CHANGELOG.md
git commit -am "docs: record GT-137"## Unreleased
<<<<<<< HEAD
- Add the filter by label (GT-134)
=======
- Store the tasks in IndexedDB (GT-137)
>>>>>>> GT-137
- Release the initial version (GT-100)# 4. With merge=union
git merge --abort
echo "CHANGELOG.md merge=union" > .gitattributes
git add . && git commit -m "chore: union merge for the changelog"
git merge GT-137
cat CHANGELOG.md# Changelog
## Unreleased
- Add the filter by label (GT-134)
- Store the tasks in IndexedDB (GT-137)
- Release the initial version (GT-100)No conflict and with both entries. (If merge fails because it cannot find the attributes file in the base commit, commit the .gitattributes on main first and rebase the branches: the attributes are read from the working copy at the moment of the merge.)
5. Why it would be dangerous in code. Suppose Ana and Bruno modify the same function:
// Ana's branch
function countPending(tasks) {
return tasks.filter(t => !t.done).length;
}
// Bruno's branch
function countPending(tasks) {
return tasks.filter(t => !t.done && !t.archived).length;
}With merge=union, the result would be:
function countPending(tasks) {
return tasks.filter(t => !t.done).length;
return tasks.filter(t => !t.done && !t.archived).length;
}Syntactically valid, silently wrong: the second line is dead code and Bruno's change is lost without anybody noticing. In other cases the result does not even compile, and in the worst case — two versions of a condition or of a brace — it produces code that does something neither of them wanted. And all of it with no conflict to warn you.
That is the key point: merge=union removes the conflict, it does not resolve it. It is only acceptable when "keep both lines" is always the right answer, that is, in cumulative lists where the order is not semantic.
# 6. export-ignore
mkdir tests && echo "test('ok', () => {});" > tests/app.test.js
cat >> .gitattributes <<'EOF'
tests/ export-ignore
.gitattributes export-ignore
EOF
git add . && git commit -m "chore: exclude the tests from the distributable package"
git archive --format=tar HEAD | tar -tNeither tests/ nor .gitattributes appears: they are versioned, but outside the package.
Conclusion
The essentials of this lesson:
.gitignoredecides what to version;.gitattributesdecides how to treat what is versioned. It acts oncheckout,commit,diff,mergeandarchive, and its diagnostic tool isgit check-attr -a.- The line endings problem — Carla's — is solved with a convention:
LFin the repository, the native ending on disk. It is declared withtext,text=autoandeol=lf/eol=crlf, andeolcontrols the disk, not the repository. .gitattributesbeatscore.autocrlfand, above all, travels with the repository. That is the underlying reason why it is the robust solution: the policy applies to everybody, including Diego with his fork, without depending on anybody configuring anything.- Adding the file does not fix the past: you need
git add --renormalize ., in an isolated commit, coordinated with the team and recorded in.git-blame-ignore-revs(lesson 08-02).git ls-files --eolshows the real state before and after. binaryis the macro for-text -diff -mergeand prevents Git from corrupting a binary file by trying to convert its bytes. Declaring binaries explicitly costs one line and saves surprises.diff=<driver>makes the@@line show the function name, and enablesgit log -L :function:file.textconvlets you see the content of a binary as text, for display only.merge=unionkeeps the lines from both sides and removes the false conflicts inCHANGELOG.md. Never on code: it removes the conflict, it does not resolve it.export-ignorecontrols what thegit archivepackages contain — including the ones the platforms generate for each tag — andexport-substfills in the version and the commit in the package.filter=lfsis the gateway to Git LFS, whose full mechanism is covered in lesson 10-03; and thelinguist-*attributes, although they do not affect Git, collapse the noise of generated files in reviews.
task-manager no longer versions what it should not, and treats each file as it deserves. What remains is the most serious of the matters we announced at the close of module 7, and the one that admits no improvisation: the configuration file with the database password that has been sitting in the history for months.
Getting it out of there has a procedure whose order of steps matters enormously, and it starts with something that has nothing to do with Git. That is lesson 08-05: Security Best Practices.
Mastering Git: From Beginner to Advanced
Module 1: Introduction to Git
- What Is Git?
- Installing Git
- Basic Git Terminology
- The Git Data Model
- Configuring Git
- Initial Configuration
Module 2: Basic Git Operations
- Creating a Repository
- Cloning a Repository
- The Basic Git Workflow
- Staging and Committing Changes
- Inspecting Changes with git diff
- Viewing Commit History
Module 3: Branching and Merging
- Understanding Branches
- Creating and Switching Branches
- Merging Branches
- Merge Strategies
- Resolving Merge Conflicts
- Branch Management
Module 4: Working with Remote Repositories
- Understanding Remote Repositories
- Adding a Remote Repository
- Authenticating with Remote Repositories
- Fetching and Pulling Changes
- Pushing Changes
- Tracking Branches
Module 5: Advanced Git Operations
Module 6: Git Tools and Techniques
- Using Git Hooks
- Git Bisect
- Git Blame
- Git Log and Aliases
- Git Submodules
- Multiple Working Copies with git worktree
Module 7: Collaboration and Workflow Strategies
- Forks and Pull Requests
- Code Reviews with Git
- The Git Flow Workflow
- GitHub Flow
- Trunk Based Development
- Continuous Integration with Git
Module 8: Git Best Practices and Tips
- Writing Good Commit Messages
- Keeping a Clean History
- Ignoring Files with .gitignore
- File Attributes with .gitattributes
- Security Best Practices
- Performance Tips
Module 9: Troubleshooting and Debugging
- Common Git Problems
- Undoing Changes
- Resolving Divergence with the Remote
- Recovering Lost Commits
- Dealing with Corrupted Repositories
- Advanced Debugging Techniques
