Skip to content

Latest commit

 

History

History
701 lines (517 loc) · 28.2 KB

git-attributes.md

File metadata and controls

701 lines (517 loc) · 28.2 KB

Git: gitattributes configuration

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.

Table of contents

Goals and scope

⇑ Back to TOC ⇑

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.

Applicability and precedence

⇑ Back to TOC ⇑

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.

Default starting point for a repository's .gitattributes

⇑ Back to TOC ⇑

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 now

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

Reasoning

⇑ Back to TOC ⇑

  • text=auto leaves 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\n byte sequences stays unconverted under this line.
  • eol=lf is what makes the outcome platform-independent. It overrides core.autocrlf and core.eol, and it applies to git archive as well, which is where the difference becomes a published artifact. text=auto alone 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, binary lines for images duplicate a detection Git already performs correctly, and a repeated whitespace= 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.

Line endings are not cosmetic

⇑ Back to TOC ⇑

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 .gitattributes for 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.

Exception: files that must keep CRLF

⇑ Back to TOC ⇑

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=crlf

You MUST:

  • Declare text eol=crlf for 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 reaches iconv_open() and fails wherever the platform's iconv does not know it. On Linux with Git 2.55, git add aborts with fatal: failed to encode 'x.ps1' from UTF-8-BOM to UTF-8.

Reasoning:

  • text eol=crlf normalizes 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=crlf preserves 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-encoding is meant for content that cannot be stored as UTF-8, such as UTF-16 sources; Git's documented example with a BOM is UTF-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.

Exception: files whose bytes are asserted

⇑ Back to TOC ⇑

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/**    binary

You MUST:

  • Declare -text or binary for every path whose exact bytes a test, a recorded digest, a signature or a provenance record asserts.

You SHOULD:

  • Use -text when 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 text disables conversion completely. An eol value inherited from the * line stays visible in git check-attr output but has no effect once text is unset.
  • A generated patch for a binary blob is noise in review and can be large; the binary macro removes it along with a merge that would silently corrupt the file.

Release artifacts: export-ignore

⇑ Back to TOC ⇑

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

You 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.md and DEVELOPMENT.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-ignore affects git archive only. 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 .github is 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 use dir/** for those.

Python source distributions

⇑ Back to TOC ⇑

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, readme and dynamic version sources all resolve against files that must exist inside the export.

Reasoning:

  • Packaging configuration cannot restore what the export dropped. A MANIFEST.in entry 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-scm or hatch-vcs, read the tracked file list and ignore export-ignore entirely. 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

⇑ Back to TOC ⇑

export-subst makes git archive expand $Format:...$ placeholders, for example $Format:%H$ for the commit hash.

You MUST NOT:

  • Use export-subst in 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 archive is 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.

Optional attributes

⇑ Back to TOC ⇑

The attributes in this section change review and merge ergonomics. They are worth adding when a repository actually has the problem they solve.

Diff drivers

⇑ Back to TOC ⇑

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=bash

You 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 gitattributes documentation lists the available names.

Merge behavior

⇑ Back to TOC ⇑

# Regenerate, never merge.
uv.lock -merge

You SHOULD:

  • Unset merge for 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=union for 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.
  • union takes 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.

Whitespace checks

⇑ Back to TOC ⇑

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 whitespace attribute 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 --check reported 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-eof in the example above switches off the blank-line-at-EOF report that Git gives for free.
  • tab-in-indent cannot 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 .editorconfig for every language foundata uses. A second definition in .gitattributes adds 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.

Forge-specific attributes

⇑ Back to TOC ⇑

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

Where .gitattributes does not apply

⇑ Back to TOC ⇑

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 archive output.

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: a build_ignore or .containerignore entry does nothing to a git archive export.
  • 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.

Verification and migration

⇑ Back to TOC ⇑

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-attr resolves precedence, later-line overrides and macros, which a pattern list read by eye does not.
  • git ls-files --eol reports the index and working-tree state separately, so it distinguishes a file stored with CRLF from one merely checked out that way.

Adopting the policy in an existing repository

⇑ Back to TOC ⇑

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 -text or binary are 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 --renormalize rewrites 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 blame honest 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 archive reads 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 later export-ignore removes and the line endings a later eol=crlf changes. 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/attributes in 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.

Attribute precedence

⇑ Back to TOC ⇑

Git resolves each attribute separately, in this order:

  1. $GIT_DIR/info/attributes, which has the highest precedence and is not tracked.
  2. .gitattributes in the directory of the path, then in each parent directory up to the top level. The closer file wins.
  3. 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 .gitattributes at 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/attributes to local experiments, and remove it again.

Reasoning:

  • .git/info/attributes and 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.

Author information

⇑ Back to TOC ⇑

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.