How Git decides, in order
The order is the whole subject, and it is not the one that reads naturally. Git does not walk your file from the top taking the last match; it walks the stack backwards and keeps the first answer it meets for each attribute.
- Collect the
.gitattributesfiles from the repository root down to the file's own directory. A path's ancestors are never evaluated — only the attributes files along the way. There is no counterpart to.gitignore's parent-directory rule. - Read that stack from the deepest file first, then the file above it, and so on up to the
root. The nearest
.gitattributestherefore wins — but only for the attributes it actually mentions. - Inside a file, read the lines from the bottom up, and the assignments on a line from right to left.
- Write down the first answer for each attribute and never revisit it. Overriding is per attribute, so a line that mentions one attribute leaves every other attribute standing.
- Expand a macro at the moment its own name is written down, and only when the value written down is a plain set. This is where the backwards reading becomes visible: a macro reference that arrives after its name is settled never expands at all.
Steps 3 and 5 are the pair that catch people. With [attr]m -text -diff, *.z m and
*.z -m, a “later lines win” model says m is unset and that
text and diff were unset by the macro. Real Git reports m: unset and
nothing else, because line 3 settles m before line 2 is read. This page reports the second
answer, and says why.
The same patterns, as a .gitignore and as a .gitattributes
The pattern language is shared, which is why .gitignore habits give the wrong answer here. Each
row below is written once and read twice: as a .gitignore, and as a .gitattributes
with probe attached to every line. Both columns come from running this site's two engines over
that text, and a test checks them — so this table cannot drift from either behaviour.
| Pattern | Path | As .gitignore | As .gitattributes | Why they differ |
|---|---|---|---|---|
| build/ | build/a.o | Ignored | probe: unspecified — the line never applies | A trailing slash means "directory" in both files. A .gitignore can ignore a directory and take everything inside it along; a .gitattributes is only ever consulted for files, so the line applies to nothing. |
| docs | docs/a.md | Ignored | probe: unspecified — the line never applies | A .gitignore walks the parent directories first and stops at the first ignored one. A .gitattributes never looks at a path’s ancestors at all — only at the .gitattributes files along the way — so a rule naming the directory does nothing for the files in it. |
| docs/* !docs/keep.md | docs/keep.md | Not ignored | probe: set | In a .gitignore the ! line puts the file back. In a .gitattributes it does nothing at all: Git warns and drops the line, so the attribute from the line above still applies. |
| *.log | sub/a.log | Ignored | probe: set | The pattern language really is shared — a name with no slash matches at any depth in both files. That is exactly why the three rows above are easy to miss. |
Pattern syntax
Every row is checked by a test against the same engine the tool above uses, with probe as the
attribute, so this table cannot drift from the behaviour either.
| Pattern | Meaning | Applies to | Leaves unspecified |
|---|---|---|---|
*.txt | A pattern with no slash in it is matched against the file name alone, so it applies at every depth below the .gitattributes. | a.txt, sub/a.txt | a.txt.bak |
/*.txt | A leading slash anchors the pattern to the directory holding the .gitattributes — nothing deeper. | a.txt | sub/a.txt |
sub/*.txt | A slash anywhere in the pattern anchors it the same way, and * never crosses a directory boundary. | sub/a.txt | sub/deep/a.txt, a.txt |
sub/**/*.txt | ** between two slashes stands for any number of directories, including none. | sub/a.txt, sub/deep/a.txt | other/a.txt |
**/logs | A leading **/ matches at any depth — which is what a pattern with no slash does anyway. | logs, a/logs | a/logs/x |
logs/** | A trailing /** matches everything inside the directory, but not the directory itself. | logs/a.txt, logs/a/b.txt | logs |
a?c | ? is exactly one character, and never a slash. | abc | a/c, abcd |
*.[ch] | A character class works as it does in a shell glob. | a.c, a.h | a.o |
Makefile | Matching is case-sensitive whatever your filesystem does, which is why a rule works on Linux and misses on a case-insensitive checkout. | Makefile, sub/Makefile | makefile, MAKEFILE |
"two words.txt" | A pattern containing a space has to be quoted, because the first unquoted space starts the attribute list. | two words.txt | two.txt |
build/ | A pattern ending in a slash matches directories only — and Git only ever looks attributes up for files, so this line can never apply to anything. | nothing | build, build/a.o |
Results that surprise people
Each of these is the answer this tool gives, verified by a test against the engine. Paste any of them above to see the deciding file, line and pattern.
| Files | Path | Result | Why |
|---|---|---|---|
| .gitattributes: [attr]m -text -diff *.z m *.z -m | a.z | m: unset | Git reads a .gitattributes from the bottom up and keeps the first answer it finds for each attribute. Line 3 settles m before line 2 is ever read, so the macro never expands and -text -diff are never applied. This is the one case where "the last line wins" gives the wrong answer. |
| .gitattributes: [attr]m -text -diff *.z -m | a.z | m: unset | A macro expands only where its name is set plainly. -m makes the name m unset and leaves the macro’s own -text -diff untouched — unsetting a macro does not unset what it stands for. |
| .gitattributes: *.q late [attr]late -text | a.q | late: set text: unset | A macro may be defined below the line that uses it: Git collects every [attr] definition in the file before it matches anything. |
| .gitattributes: *.pdf binary [attr]binary -diff | a.pdf | binary: set diff: unset | Git defines binary itself as -diff -merge -text, below every file. A top-level [attr]binary replaces that definition outright rather than adding to it, so merge and text are left unspecified here. |
| .gitattributes: *.png binary | LOGO.PNG | no attributes at all | Matching is case-sensitive. The same rule gives logo.png four attributes and LOGO.PNG none, which is why a repository behaves differently on a case-insensitive checkout. |
| .gitattributes: *.log -text !*.log text | app.log | text: unset | There is no negation in this file format. Git warns "Negative patterns are ignored in git attributes" and drops line 2, so line 1 still stands. |
| .gitattributes: docs/ text | docs/a.md | no attributes at all | The pattern ends in a slash, so it matches directories only — and attributes are only ever looked up for files. The line can apply to nothing at all. |
| .gitattributes: *.c text diff=cpp *.c ba$d merge=x | a.c | diff: cpp text: set | Git validates every attribute name on a line before applying any of them, so one bad name discards the whole line — merge is lost along with it, and line 1 decides instead. |
| .gitattributes: docs/*.md text eol=crlf docs/.gitattributes: *.md eol=lf | docs/a.md | eol: lf text: set | A nearer .gitattributes wins per attribute, not per line: docs/.gitattributes overrides eol and leaves text from the root file standing. |
| .gitattributes: *.md text docs/.gitattributes: [attr]mym -text *.md mym | docs/a.md | mym: set text: set | A macro may only be defined in the top-level .gitattributes. Git refuses this definition, and line 2 then sets a plain attribute called mym instead of expanding anything. |
| .gitattributes: * text=auto *.y !text | a.y | text: unspecified | !text pins the attribute to unspecified, which stops * text=auto from reaching it. git check-attr -a prints nothing here, because it lists only attributes that have a value; this page shows the pin, because the pin is what decided. |
What this page shows that git check-attr does not
The command is exact and free, and if you have the repository checked out you should use it. Three things it cannot tell you, and this page can:
- Which file, line and pattern decided an attribute.
git check-attrprintspath: attr: valueand stops. There is no-vas there is forgit check-ignore. - What lost. Every assignment that reached an attribute and was beaten is listed here, with which of the three orderings beat it — a nearer file, a lower line, or a token further right.
- A macro reference that never expanded. This is invisible in the command's output: the macro's attributes are simply absent, with nothing to say they were ever nearly there.
One difference in the other direction, worth knowing: git check-attr -a lists only attributes
that have a value, so an attribute pinned to unspecified by a !attr line prints
nothing. This page shows it, because that pin is what stopped a lower-precedence rule — and asking the
command for the name explicitly makes it print unspecified too.
Two files this tool deliberately leaves out
Git reads two more sources of attributes that are not files a visitor can paste, so they are out of scope
here: $GIT_DIR/info/attributes, which outranks everything above, and the global and system
attributes files, which rank below everything above. If a real repository disagrees with this page, those are
the first places to look.
About this tool
The resolution is implemented from the documented behaviour in gitattributes(5) and then pinned
by 5,736 answers recorded from git check-attr 2.43.0 across 67 corpora —
the whole -a listing plus an explicit query for every attribute name on every path, so
unspecified is pinned as a state rather than as an absence, together with Git's own warnings for
the lines it drops. No Git source is copied: Git is GPL-2.0 and this project is not. The pattern matching is
shared with our gitignore tester, which is pinned the same way against
git check-ignore.
Two limits worth stating. Matching compares UTF-16 code units where Git compares bytes, which can only matter
inside a character class you write yourself containing a non-BMP character such as an emoji. And this page
answers the attribute question only: what Git then does with text,
eol or filter at checkout also depends on your configuration and on the file's
contents. Everything runs in your browser — the files and paths you paste are never uploaded.