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

  1. What .gitattributes is and how it differs from .gitignore
  2. Syntax and precedence
  3. The line endings problem
  4. text, eol and text=auto
  5. .gitattributes versus core.autocrlf
  6. Renormalising a project that already has the problem
  7. Marking binaries with -text and binary
  8. Custom diffs: diff=<driver>
  9. Making @@ show the function name
  10. textconv: seeing the text of binary files
  11. Merge drivers and the merge=union case
  12. export-ignore and git archive
  13. Other attributes: filter, linguist-*, export-subst
  14. Summary table

  1. What .gitattributes is and how it differs from .gitignore

The 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:

<pattern>  <attribute>  <attribute>=<value>  -<attribute>
*.js        text eol=lf
*.png       binary
*.md        diff=markdown merge=union
tests/      export-ignore
*.pdf       -text diff=pdf

The 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

  1. 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:

# All the attributes of a file
git check-attr -a styles.css
styles.css: text: set
styles.css: eol: lf
styles.css: diff: css
# 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 -a

git 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.

  1. 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 diff shows the whole file as modified with no visible change.
  • git status marks as modified files you have not touched.
  • Merge conflicts on every line of a file that only one person touched.
  • git blame attributes 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.

  1. text, eol and text=auto

The text attribute

Declaration On commit (add) On checkout
text Converts CRLFLF Converts LF → according to core.eol (by default, the system's native ending)
text=auto Converts CRLFLF 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=lf does not mean "store LF in the repository": in the repository there is always LF when the file is text. eol controls what gets written to your disk on checkout.
  • text=auto decides 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=auto

And 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      binary

The two middle sections are the ones that prevent real failures:

  • *.sh text eol=lf: a shell script with CRLF does not start on Linux. The interpreter looks for /bin/bash and finds /bin/bash\r. With this rule, even if Carla edits it on Windows, she will have LF on her disk and it will work in the container.
  • *.bat text eol=crlf: old Windows .bat files need CRLF. Even if Ana edits them on Ubuntu, they will land on Carla's disk with CRLF.

  1. .gitattributes versus core.autocrlf

In 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 CRLFLF LFCRLF Windows
input CRLFLF 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 input

It 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: .gitattributes for the project's policy; core.autocrlf as a personal safety net in repositories that do not have one. If you have .gitattributes, core.autocrlf is 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

  1. 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:

  1. Give notice in advance and set a time.
  2. Have everybody merge or close their branches before that time.
  3. One person does the renormalisation in a PR of its own, with nothing else in it.
  4. It is merged as soon as it is approved, without leaving it open for days.
  5. Everybody runs pull and, if they had live branches, rebases them onto the renormalisation commit.
  6. 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:

# Lists the versioned files that contain CRLF
git ls-files --eol | grep 'w/crlf'
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.

  1. Marking binaries with -text and binary

A 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.

*.png   binary
*.pdf   binary
*.zip   binary

binary is a macro attribute: it is exactly equivalent to writing

*.png   -text -diff -merge

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.png

When 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:

*.svg   -text diff

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:

*.rc    working-tree-encoding=UTF-16LE text eol=crlf

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.

  1. Custom diffs: 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=json

The 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.

  1. Making @@ show the function name

Here 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:

# The whole history of one particular function
git log -L :matchesFilter:app.js

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).

# .gitattributes
*.js   diff=jsmodern
# 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.

  1. textconv: seeing the text of binary files

There 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:

*.pdf    diff=pdf
*.docx   diff=word
*.png    diff=exif
# 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 true

Each command receives the path of the file as an argument and must write text to standard output.

Now, if Bruno changes the user manual:

git diff manual.pdf
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.

  1. Merge drivers and the merge=union case

Just 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:

## Unreleased
- Add the filter by label (GT-134)

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

CHANGELOG.md   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=union must 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":

package-lock.json   merge=npm-lock
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.

  1. export-ignore and git archive

git 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 HEAD

The 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-ignore

Check the result before publishing:

git archive --format=tar HEAD | tar -t

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:

VERSION.txt   export-subst
Version: $Format:%(describe:tags)$
Commit:  $Format:%H$
Date:    $Format:%cI$

When you run git archive, the file in the package comes out with the values filled in:

Version: v1.5.0
Commit:  9f8e7d6c5b4a3f2e1d0c9b8a7f6e5d4c3b2a1f0e
Date:    2026-08-01T10:22:14+02:00

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.

  1. Other attributes: filter, linguist-*, export-subst

filter: 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 -text

Those 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):

*.md   filter=trim-trailing-space
git config --local filter.trim-trailing-space.clean "sed 's/[[:space:]]*$//'"
git config --local filter.trim-trailing-space.smudge cat

Be 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-generated

linguist-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.

  1. 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-ignore

Common 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

  1. Create a repository with a 10-line styles.css, saved with LF, and commit it without a .gitattributes.
  2. Simulate saving from Windows by converting the file to CRLF:
    sed -i 's/$/\r/' styles.css
    
  3. Run git diff --stat and check that the 10 lines appear as modified.
  4. Check the real state with git ls-files --eol.
  5. Add a .gitattributes with *.css text eol=lf, commit it, and show that the diff is still just as broken until you renormalise.
  6. Renormalise properly and verify with git ls-files --eol that the index ends up with i/lf.

Exercise 2: diffs with function context

  1. Create an app.js with three functions of about five lines each and commit it.
  2. Modify a line inside the second function and run git diff. Look at the @@ line.
  3. Add *.js diff=javascript to the .gitattributes and repeat the git diff. Compare.
  4. Try git log -L :theFunctionName:app.js and explain what it does.
  5. Add a *.pdf diff=pdf with a textconv (use pdftotext if you have it, or simulate one with a script of your own that converts a made-up format into text) and show that git diff displays the content.

Exercise 3: merge=union and export-ignore

  1. Create a repository with a CHANGELOG.md that has a heading and one entry.
  2. Create two branches and add a different entry in each one in the same position, at the top of the list.
  3. Merge the second into the first and check the conflict.
  4. Abort the merge, add CHANGELOG.md merge=union to the .gitattributes, commit it and repeat the merge. Verify the result.
  5. Explain why this same configuration on app.js would be dangerous, with a concrete example of broken code.
  6. Add tests/ export-ignore and .gitattributes export-ignore, and show with git archive --format=tar HEAD | tar -t that 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"
# 2 and 3. Simulate saving from Windows
sed -i 's/$/\r/' styles.css
git diff --stat
 styles.css | 20 ++++++++++----------
 1 file changed, 10 insertions(+), 10 deletions(-)

Ten lines modified without anything visible having changed. Exactly Carla's problem.

# 4. The real state
git ls-files --eol
i/lf    w/crlf  attr/                styles.css

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 --stat
 styles.css | 20 ++++++++++----------

Still the same. The attribute will apply from now on, but the content on disk is still CRLF and Git still sees a difference.

# 6. Renormalise
git add --renormalize .
git status --short
M  styles.css
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 --eol
i/lf    w/lf    attr/text eol=lf    styles.css

Index 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.

# 3. With a driver
echo "*.js diff=javascript" > .gitattributes
git diff
@@ -7,7 +7,7 @@ function matchesFilter(task, filter) {

Git has added the function signature. A check that the attribute is being applied:

git check-attr -a app.js
app.js: diff: javascript
# 4. The history of one function
git add . && git commit -m "refactor(filters): compare labels case-insensitively"
git log -L :matchesFilter:app.js

It 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.inv
diff --git a/manual.inv b/manual.inv
--- a/manual.inv
+++ b/manual.inv
@@ -1 +1 @@
-Task manager manual, version 1
+Task manager manual, version 2

Git 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"
# 3. The conflict
git switch GT-134
git merge GT-137
Auto-merging CHANGELOG.md
CONFLICT (content): Merge conflict in CHANGELOG.md
## 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 -t
CHANGELOG.md

Neither tests/ nor .gitattributes appears: they are versioned, but outside the package.

Conclusion

The essentials of this lesson:

  • .gitignore decides what to version; .gitattributes decides how to treat what is versioned. It acts on checkout, commit, diff, merge and archive, and its diagnostic tool is git check-attr -a.
  • The line endings problem — Carla's — is solved with a convention: LF in the repository, the native ending on disk. It is declared with text, text=auto and eol=lf/eol=crlf, and eol controls the disk, not the repository.
  • .gitattributes beats core.autocrlf and, 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 --eol shows the real state before and after.
  • binary is the macro for -text -diff -merge and 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 enables git log -L :function:file. textconv lets you see the content of a binary as text, for display only.
  • merge=union keeps the lines from both sides and removes the false conflicts in CHANGELOG.md. Never on code: it removes the conflict, it does not resolve it.
  • export-ignore controls what the git archive packages contain — including the ones the platforms generate for each tag — and export-subst fills in the version and the commit in the package.
  • filter=lfs is the gateway to Git LFS, whose full mechanism is covered in lesson 10-03; and the linguist-* 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

Module 2: Basic Git Operations

Module 3: Branching and Merging

Module 4: Working with Remote Repositories

Module 5: Advanced Git Operations

Module 6: Git Tools and Techniques

Module 7: Collaboration and Workflow Strategies

Module 8: Git Best Practices and Tips

Module 9: Troubleshooting and Debugging

Module 10: Git in the Real World

© Copyright 2026. All rights reserved