The gitattributes config assigns attributes to paths. It decides which bytes
Git writes into the working tree, which bytes git archive writes into a
release artifact, and how Git diffs and merges a file. A .gitattributes file
in the repository root is the usual place for it. It is tracked, so every
clone, every contributor and every build host applies the same rules.
MUST, SHOULD and other key words are used as defined in RFC 2119 and RFC 8174.
- Goals and scope
- Applicability and precedence
- Default starting point for a repository's
.gitattributes - Line endings are not cosmetic
- Exception: files that must keep CRLF
- Exception: files whose bytes are asserted
- Release artifacts:
export-ignore export-subst- Optional attributes
- Forge-specific attributes
- Where
.gitattributesdoes not apply - Verification and migration
- Attribute precedence
- Author information
Without a declaration, the bytes of a checkout depend on each contributor's
core.autocrlf
setting. Git for Windows offers core.autocrlf=true as its installation
default, so one commit produces different files on different machines, and
git archive carries that difference into release artifacts. A tracked
.gitattributes moves the decision from the client to the repository.
This guide covers the attributes foundata repositories need: line endings, byte-exact files, archive contents, and the optional diff, merge and hosting attributes. It does not cover clean and smudge filters, Git LFS or custom merge driver definitions.
This guide defines the baseline for foundata repositories. A specific project, upstream community or packaging ecosystem MAY extend or override it. The narrowest applicable rule takes precedence.
You MUST:
- Follow a repository's own rules when they differ from this guide, including when contributing to a repository outside foundata.
- Keep an attribute that a consumer of the repository depends on, even when this guide would not ask for it.
Use the following as the default .gitattributes of any unspecific Git
repository:
# Attributes implementing foundata's guideline. Details, reasoning:
# https://foundata.com/en/guidelines/git-attributes/
#
# SPDX-FileCopyrightText: foundata GmbH <https://foundata.com>
# SPDX-License-Identifier: GPL-3.0-or-later
#
# Store every text file with LF in the repository and write LF into the
# working tree on every platform, regardless of a contributor's local
# core.autocrlf or core.eol setting. Binaries are left untouched.
* text=auto eol=lf
### Repository-specific exceptions
# none right nowThe header links this guide and carries the license comment header. Add exceptions below the marked heading, with a comment naming what requires them. The rest of this guide describes the exceptions that come up in practice.
text=autoleaves the text-or-binary decision to Git's content inspection, which is more reliable than a maintained list of file extensions. A PNG containing\r\nbyte sequences stays unconverted under this line.eol=lfis what makes the outcome platform-independent. It overridescore.autocrlfandcore.eol, and it applies togit archiveas well, which is where the difference becomes a published artifact.text=autoalone does not do this: it normalizes the repository side only and leaves the checkout side to the client.- One rule plus named exceptions stays reviewable. Its behavior for a file type nobody thought about is defined, so a new extension needs no maintenance.
- A per-extension matrix looks more explicit than it is. Every extension it
does not list falls back to the default anyway,
binarylines for images duplicate a detection Git already performs correctly, and a repeatedwhitespace=value with a fixed tab width contradicts languages such as Go and file formats such as Makefiles that require tabs. Declare a path explicitly where detection is wrong or where a consumer requires specific bytes, and let the default handle everything else. - Contributors need no local Git configuration change. Attributes win over
core.autocrlf, so a Windows default installation produces the same bytes as a Linux one.
git archive applies the same conversion as a checkout. Exporting a commit on
a machine with core.autocrlf=true therefore produces different bytes than
exporting the same commit on Linux, and any build running on that export
inherits the difference.
Measured on 2026-09-21 on Windows Server 2025, from a clean clone of
foundata/releasing v2.2.0 with Git for Windows' default core.autocrlf=true:
the exported version.py came out CRLF, and the resulting artifacts differed
from the published Linux build of the same commit. The source distribution was
65301 bytes against 64694, the wheel 74676 against 74150. The repository has no
.gitattributes, so nothing pinned this.
You MUST:
- Declare line endings in a tracked
.gitattributesfor every repository whose contents are built, published, hashed or compared.
Reasoning:
-
A release is a commit. Two hosts exporting one commit must produce the same bytes, otherwise a rebuild cannot confirm a published artifact and a reproducibility claim is unverifiable.
-
The check is cheap. With the default in place, both invocations below print the same digest; without it, they differ:
git -c core.autocrlf=false archive --format=tar HEAD | sha256sum git -c core.autocrlf=true archive --format=tar HEAD | sha256sum
-
Git converts LF to CRLF on checkout only for blobs stored with LF; a blob already containing CRLF passes through unchanged. Repositories whose files were committed from Windows are therefore sometimes unaffected today. That is a property of the current blobs, not a rule, and the first contributor committing the same file from Linux changes it.
Some consumers require specific bytes. Chocolatey's
character encoding rules
require UTF-8 with a byte-order mark (BOM) for PowerShell scripts, because
PowerShell needs the BOM to recognize a script as UTF-8; for *.nuspec the BOM
is optional. The PowerShell style guide requires
UTF-8 with BOM and CRLF for PowerShell sources, for compatibility with Windows
PowerShell 5.1 and Windows tooling.
### Repository-specific exceptions
# Windows PowerShell and Chocolatey packaging, see the PowerShell style guide.
*.ps1 text eol=crlf
*.psm1 text eol=crlf
*.psd1 text eol=crlf
*.nuspec text eol=crlfYou MUST:
- Declare
text eol=crlffor every path a consumer requires as CRLF, instead of relying on the bytes a contributor happened to commit. - Keep the BOM requirement out of Git. Enforce it with
.editorconfig(charset = utf-8-bom), the editor and a check in continuous integration.
You MUST NOT:
- Use
working-tree-encoding=UTF-8-BOM. Git does not implement that name; it reachesiconv_open()and fails wherever the platform's iconv does not know it. On Linux with Git 2.55,git addaborts withfatal: failed to encode 'x.ps1' from UTF-8-BOM to UTF-8.
Reasoning:
text eol=crlfnormalizes the stored blob to LF and writes CRLF into the working tree and into every export, on every platform. The result no longer depends on who committed the file or on which system.- A BOM is file content. It travels in the blob untouched by line-ending
conversion, so
eol=crlfpreserves it. Git will not add one to a file that lacks it, which is why the BOM needs an editor or a check rather than an attribute. working-tree-encodingis meant for content that cannot be stored as UTF-8, such as UTF-16 sources; Git's documented example with a BOM isUTF-16LE-BOM. PowerShell files stored as UTF-8 need no transcoding at all.- Declaring the requirement also documents it. A reviewer sees why the file differs from the repository default instead of discovering it from a failed Chocolatey package validation.
Golden files, recorded corpus snapshots and fixtures with a stored digest are data, even when they look like text. Any conversion invalidates them.
# Fixtures with recorded digests, no platform may rewrite these.
tests/fixtures/** -text
tests/corpus/** binaryYou MUST:
- Declare
-textorbinaryfor every path whose exact bytes a test, a recorded digest, a signature or a provenance record asserts.
You SHOULD:
- Use
-textwhen reviewers still benefit from a textual diff and a three-way merge, for example a golden file of expected program output. - Use
binary, the macro for-text -diff -merge, when the content is not meaningfully reviewable as text. Git then shows the path as changed, produces no patch text and leaves a conflict for manual resolution.
Reasoning:
- A corpus provenance test in a foundata repository failed on Windows for exactly this reason: the fixtures were checked out with CRLF, so their digests no longer matched. The code was correct and the test was correct.
- Unsetting
textdisables conversion completely. Aneolvalue inherited from the*line stays visible ingit check-attroutput but has no effect oncetextis unset. - A generated patch for a binary blob is noise in review and can be large; the
binarymacro removes it along with a merge that would silently corrupt the file.
foundata builds export a commit with git archive and build from that export,
so the working tree cannot leak into an artifact. That makes export-ignore
the content list of the artifact rather than a convenience.
# Not part of a release artifact.
.gitattributes export-ignore
.gitignore export-ignore
.github export-ignore
ARCHITECTURE.md export-ignore
CONTRIBUTING.md export-ignore
DESIGN.md export-ignore
DEVELOPMENT.md export-ignoreYou MUST:
-
Exclude a path only when neither the build nor any consumer of the artifact reads it.
-
Keep everything the build reads or the artifact must ship, including
README.md,LICENSES/,REUSE.toml, the package metadata and any file they reference. Excluding one of these turns into a failing build or an artifact that violates its own license metadata. -
Verify the result instead of predicting it:
git archive --format=tar HEAD | tar -t
You SHOULD:
- Exclude repository mechanics that have no meaning outside the repository,
such as
.gitattributes,.gitignore, continuous integration configuration and editor settings. - Exclude contributor documentation, including
ARCHITECTURE.md,CONTRIBUTING.md,DESIGN.mdandDEVELOPMENT.md. These documents describe working on the project; the artifact is for using it. - Keep a contributor document in the artifact only when its downstream maintainers want it, and give the reason in a comment next to the attribute line.
- Ask for every candidate this guide does not name whether someone rebuilding from the artifact needs it, instead of copying another repository's list.
Reasoning:
export-ignoreaffectsgit archiveonly. A clone keeps every excluded file, so this is a packaging decision and never a way to hide content.- Excluding tests is the common mistake. Distribution maintainers run the test suite of the artifact they package; a test tree excluded from the export cannot be run and cannot be restored by packaging configuration.
- Contributor documentation describes the repository's workflow: how to set up a development environment, how a change is reviewed, how a release is made. Shipped in an artifact it sends its reader to tooling, paths and history that the artifact does not contain, and the people it is written for have the repository anyway.
- For
export-ignore, a directory pattern such as.githubis enough, because the archive skips the whole tree. Other attributes do not work that way: Git's manual states that a directory pattern does not recursively apply to the paths inside it, so usedir/**for those.
A foundata Python release builds the source distribution from the export and
the wheel from that source distribution, so export-ignore decides the
contents of both.
You MUST:
- Check the source distribution against the packaging metadata before adding an
exclusion.
license-files,readmeand dynamic version sources all resolve against files that must exist inside the export.
Reasoning:
- Packaging configuration cannot restore what the export dropped. A
MANIFEST.inentry or a build-backend include list only selects from the files present in the directory the backend builds from. - Build backends that collect files from Git, such as
setuptools-scmorhatch-vcs, read the tracked file list and ignoreexport-ignoreentirely. A project using one of them gets a different source distribution when it is built from a clone instead of an export, which is one more reason to build from the export.
export-subst makes git archive expand $Format:...$ placeholders, for
example $Format:%H$ for the commit hash.
You MUST NOT:
- Use
export-substin foundata repositories.
Reasoning:
- It makes one tracked line mean two different things. The file in a clone contains the placeholder and the file in the artifact contains a hash, so a byte comparison between a rebuilt export and a checkout no longer works, and neither does running the file from a clone.
- Nothing expands when
git archiveis given a tree instead of a commit or a tag, which produces a released file containing a literal$Format:%H$. - foundata projects carry their version and revision in tracked files that the release tooling maintains. A placeholder would add a second, archive-only source for data the build already controls.
The attributes in this section change review and merge ergonomics. They are worth adding when a repository actually has the problem they solve.
A diff driver tells Git how to find the enclosing function or section for a hunk header.
*.py diff=python
*.go diff=golang
*.md diff=markdown
*.sh diff=bashYou MAY:
- Set a built-in diff driver for the languages a repository mainly contains.
Reasoning:
- The hunk header changes from a nearby line to the enclosing function, heading or section, which makes a diff readable without opening the file.
- The drivers are built into Git and need no configuration, so a clone gets the
behavior without a setup step. Git's
gitattributesdocumentation lists the available names.
# Regenerate, never merge.
uv.lock -mergeYou SHOULD:
- Unset
mergefor generated lockfiles. Git then leaves the path conflicted so the file gets regenerated with its tool instead of merged into something that looks plausible and is not valid. - Prefer a changelog fragment directory over merging a single changelog file where the tooling supports it, as foundata's Ansible collections do.
You MAY:
- Set
merge=unionfor an append-only list where both sides should be kept, such as a contributors file.
Reasoning:
- A three-way merge of a lockfile can resolve without conflict and still produce a dependency set that was never resolved by the tool.
uniontakes the lines from both sides and writes no conflict markers. The order of added lines is arbitrary and nothing prompts anyone to look, so it only fits content where order does not matter and a wrong result is visible.
git diff --check reports whitespace errors and runs as a step in the release
procedure of several foundata projects. The whitespace attribute defines per
path what counts as an error there and in git apply. The core.whitespace
configuration defines the same thing for the whole repository, and its default
already reports trailing whitespace, a space before a tab in the indent and a
new blank line at the end of a file.
You SHOULD NOT:
- Set a repository-wide
whitespaceattribute such as* whitespace=blank-at-eol,-blank-at-eof,tab-in-indent.
You MAY:
-
Unset the attribute for paths whose whitespace is part of the recorded content:
tests/fixtures/** -whitespace
Reasoning:
- The default costs nothing and already reports what those release procedures
look for. In a repository with no whitespace configuration at all,
git diff --checkreported an added trailing space and an added blank line at the end of a file. - A written-out value usually weakens that default instead of strengthening
it. The
-blank-at-eofin the example above switches off the blank-line-at-EOF report that Git gives for free. tab-in-indentcannot hold repository-wide. It flagged every added line of a Makefile recipe and every indented line of a Go file, both of which require tabs. Keeping it would mean a per-language exception list, which is the maintenance problem this guide rejects for line endings.- Indentation is already decided by the formatter and by
.editorconfigfor every language foundata uses. A second definition in.gitattributesadds a place for them to disagree. - Unsetting the attribute is the useful direction. It silences a path whose bytes are the point without changing what Git reports elsewhere. Unsetting it for a source tree would also switch off the trailing-whitespace report, which is worth keeping.
Some hosting platforms read attributes of their own. GitHub uses
linguist-generated to collapse a file in a pull request diff and
linguist-vendored to keep a tree out of the repository language statistics.
You MUST NOT:
- Use an attribute that only one hosting platform interprets, including
linguist-generated,linguist-vendored,linguist-languageandlinguist-detectable.
You SHOULD:
- Record that a tree is generated or vendored where every reader finds it: in the contributor documentation, in a README inside the tree, or in a header the generator writes into the files.
Reasoning:
- These attributes have no effect in Git and no effect on any other platform. They buy a cosmetic improvement in one vendor's web interface at the price of a repository that presents itself correctly only while it stays there.
- Whether a tree is generated is information a reader needs in a clone, in a release artifact and after a migration. Written into the repository, it survives all three.
- Declining them keeps the file portable. Every attribute in this guide is interpreted by Git itself, so a move between platforms changes nothing about how the repository behaves.
export-ignore is read by git archive and by nothing else. Every other tool
that produces an artifact decides its contents with its own mechanism, usually
by reading the working tree or the tracked file list. An exclusion therefore
has to be declared once for every tool that builds an artifact from the
repository.
You MUST:
- Declare an exclusion in the mechanism belonging to the tool that produces the artifact, and repeat it for every further tool that produces one from the same repository.
- Verify an exclusion against the artifact the tool produces, not against
git archiveoutput.
You MUST NOT:
- Use attributes to keep a file out of the repository. That is
.gitignore; attributes apply to tracked paths.
The mechanisms currently in use at foundata:
| Artifact | Produced by | Exclusions declared in |
|---|---|---|
| Source export | git archive |
export-ignore in .gitattributes |
| Python source distribution | build backend, run on the export | the export, plus the backend's own include list |
| Ansible collection | ansible-galaxy collection build |
build_ignore in galaxy.yml |
| Container image | Buildah or Podman, see the OCI container image guide | .containerignore in the build context |
Reasoning:
- A tool that reads the working tree or the tracked file list never sees
export-ignore, so an attribute meant as an exclusion silently ships the file. The reverse holds as well: abuild_ignoreor.containerignoreentry does nothing to agit archiveexport. - The table lists what foundata builds today and will go out of date. For a
packaging tool it does not name, ask which files that tool reads and what
its own documentation says about excluding them. Git attributes are the
answer only for
git archive.
You SHOULD:
-
Check what Git actually resolves for a path rather than reading the patterns:
# All attributes in effect for a path, including macro expansion. git check-attr -a -- src/example.ps1 # Index and working-tree line endings plus the attributes behind them. git ls-files --eol # Tracked files not stored with LF. git ls-files --eol | grep -v 'i/lf'
Reasoning:
git check-attrresolves precedence, later-line overrides and macros, which a pattern list read by eye does not.git ls-files --eolreports the index and working-tree state separately, so it distinguishes a file stored with CRLF from one merely checked out that way.
Adding the file changes nothing by itself; existing blobs keep their stored line endings until they are rewritten.
You MUST:
- Commit the byte-exact declarations first, in their own commit, before
renormalizing anything. Fixtures, corpus snapshots and signed data marked
-textorbinaryare then out of reach of the rewrite. - Regenerate every recorded digest that the rewrite invalidates, and say so in the commit message.
- Treat artifacts published before the adoption as untouched by it. A procedure that rebuilds an older tag and compares the result against a stored digest has to expect that tag's own bytes, not the ones the policy produces today.
You SHOULD:
-
Renormalize in a separate commit that contains nothing else:
git add --renormalize . git status git diff --cached --stat
Reasoning:
git add --renormalizerewrites stored blobs to the declared form. Every file whose line endings change gets different bytes in the working tree of every clone afterwards, so a checksum, signature or provenance record computed from those bytes becomes wrong. This is the same failure the attributes are meant to prevent, and doing it in the wrong order causes it deliberately.- A renormalization touches many files and carries no review value. Keeping it
apart from content changes lets a reviewer skip it and keeps
git blamehonest for the lines that really changed. - Ordering the declarations before the rewrite means the working tree never passes through a state in which a fixture is converted, so no test result has to be explained away.
git archivereads the attributes of the tree it archives, so adopting the policy fixes future exports and changes nothing about published ones. In a test repository, the export of a tag created before the adoption still carried the file a laterexport-ignoreremoves and the line endings a latereol=crlfchanges. An artifact released before the policy therefore stays as reproducible, or as platform-dependent, as it was on the day it was built.- The exception is the untracked attributes file. With
.git/info/attributesin place, the same old tag exported with the new line endings and without the excluded file, so a verification run has to confirm that no such file is active. See attribute precedence.
Git resolves each attribute separately, in this order:
$GIT_DIR/info/attributes, which has the highest precedence and is not tracked..gitattributesin the directory of the path, then in each parent directory up to the top level. The closer file wins.- The user's
core.attributesFile, then the system-wide file.
Within one file, a later line overrides an earlier one, per attribute.
You MUST:
- Put every rule that has to hold for everyone in the tracked
.gitattributesat the repository root.
You SHOULD:
- Express subtree rules as patterns in the root file, such as
tests/corpus/**, rather than adding a nested.gitattributes, unless the subtree is genuinely self-contained, for example vendored code that carries its own file. - Restrict
.git/info/attributesto local experiments, and remove it again.
Reasoning:
.git/info/attributesand the user-level file are per machine and invisible to everyone else. A build that depends on them produces a result nobody can reproduce, and the difference does not show up in review.- A rule in the root file is found by reading one file. Nested files split the policy across the tree, and the effective value for a path then depends on how deep it sits.
This guide was written by foundata to make checkouts
and release artifacts of a commit byte-identical on every platform. It follows
Git's gitattributes documentation
and records the exceptions foundata projects need for Chocolatey packaging,
byte-exact test data and archive contents.