Gitattributes resolver: which line decided this attribute?

Paste your .gitattributes files and the paths you are unsure about. This answers with Git's own rules, names the file, line and pattern behind every attribute, and shows the assignments that lost — including the macro references that look like they expand and do not.

Runs entirely in your browser. Your data is never uploaded.

Write each path relative to the repository root, as git check-attr wants it. Every path is treated as a file — Git only ever looks attributes up for files, and the path does not have to exist.

Examples

    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.

    1. Collect the .gitattributes files 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.
    2. Read that stack from the deepest file first, then the file above it, and so on up to the root. The nearest .gitattributes therefore wins — but only for the attributes it actually mentions.
    3. Inside a file, read the lines from the bottom up, and the assignments on a line from right to left.
    4. 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.
    5. 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:

    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.