---
title: 'Repository Attributes'
source: 'https://academia.sh/en/courses/advanced-git/repository-attributes'
course: 'Advanced Git'
language: en
updated: '2026-08-17T18:10:42+00:00'
license: 'CC BY-SA 4.0'
---

# Repository Attributes

The attributes file determines which operation behaves how on which file: an unconfigured thousand-commit history writes 5888 objects, line endings produce 162 unnecessary objects, and 43 of fifty merges conflict in the generated file, adding 86 more objects.

The previous lesson's measurement left one assumption outside: the assumption that the files
checked out to the working area are the same under both schemes. This said that **how** a
file is written is not a choice.

It is not. The blob in the object store and the file in the working area do not have to be
identical to each other; there is a transformation layer between them, and that layer is
configurable. Left unconfigured, the layer works silently, and what it does gets written to
history. This lesson's question is: which attribute changes which operation on which file,
and in the unconfigured state, how many objects are written **even though content did not
change**?

## What the Attributes File Determines

**Repository attributes** are rules that bind behavior to file patterns, and they stand in
the `.gitattributes` file. The file is a tracked file: it enters history, can differ between
branches, and carries the same rule to everyone who clones. This is what separates it from a
configuration file — configuration is personal and copy-specific, an attribute belongs to
the repository.

```text
# attributes file — example dump, not executed

# .gitattributes
*                 text=auto
*.py              text eol=lf
*.md              text eol=lf
config.py         text merge=ours
presentation.bin  -text -diff
document.md       diff=text
```

Each line is a pattern and the attributes bound to it. `text` declares that the file is
considered text and can go through line-ending conversion; `text=auto` asks the tool to make
that decision by looking at content. `eol=lf` fixes which **line ending** is used when
writing to the working area. The leading minus sign removes an attribute: `-text` declares
the file will go through no conversion at all, `-diff` declares comparison will not be
attempted as text. `merge=ours` and `diff=text` name a **merge driver** or a comparison
definition.

## Four Operations, Four Separate Moments

Attributes engage at four separate moments, and if which one runs when gets confused, the
measurement gets confused too.

**Checkout moment:** line-ending conversion is applied while a blob is being written to the
working area. **Commit moment:** the reverse conversion is applied while the file in the
working area is converted to a blob — the form written to the repository is single.
**Merge moment:** which driver is used is decided while the two sides' changes are being
blended. **Diff moment:** whether the file is treated as text is decided while the diff
between two blobs is produced.

There is a fifth moment too, and it does not concern history at all: which paths are left
out is also declared by an attribute while a version of the repository is packaged as an
archive. The rule looks at packaging, not committing; the file stays in history, it just
does not go into the archive. This lesson does not measure it because it has no counterpart
in the object unit.

The first two write objects to history; of the last two, only merge writes one. Diff affects
readability, not history — this is why the `-diff` row gives zero in the measurement, and
that is not a gap, it is the correct result.

What happens when line-ending conversion is unconfigured? Whatever is in the writer's
environment goes into the repository as is. When two environments using two different
line-ending styles touch the same file in sequence, **even though the content did not
change at all**, the blob is rewritten from scratch: the byte sequence is different, so the
object is different.

## The Measurement's Assumptions

- **LR20** — The measurement uses the shared setup's four scales. Two fields are added to
  each commit from a separate generator: the writer's line-ending style and whether the
  content actually changed.
- **LR21** — **A third** of commits come from an environment using a different line-ending
  style; **a fifth** do not actually change content, they only touch the file.
- **LR22** — With no line-ending setting, a commit whose content did not change writes a new
  blob only if the file's **last recorded style** differs; every such write is **2
  objects**. When the setting is assumed, these commits write **0 objects**.
- **LR23** — A merge happens every **20** commits; if both halves of the window touched the
  generated file, a merge conflict is born. Manual resolution writes one commit, **2
  objects**.
- **LR24** — When `merge=ours` is defined, no conflict is born and no resolution commit is
  written; the driver does not take the other side's version in the generated file.
- **LR25** — `-text` and `-diff` add no object to the binary asset and remove none; their
  effects are at checkout and diff moments, not in history.
- **LR26** — Cost is counted in the **touched object** unit. The "unconfigured" column is the
  baseline, the "fully configured" column is the shared setup's clone cost; the gap between
  them is the object the attribute **stops from being written**.

## Measurement

```python
"""Repository attributes: the unnecessary object born when unconfigured.

Part 1 - which attribute changes which operation on which file (1000 commits).
Part 2 - unconfigured, line-ending configured, and fully configured objects at four scales.
"""
SEED = 20260814
FILES = ("metrics.py", "report.py", "identity.py", "config.py", "document.md")
BINARY_ASSET = "presentation.bin"
GENERATED = "config.py"      # generated file: the merge driver is bound to this one
SCALES = (50, 200, 1000, 4000)
MERGE_INTERVAL = 20     # one merge every 20 commits


def rng(seed):
    d = seed % 2147483646 + 1

    def draw(n):
        nonlocal d
        d = (d * 48271) % 2147483647
        return d % n
    return draw


def history(n, seed=SEED):
    """n-commit linear history; each commit touches one file."""
    draw, commits = rng(seed), []
    for i in range(n):
        file = FILES[draw(5)]
        binary = draw(11) == 0
        commits.append({"no": i + 1, "file": file, "binary": binary,
                         "objects": 2 + (40 if binary else 0)})
    return commits


def clone_cost(t, include_binary=True):
    return sum(x["objects"] for x in t if include_binary or not x["binary"])


def with_environment(t, seed):
    """Each commit gets the author's line-ending style and whether the content
    actually changed."""
    draw, k = rng(seed), []
    for x in t:
        k.append({**x, "crlf": draw(3) == 0, "real": draw(5) != 0})
    return k


def line_ending_objects(k, file=None):
    """Unconfigured: a blob rewritten because the line-ending style changed even
    though the content is the same. The last style is tracked per file."""
    last, wasted = {}, 0
    for x in k:
        if x["file"] == file or file is None:
            if not x["real"] and last.get(x["file"]) not in (None, x["crlf"]):
                wasted += 2
        last[x["file"]] = x["crlf"]
    return wasted


def conflicting_merges(k):
    """One merge per interval; if both halves touched the generated file,
    a conflict is born and the manual resolution writes one commit."""
    count = 0
    for start in range(0, len(k) - MERGE_INTERVAL + 1, MERGE_INTERVAL):
        window = k[start:start + MERGE_INTERVAL]
        half = MERGE_INTERVAL // 2
        left = any(x["file"] == GENERATED for x in window[:half])
        right = any(x["file"] == GENERATED for x in window[half:])
        count += left and right
    return count


def driver_objects(k):
    return 2 * conflicting_merges(k)


K = with_environment(history(1000), SEED + 7)
print("file               attribute        operation changed          line-ending objects"
      "  driver objects")
ATTRIBUTES = (("metrics.py", "text eol=lf", "checkout and commit"),
              ("report.py", "text eol=lf", "checkout and commit"),
              ("identity.py", "text eol=lf", "checkout and commit"),
              ("config.py", "text merge=ours", "checkout, commit, merge"),
              ("document.md", "text eol=lf", "checkout and commit"),
              (BINARY_ASSET, "-text -diff", "checkout and diff"))
for file, attr, op in ATTRIBUTES:
    s = 0 if file == BINARY_ASSET else line_ending_objects(K, file)
    d = driver_objects(K) if file == GENERATED else 0
    print(f"  {file:17s} {attr:16s} {op:26s} {s:16d} {d:15d}")
print()
print("commits  unconfigured objects  line-ending configured  fully configured  wasted objects   share")
for n in SCALES:
    k = with_environment(history(n), SEED + 7)
    full, s, d = clone_cost(k), line_ending_objects(k), driver_objects(k)
    print(f"{n:6d} {full + s + d:14d} {full + d:18d} {full:11d} {s + d:15d} "
          f"{(s + d) / (full + s + d):6.4f}")
print()
n = 1000
k = with_environment(history(n), SEED + 7)
print(f"history {n} commits; blobs rewritten because of line ending "
      f"{line_ending_objects(k) // 2}, objects written {line_ending_objects(k)}")
print(f"merges {n // MERGE_INTERVAL}, conflicting in the generated file "
      f"{conflicting_merges(k)}, objects written by manual resolution {driver_objects(k)}")
```

```
file               attribute        operation changed          line-ending objects  driver objects
  metrics.py        text eol=lf      checkout and commit                      38               0
  report.py         text eol=lf      checkout and commit                      34               0
  identity.py       text eol=lf      checkout and commit                      22               0
  config.py         text merge=ours  checkout, commit, merge                  36              86
  document.md       text eol=lf      checkout and commit                      32               0
  presentation.bin  -text -diff      checkout and diff                         0               0

commits  unconfigured objects  line-ending configured  fully configured  wasted objects   share
    50            390                384         380              10 0.0256
   200            972                938         920              52 0.0535
  1000           5888               5726        5640             248 0.0421
  4000          23578              22912       22600             978 0.0415

history 1000 commits; blobs rewritten because of line ending 81, objects written 162
merges 50, conflicting in the generated file 43, objects written by manual resolution 86
```

## Which Attribute, Which File

The top table answers the lesson's first question, and what stands out is that the columns
are **independent of each other**.

The line-ending column spreads across five text files: **38**, **34**, **22**, **36**, and
**32** — **162** objects total. The distribution is not even, but the reason is not the
attribute itself, it is the setup's file distribution. From the attribute's point of view,
all five lines carry the same rule; the cost paid changes with how often the file is
touched.

The driver column collects onto a single row: **86** objects, only in the `config.py` row.
This, by the setup's design, is a generated file — one not written by hand, but produced by
a tool and placed in the repository. In the other four files the driver column is **0**,
because in them a conflict has to be resolved by hand, and resolution is a decision, not
something to be changed by a setting.

The binary asset row gives **0** in both columns. The `-text` and `-diff` attributes do real
work — they stop conversion, they prevent an unreadable comparison — but they **add and
remove not a single object from history**. Because the measure's unit is objects, those
effects do not show up here. This row is the declaration of the measure's scope boundary.

## What the Absence of Settings Costs

The lower table's unit is **touched object**, and the three columns give three configuration
levels of the same history. An unconfigured thousand-commit history writes **5888** objects;
once line endings are configured, this drops to **5726**, and once the merge driver is also
defined, to **5640** — that is, the shared setup's clone cost. Wasted objects: **248**,
share **0.0421**.

The share is small, and this is the measurement's honest result: line-ending irregularity
does not bloat a repository. Two observations are still worth noting. **First**, the wasted
object share comes out to **0.0256**, **0.0535**, **0.0421**, and **0.0415** at the four
scales; it settles around **0.042** at the two large scales and does not decrease as scale
grows. This is a cost that keeps being paid across a history's whole lifetime.
**Second**, the **10** objects in the fifty-commit row correspond to five commits at that
scale's **1/50** resolution; no conclusion can be drawn from this cost in a small repository.

The real number stands in the third line: **43** of **50** merges conflict in the generated
file. This is more than **four-fifths** of merges, and each one means a person sitting down
and manually clearing conflict markers. The **86** objects it writes to history are the
smallest part of this work; the unmeasured part is the decision given forty-three times, none
of which carries any information. The Branching and Collaboration course showed that
conflict resolution is not written to history — what is seen here is that **forty-three of**
those unwritten decisions **were unnecessary from the start.**

## Which Line Wins

The measurement assumed a single rule fell on each file. In a real attributes file, more
than one pattern matches a path, and without knowing which one wins, no setting's effect can
be predicted.

Rule order has three layers. **Within the same file, the last matching line wins** — it is
read top to bottom, and the one below overrides the one above; this is why general patterns
go first and specific patterns go last. **An attributes file in a subdirectory overrides the
one above it**, because it is closer. And inside the repository there is one more attributes
file that never enters history, a local one; it **overrides all of them** and applies only
in that copy.

The third layer is double-edged. It lets a copy's behavior be tuned without changing the
repository; for the same reason, when an unexpected conversion is seen in one copy, that is
the first place to look. We said attributes belong to the repository — that is true outside
this local layer, and if it is not known where the difference is, why a file comes out
differently is never understood at all.

An attribute can take three values: given (`text`), removed (`-text`), and
**unspecified**. The third is not the same thing as the second: a removed attribute
finalizes a decision, an unspecified attribute leaves it to a lower layer or the tool's own
default. `text=auto` manages exactly this third case — it makes the decision by looking at
content and applies no conversion to a file it judges binary.

## The Limit of Configuration and Recovery

The attributes file works forward. A `text=auto` line written today does not fix blobs
written yesterday; the **162** objects in history stay in place. The setting's effect starts
after the first checkout following it, and if the repository is already checked out in
people's hands, the files need to be re-normalized once — this shows up as a new commit in
history and does not rewrite history.

There is a rewriting path too, and it falls under the same rule as this topic's second
lesson: normalizing every past blob **is rewriting history**, it changes identities,
**cannot be undone**, and requires every copy to be re-cloned. Its gain is read in the
measurement — **162** objects. In a thousand-commit repository, this is a thirty-fifth of
the clone cost, and rewriting history for that gain is justified in almost no case.

The cheap and reversible path is this: the attributes file is written on the repository's
first day; if it was written later, it is corrected going forward with a single
normalization commit and the past is left as is. A wrong attribute line is itself harmless —
because the file is a tracked file, the line can be reverted, and the conversion returns to
its old form on the next checkout.

## Summary

- Repository attributes stand in a tracked file and belong to the repository; this is what
  separates them from configuration. They engage at four separate moments: checkout,
  commit, merge, and diff.
- Diff moment writes no object to history; the binary-asset row gives **0** in both columns
  of the measurement, and this is the declaration of the measure's scope boundary.
- With line endings unconfigured, **81** commits whose content did not change write a new
  blob and produce **162** objects; these spread across five text files as
  **38 / 34 / 22 / 36 / 32**.
- With no merge driver defined for the generated file, **43** of **50** merges conflict and
  manual resolution writes **86** objects; `merge=ours` eliminates all of these conflicts.
- At a thousand commits, the unconfigured history is **5888**, the fully configured one is
  **5640** objects; the wasted **248** objects have a share of **0.0421**, and it does not
  decrease as scale grows.

## Next Step

This topic's four lessons made four separate interventions on the same repository: they
embedded a dependent repository, moved a binary body outside, duplicated the working tree,
and configured the transformation layer. All four assumed one thing — where the
repository's **boundary** is. The next lesson measures that assumption: if the same work is
done in a single repository and in four separate repositories, how do clone cost and search
steps change, and how many of the questions asked across components go unanswered under the
second scheme?
