<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Tooling on Londopy</title><link>https://londopy.github.io/blog/tags/tooling/</link><description>Recent content in Tooling on Londopy</description><image><title>Londopy</title><url>https://londopy.github.io/blog/og-image.png</url><link>https://londopy.github.io/blog/og-image.png</link></image><generator>Hugo</generator><language>en-us</language><copyright>2026 Londopy · Posts CC BY 4.0 · Code MIT</copyright><lastBuildDate>Thu, 24 Sep 2026 01:50:00 -0700</lastBuildDate><atom:link href="https://londopy.github.io/blog/tags/tooling/index.xml" rel="self" type="application/rss+xml"/><item><title>The Best Git Feature You've Never Used</title><link>https://londopy.github.io/blog/posts/gitattributes/</link><pubDate>Thu, 24 Sep 2026 01:50:00 -0700</pubDate><guid>https://londopy.github.io/blog/posts/gitattributes/</guid><description>Every repo has a .gitignore. Almost nobody has a .gitattributes. Here&amp;#39;s everything it can do: line endings, smarter diffs, merge strategies, filters, release archives, and GitHub extras.</description><content:encoded><![CDATA[<p>Every repo has a <code>.gitignore</code>. Almost nobody has a <code>.gitattributes</code>. That&rsquo;s a shame, because this one small file controls how Git sees, diffs, merges, normalizes, and exports your files. Once you know what it can do, you&rsquo;ll want one in every project.</p>
<h2 id="what-it-is">What it is</h2>
<p><code>.gitattributes</code> is a plain text file that assigns <em>attributes</em> to paths. Each line is a pattern followed by one or more attributes:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-gitattributes" data-lang="gitattributes"><span class="line"><span class="cl"><span class="s">*.sh</span>    <span class="na">text</span> <span class="na">eol</span><span class="o">=</span><span class="l">lf</span>
</span></span><span class="line"><span class="cl"><span class="s">*.png</span>   <span class="na">binary</span>
</span></span><span class="line"><span class="cl"><span class="s">docs/**</span> <span class="na">linguist-documentation</span>
</span></span></code></pre></div>
<p>The patterns work much like <code>.gitignore</code>, with two catches. Negation (<code>!pattern</code>) isn&rsquo;t allowed. And a directory pattern like <code>vendor/</code> does not apply to the files inside it, so you need <code>vendor/**</code>.</p>
<p>Each attribute can be in one of four states:</p>
<ul>
<li><strong>Set:</strong> <code>text</code></li>
<li><strong>Unset:</strong> <code>-text</code></li>
<li><strong>Set to a value:</strong> <code>eol=lf</code></li>
<li><strong>Unspecified:</strong> <code>!text</code>, which resets it to as if you never mentioned it</li>
</ul>
<h2 id="where-it-lives">Where it lives</h2>
<p>Git reads attributes from several places, from highest priority to lowest:</p>
<ol>
<li><code>.git/info/attributes</code>: local to your clone and never committed. Good for personal rules.</li>
<li><code>.gitattributes</code> files in the repo. A file in a subdirectory overrides one in a parent directory.</li>
<li>Your global file (set with <code>core.attributesFile</code>, usually <code>~/.config/git/attributes</code>).</li>
<li>The system-wide file.</li>
</ol>
<p>When a file behaves strangely, ask Git what it thinks:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">git check-attr -a path/to/file
</span></span></code></pre></div><h2 id="1-end-the-line-ending-wars">1. End the line-ending wars</h2>
<p>This is the reason most people should add a <code>.gitattributes</code> today. Windows uses CRLF, everything else uses LF, and without rules you end up with diffs where every line &ldquo;changed.&rdquo;</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-gitattributes" data-lang="gitattributes"><span class="line"><span class="cl"><span class="s">*</span> <span class="na">text</span><span class="o">=</span><span class="l">auto</span>
</span></span><span class="line"><span class="cl"><span class="s">*.sh</span>  <span class="na">text</span> <span class="na">eol</span><span class="o">=</span><span class="l">lf</span>
</span></span><span class="line"><span class="cl"><span class="s">*.bat</span> <span class="na">text</span> <span class="na">eol</span><span class="o">=</span><span class="l">crlf</span>
</span></span><span class="line"><span class="cl"><span class="s">*.ps1</span> <span class="na">text</span> <span class="na">eol</span><span class="o">=</span><span class="l">crlf</span>
</span></span></code></pre></div>
<p><code>text=auto</code> lets Git detect text files and store them with LF in the repo. The <code>eol=</code> rules pin specific file types, which matters for shell scripts that break with CRLF and batch files that expect it.</p>
<p><strong>Know this:</strong> adding these rules doesn&rsquo;t fix files that are already committed. Run this once and commit the result:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">git add --renormalize .
</span></span></code></pre></div><p>To see the current line-ending state of every file:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">git ls-files --eol
</span></span></code></pre></div><h2 id="2-mark-binaries-as-binary">2. Mark binaries as binary</h2>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-gitattributes" data-lang="gitattributes"><span class="line"><span class="cl"><span class="s">*.png</span>  <span class="na">binary</span>
</span></span><span class="line"><span class="cl"><span class="s">*.jpg</span>  <span class="na">binary</span>
</span></span><span class="line"><span class="cl"><span class="s">*.zip</span>  <span class="na">binary</span>
</span></span></code></pre></div>
<p><code>binary</code> is a built-in <em>macro</em> that expands to <code>-text -diff -merge</code>. Git won&rsquo;t try to convert line endings, show a text diff, or attempt a text merge. That last one saves you from corrupted files after a merge that &ldquo;succeeded.&rdquo;</p>
<h2 id="3-hunk-headers-that-actually-help">3. Hunk headers that actually help</h2>
<p>When you run <code>git diff</code>, each hunk starts with a line like <code>@@ -12,7 +12,8 @@</code>, followed by some context Git guessed at. Often that guess is useless. Tell Git the language and it names the enclosing function or class instead:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-gitattributes" data-lang="gitattributes"><span class="line"><span class="cl"><span class="s">*.py</span>   <span class="na">diff</span><span class="o">=</span><span class="l">python</span>
</span></span><span class="line"><span class="cl"><span class="s">*.rs</span>   <span class="na">diff</span><span class="o">=</span><span class="l">rust</span>
</span></span><span class="line"><span class="cl"><span class="s">*.go</span>   <span class="na">diff</span><span class="o">=</span><span class="l">golang</span>
</span></span><span class="line"><span class="cl"><span class="s">*.java</span> <span class="na">diff</span><span class="o">=</span><span class="l">java</span>
</span></span><span class="line"><span class="cl"><span class="s">*.md</span>   <span class="na">diff</span><span class="o">=</span><span class="l">markdown</span>
</span></span><span class="line"><span class="cl"><span class="s">*.tex</span>  <span class="na">diff</span><span class="o">=</span><span class="l">tex</span>
</span></span></code></pre></div>
<p>These drivers are built in. There&rsquo;s nothing to install. The Markdown one shows the nearest heading, which is great for docs.</p>
<h2 id="4-diff-files-that-arent-text">4. Diff files that aren&rsquo;t text</h2>
<p>With a <code>textconv</code> driver, Git runs a command that turns a binary file into text, then diffs that text.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-gitattributes" data-lang="gitattributes"><span class="line"><span class="cl"><span class="s">*.png</span>  <span class="na">diff</span><span class="o">=</span><span class="l">exif</span>
</span></span><span class="line"><span class="cl"><span class="s">*.pdf</span>  <span class="na">diff</span><span class="o">=</span><span class="l">pdf</span>
</span></span><span class="line"><span class="cl"><span class="s">*.docx</span> <span class="na">diff</span><span class="o">=</span><span class="l">docx</span>
</span></span></code></pre></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">git config --global diff.exif.textconv exiftool
</span></span><span class="line"><span class="cl">git config --global diff.docx.textconv <span class="s2">&#34;pandoc --from=docx --to=plain&#34;</span>
</span></span></code></pre></div><p>Git appends the file path to the command, and the command must print text to stdout. <code>exiftool</code> and <code>pandoc</code> already do that. <code>pdftotext</code> doesn&rsquo;t; by default it writes a <code>.txt</code> file next to the input. Wrap it in a tiny script:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl"><span class="cp">#!/bin/sh
</span></span></span><span class="line"><span class="cl"><span class="c1"># save as ~/bin/pdf2txt and chmod +x it</span>
</span></span><span class="line"><span class="cl">pdftotext -layout <span class="s2">&#34;</span><span class="nv">$1</span><span class="s2">&#34;</span> -
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">git config --global diff.pdf.textconv pdf2txt
</span></span></code></pre></div><p>Now <code>git diff</code> on an image shows which metadata changed, and <code>git log -p</code> on a Word doc or PDF shows actual prose changes. Add <code>git config --global diff.pdf.cachetextconv true</code> so Git doesn&rsquo;t reconvert unchanged files every time.</p>
<h2 id="5-silence-noise-you-dont-care-about">5. Silence noise you don&rsquo;t care about</h2>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-gitattributes" data-lang="gitattributes"><span class="line"><span class="cl"><span class="s">package-lock.json</span> <span class="o">-</span><span class="na">diff</span>
</span></span><span class="line"><span class="cl"><span class="s">Cargo.lock</span>        <span class="o">-</span><span class="na">diff</span>
</span></span><span class="line"><span class="cl"><span class="s">*.min.js</span>          <span class="o">-</span><span class="na">diff</span>
</span></span></code></pre></div>
<p>The files are still tracked and versioned normally. <code>git diff</code> just prints &ldquo;Binary files differ&rdquo; instead of four thousand lines of dependency churn.</p>
<h2 id="6-merge-strategies-per-file">6. Merge strategies per file</h2>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-gitattributes" data-lang="gitattributes"><span class="line"><span class="cl"><span class="s">CHANGELOG.md</span>  <span class="na">merge</span><span class="o">=</span><span class="l">union</span>
</span></span></code></pre></div>
<p><code>union</code> keeps the lines from both sides instead of creating a conflict. It&rsquo;s perfect for append-only files like changelogs or lists of contributors. It&rsquo;s dangerous for anything where order or structure matters, like code or JSON.</p>
<p>You can also keep your own version of a file on merge:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-gitattributes" data-lang="gitattributes"><span class="line"><span class="cl"><span class="s">config/local.yml</span> <span class="na">merge</span><span class="o">=</span><span class="l">ours</span>
</span></span></code></pre></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">git config merge.ours.driver <span class="nb">true</span>
</span></span></code></pre></div><p><strong>Know this:</strong> a merge driver only runs when <em>both</em> branches changed the file. If only the other branch touched it, Git takes their version cleanly and your driver never runs. Plenty of people have been surprised by this.</p>
<h2 id="7-clean-and-smudge-filters">7. Clean and smudge filters</h2>
<p>This is the most powerful feature in the file. A filter has two halves:</p>
<ul>
<li><strong>clean</strong> runs when a file is staged, transforming the working copy into what gets stored.</li>
<li><strong>smudge</strong> runs on checkout, transforming the stored version back into your working copy.</li>
</ul>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-gitattributes" data-lang="gitattributes"><span class="line"><span class="cl"><span class="s">*.ipynb</span> <span class="na">filter</span><span class="o">=</span><span class="l">nbstrip</span>
</span></span></code></pre></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">git config filter.nbstrip.clean <span class="s2">&#34;jupyter nbconvert --clear-output --to notebook --stdout --stdin&#34;</span>
</span></span><span class="line"><span class="cl">git config filter.nbstrip.smudge cat
</span></span><span class="line"><span class="cl">git config filter.nbstrip.required <span class="nb">true</span>
</span></span></code></pre></div><p>Now notebook outputs never get committed, but you keep them locally.</p>
<p>Real-world tools built on filters:</p>
<ul>
<li><strong>Git LFS</strong> stores large files elsewhere and commits small pointers: <code>*.psd filter=lfs diff=lfs merge=lfs -text</code></li>
<li><strong>git-crypt</strong> transparently encrypts files in the repo and decrypts them on checkout</li>
<li><strong>nbstripout</strong> does the notebook trick above</li>
</ul>
<p><strong>Know this:</strong> filter <em>definitions</em> live in Git config, not in the repo. <code>.gitattributes</code> only says &ldquo;use the filter named X.&rdquo; Every person who clones the repo has to set up the filter themselves. This is on purpose: if a repo could define commands that run automatically on checkout, cloning a stranger&rsquo;s repo would be a security risk. Document your filters in the README.</p>
<h2 id="8-shape-your-release-archives">8. Shape your release archives</h2>
<p><code>git archive</code> builds a tarball or zip of your repo. Attributes control what goes in it:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-gitattributes" data-lang="gitattributes"><span class="line"><span class="cl"><span class="s">tests/</span>         <span class="na">export-ignore</span>
</span></span><span class="line"><span class="cl"><span class="s">.github/</span>       <span class="na">export-ignore</span>
</span></span><span class="line"><span class="cl"><span class="s">.gitattributes</span> <span class="na">export-ignore</span>
</span></span><span class="line"><span class="cl"><span class="s">VERSION</span>        <span class="na">export-subst</span>
</span></span></code></pre></div>
<p><code>export-ignore</code> leaves files out of release archives. GitHub&rsquo;s &ldquo;Download ZIP&rdquo; and release source tarballs respect this too. It&rsquo;s also one of the few attributes where a directory pattern like <code>tests/</code> works, because <code>git archive</code> checks each directory as it walks the tree.</p>
<p><code>export-subst</code> expands placeholders at archive time. Put this in <code>VERSION</code>:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">$Format:%H$ $Format:%cd$
</span></span></code></pre></div><p>The archive will contain the real commit hash and date.</p>
<h2 id="9-the-github-extras">9. The GitHub extras</h2>
<p>GitHub reads a set of <code>linguist-*</code> attributes that change how your repo is displayed:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-gitattributes" data-lang="gitattributes"><span class="line"><span class="cl"><span class="s">vendor/**</span>      <span class="na">linguist-vendored</span>
</span></span><span class="line"><span class="cl"><span class="s">dist/**</span>        <span class="na">linguist-generated</span>
</span></span><span class="line"><span class="cl"><span class="s">docs/**</span>        <span class="na">linguist-documentation</span>
</span></span><span class="line"><span class="cl"><span class="s">*.nx</span>           <span class="na">linguist-language</span><span class="o">=</span><span class="l">Rust</span>
</span></span><span class="line"><span class="cl"><span class="s">scripts/*.txt</span>  <span class="na">linguist-detectable</span>
</span></span></code></pre></div>
<ul>
<li><code>linguist-vendored</code> and <code>linguist-documentation</code> exclude files from the language bar.</li>
<li><code>linguist-generated</code> also <strong>collapses those files in pull request diffs</strong>. For generated code, lockfiles, or build output, this makes reviews much faster.</li>
<li><code>linguist-language</code> fixes misdetection, or lets a custom language borrow another&rsquo;s highlighting.</li>
<li><code>linguist-detectable</code> forces a language to count toward the stats when it normally wouldn&rsquo;t.</li>
</ul>
<h2 id="10-lesser-known-attributes">10. Lesser-known attributes</h2>
<p><strong><code>whitespace</code></strong> sets per-file rules for what <code>git diff --check</code> flags:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-gitattributes" data-lang="gitattributes"><span class="line"><span class="cl"><span class="s">*.md</span> <span class="na">whitespace</span><span class="o">=</span><span class="l">-trailing-space</span>
</span></span><span class="line"><span class="cl"><span class="s">*.py</span> <span class="na">whitespace</span><span class="o">=</span><span class="l">trailing-space,tab-in-indent</span>
</span></span></code></pre></div>
<p>Markdown uses two trailing spaces as a line break, so you don&rsquo;t want those flagged there.</p>
<p><strong><code>working-tree-encoding</code></strong> stores UTF-16 files as UTF-8 in the repo so they diff properly, then converts them back on checkout:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-gitattributes" data-lang="gitattributes"><span class="line"><span class="cl"><span class="s">*.ps1</span> <span class="na">working-tree-encoding</span><span class="o">=</span><span class="l">UTF-16LE</span> <span class="na">eol</span><span class="o">=</span><span class="l">crlf</span>
</span></span></code></pre></div>
<p>Only use it on files that really are UTF-16. With this line, <code>git add</code> refuses a <code>.ps1</code> saved with a byte order mark (declare <code>UTF-16LE-BOM</code> for those) and one saved as UTF-8, which is what most editors write today.</p>
<p><strong><code>-delta</code></strong> skips delta compression for huge binaries that don&rsquo;t compress well anyway, which speeds up packing:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-gitattributes" data-lang="gitattributes"><span class="line"><span class="cl"><span class="s">*.mp4</span> <span class="o">-</span><span class="na">delta</span>
</span></span></code></pre></div>
<p><strong><code>ident</code></strong> expands <code>$Id$</code> in a file to <code>$Id: &lt;blob hash&gt;$</code> on checkout. It&rsquo;s old-school, but handy for embedding a file&rsquo;s exact version.</p>
<p><strong><code>lockable</code></strong> works with Git LFS file locking. Files are checked out read-only until you lock them, which prevents two people from editing the same unmergeable file:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-gitattributes" data-lang="gitattributes"><span class="line"><span class="cl"><span class="s">*.psd</span> <span class="na">lockable</span>
</span></span></code></pre></div>
<h2 id="11-macros">11. Macros</h2>
<p>In the top-level <code>.gitattributes</code> only, you can define your own bundles of attributes:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-gitattributes" data-lang="gitattributes"><span class="line"><span class="cl"><span class="k">[attr]</span><span class="nf">lockfile</span> <span class="o">-</span><span class="na">diff</span> <span class="na">linguist-generated</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="s">package-lock.json</span> <span class="na">lockfile</span>
</span></span><span class="line"><span class="cl"><span class="s">yarn.lock</span>         <span class="na">lockfile</span>
</span></span><span class="line"><span class="cl"><span class="s">Cargo.lock</span>        <span class="na">lockfile</span>
</span></span></code></pre></div>
<p>One name, one place to change it later.</p>
<p><strong>Know this:</strong> don&rsquo;t put <code>merge=ours</code> in a macro like this. When both branches add a dependency, the merge succeeds without a conflict and the lockfile quietly loses the other branch&rsquo;s entries. Regenerate lockfiles after a merge instead.</p>
<h2 id="a-sane-starter-file">A sane starter file</h2>
<p>If you take one thing from this post, drop this into your next project:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-gitattributes" data-lang="gitattributes"><span class="line"><span class="cl"><span class="c1"># Normalize line endings</span>
</span></span><span class="line"><span class="cl"><span class="s">*</span> <span class="na">text</span><span class="o">=</span><span class="l">auto</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Scripts that care about endings</span>
</span></span><span class="line"><span class="cl"><span class="s">*.sh</span>  <span class="na">text</span> <span class="na">eol</span><span class="o">=</span><span class="l">lf</span>
</span></span><span class="line"><span class="cl"><span class="s">*.bat</span> <span class="na">text</span> <span class="na">eol</span><span class="o">=</span><span class="l">crlf</span>
</span></span><span class="line"><span class="cl"><span class="s">*.ps1</span> <span class="na">text</span> <span class="na">eol</span><span class="o">=</span><span class="l">crlf</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Better diff hunk headers</span>
</span></span><span class="line"><span class="cl"><span class="s">*.py</span> <span class="na">diff</span><span class="o">=</span><span class="l">python</span>
</span></span><span class="line"><span class="cl"><span class="s">*.md</span> <span class="na">diff</span><span class="o">=</span><span class="l">markdown</span>
</span></span><span class="line"><span class="cl"><span class="s">*.rs</span> <span class="na">diff</span><span class="o">=</span><span class="l">rust</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Binaries</span>
</span></span><span class="line"><span class="cl"><span class="s">*.png</span> <span class="na">binary</span>
</span></span><span class="line"><span class="cl"><span class="s">*.jpg</span> <span class="na">binary</span>
</span></span><span class="line"><span class="cl"><span class="s">*.gif</span> <span class="na">binary</span>
</span></span><span class="line"><span class="cl"><span class="s">*.ico</span> <span class="na">binary</span>
</span></span><span class="line"><span class="cl"><span class="s">*.zip</span> <span class="na">binary</span>
</span></span><span class="line"><span class="cl"><span class="s">*.pdf</span> <span class="na">binary</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Quiet noisy files</span>
</span></span><span class="line"><span class="cl"><span class="s">*.lock</span>            <span class="o">-</span><span class="na">diff</span>
</span></span><span class="line"><span class="cl"><span class="s">package-lock.json</span> <span class="o">-</span><span class="na">diff</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Keep release archives clean</span>
</span></span><span class="line"><span class="cl"><span class="s">.github/</span>        <span class="na">export-ignore</span>
</span></span><span class="line"><span class="cl"><span class="s">.gitattributes</span>  <span class="na">export-ignore</span>
</span></span></code></pre></div>
<h2 id="closing-thought">Closing thought</h2>
<p><code>.gitignore</code> tells Git what to leave out. <code>.gitattributes</code> tells Git how to understand everything you keep. It&rsquo;s a few lines of text that fix line-ending chaos, make diffs readable, prevent merge disasters, and shape what your users download. Most repos never touch it. Now yours can.</p>
]]></content:encoded></item></channel></rss>