Lesson 11 / 12
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.
Contents
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.
# 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=oursis 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 —
-textand-diffadd 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
"""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=ourseliminates 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?
To keep your progress and take notes, Log in
My notes
Log in to take notes.