<?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://londo.me/blog/tags/tooling/</link><description>Recent content in Tooling on Londopy</description><image><title>Londopy</title><url>https://londo.me/blog/og-image.png</url><link>https://londo.me/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:52:31 -0700</lastBuildDate><atom:link href="https://londo.me/blog/tags/tooling/index.xml" rel="self" type="application/rss+xml"/><item><title>git bisect run: Let Git Find the Bug for You</title><link>https://londo.me/blog/posts/git-bisect-run/</link><pubDate>Thu, 08 Oct 2026 09:00:00 -0700</pubDate><guid>https://londo.me/blog/posts/git-bisect-run/</guid><description>Something broke, and you don&amp;#39;t know which of the last 400 commits did it. Git can binary-search your history and find the exact commit, automatically.</description><content:encoded><![CDATA[<p>It worked last month. It doesn&rsquo;t work now. There are 400 commits in between.</p>
<p>You could read them all. Or you could let Git find the guilty one in about nine steps.</p>
<h2 id="the-idea-binary-search-through-history">The idea: binary search through history</h2>
<p><code>git bisect</code> takes two points: a commit where things were <strong>good</strong> and one where they&rsquo;re <strong>bad</strong>. It checks out the commit halfway between and asks you which it is. Then it throws away the half that can&rsquo;t contain the bug and repeats.</p>
<p>Each step halves the search. 400 commits takes at most 9 steps. 10,000 commits takes at most 14.</p>
<h2 id="manual-bisect">Manual bisect</h2>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">git bisect start
</span></span><span class="line"><span class="cl">git bisect bad                 <span class="c1"># the current commit is broken</span>
</span></span><span class="line"><span class="cl">git bisect good v2.3.0         <span class="c1"># this tag was fine</span>
</span></span></code></pre></div><p>Git checks out a commit in the middle and tells you roughly how many steps are left. Test it, then tell Git:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">git bisect good    <span class="c1"># this one works</span>
</span></span><span class="line"><span class="cl"><span class="c1"># or</span>
</span></span><span class="line"><span class="cl">git bisect bad     <span class="c1"># this one is broken</span>
</span></span></code></pre></div><p>Repeat until Git prints:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">a1b2c3d is the first bad commit
</span></span></code></pre></div><p>When you&rsquo;re done, go back to where you started:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">git bisect reset
</span></span></code></pre></div><p>You can also give both endpoints up front:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">git bisect start HEAD v2.3.0
</span></span></code></pre></div><h2 id="the-real-magic-git-bisect-run">The real magic: <code>git bisect run</code></h2>
<p>If you can write a script that tells good from bad, Git will do the whole search without you:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">git bisect start HEAD v2.3.0
</span></span><span class="line"><span class="cl">git bisect run ./check.sh
</span></span></code></pre></div><p>Git checks out each candidate commit, runs your script, and reads its <strong>exit code</strong>:</p>
<table>
	<thead>
			<tr>
					<th>Exit code</th>
					<th>Meaning</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>0</td>
					<td>good</td>
			</tr>
			<tr>
					<td>1 to 127 (not 125)</td>
					<td>bad</td>
			</tr>
			<tr>
					<td>125</td>
					<td>skip: this commit can&rsquo;t be tested</td>
			</tr>
			<tr>
					<td>128 or higher</td>
					<td>abort the whole bisect</td>
			</tr>
	</tbody>
</table>
<p>Your test suite is often the script already:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">git bisect run pytest tests/test_login.py
</span></span><span class="line"><span class="cl">git bisect run cargo <span class="nb">test</span> parse_header
</span></span><span class="line"><span class="cl">git bisect run npm <span class="nb">test</span>
</span></span></code></pre></div><h2 id="writing-a-good-check-script">Writing a good check script</h2>
<p>The script should test <em>only</em> the bug you&rsquo;re hunting. A full test suite may fail for unrelated reasons partway through history.</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"># check.sh: exit 0 if the bug is absent, 1 if present, 125 if we can&#39;t tell</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># If it doesn&#39;t build, we can&#39;t test this commit. Skip it.</span>
</span></span><span class="line"><span class="cl">make &gt;/dev/null 2&gt;<span class="p">&amp;</span><span class="m">1</span> <span class="o">||</span> <span class="nb">exit</span> <span class="m">125</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># The actual check</span>
</span></span><span class="line"><span class="cl">./app --version <span class="p">|</span> grep -q <span class="s2">&#34;expected output&#34;</span> <span class="o">&amp;&amp;</span> <span class="nb">exit</span> <span class="m">0</span> <span class="o">||</span> <span class="nb">exit</span> <span class="m">1</span>
</span></span></code></pre></div><p>A few tips:</p>
<ul>
<li><strong>Use 125 for &ldquo;can&rsquo;t test.&rdquo;</strong> Commits that don&rsquo;t compile, or that predate the feature entirely, should be skipped, not marked bad. Otherwise Git blames the wrong commit.</li>
<li><strong>Keep the script outside the repo,</strong> or untracked. Bisect checks out old commits, and an old commit might not have your script, or might have an older version of it.</li>
<li><strong>Make it fast.</strong> It runs once per step.</li>
<li><strong>Make it deterministic.</strong> A flaky test sends the search in the wrong direction and gives a confident, wrong answer.</li>
</ul>
<h2 id="useful-extras">Useful extras</h2>
<p><strong>Skip a commit by hand:</strong></p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">git bisect skip
</span></span></code></pre></div><p><strong>See or replay the session.</strong> If you mark a commit wrong, save the log, edit out the mistake, and replay it:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">git bisect log &gt; bisect.log
</span></span><span class="line"><span class="cl"><span class="c1"># edit bisect.log</span>
</span></span><span class="line"><span class="cl">git bisect reset
</span></span><span class="line"><span class="cl">git bisect replay bisect.log
</span></span></code></pre></div><p><strong>Visualize what&rsquo;s left:</strong></p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">git bisect visualize --oneline
</span></span></code></pre></div><p><strong>Hunt for things that aren&rsquo;t bugs.</strong> &ldquo;Good&rdquo; and &ldquo;bad&rdquo; are just labels. You can rename them:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">git bisect start --term-old<span class="o">=</span>fast --term-new<span class="o">=</span>slow
</span></span><span class="line"><span class="cl">git bisect fast v2.3.0
</span></span><span class="line"><span class="cl">git bisect slow HEAD
</span></span></code></pre></div><p>Now you can find the commit where something got slow, or the commit where a feature first appeared.</p>
<p><strong>Ignore merged-in side branches.</strong> On a repo with many merges, <code>--first-parent</code> only follows the main line of history:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">git bisect start --first-parent
</span></span></code></pre></div><h2 id="things-you-should-know">Things you should know</h2>
<ul>
<li><strong>Bisect needs a clean working tree.</strong> Commit or stash your changes first.</li>
<li><strong>It finds the first bad commit, not the cause.</strong> Sometimes the &ldquo;first bad commit&rdquo; just exposed a bug that was already there. Read the diff before blaming anyone.</li>
<li><strong>Small commits make bisect better.</strong> If the guilty commit changed 3,000 lines, knowing which commit it was doesn&rsquo;t help much. Another reason to commit in small, focused steps.</li>
</ul>
<h2 id="closing-thought">Closing thought</h2>
<p>The next time you catch yourself scrolling through <code>git log</code> trying to guess when something broke, stop. Write a ten-line script, run <code>git bisect run</code>, and go get a coffee.</p>
]]></content:encoded></item><item><title>git worktree: Stop Stashing, Start Branching in Parallel</title><link>https://londo.me/blog/posts/git-worktree/</link><pubDate>Thu, 01 Oct 2026 09:00:00 -0700</pubDate><guid>https://londo.me/blog/posts/git-worktree/</guid><description>Check out multiple branches at the same time, in separate folders, from one repo. No stash, no second clone, no lost work.</description><content:encoded><![CDATA[<p>You&rsquo;re halfway through a feature. Files are open, tests are half-written, nothing compiles. Then someone says: &ldquo;Production is broken, can you look?&rdquo;</p>
<p>Most people do one of these:</p>
<ul>
<li><code>git stash</code>, switch branches, fix, switch back, <code>git stash pop</code>, pray.</li>
<li>Commit a &ldquo;WIP&rdquo; mess they&rsquo;ll have to clean up later.</li>
<li>Clone the whole repo again into another folder.</li>
</ul>
<p>There&rsquo;s a better option that has been in Git since 2015: <strong>worktrees</strong>.</p>
<h2 id="what-a-worktree-is">What a worktree is</h2>
<p>A normal repo has one working directory: the folder where your files live. A worktree is an <em>extra</em> working directory attached to the same repo. Each one can have a different branch checked out, and they all share the same history, objects, and remotes.</p>
<p>It&rsquo;s like a second clone, except:</p>
<ul>
<li>It&rsquo;s instant. Nothing is downloaded or copied.</li>
<li>It uses almost no extra disk space for history.</li>
<li>A commit made in one worktree is immediately visible from the others.</li>
</ul>
<h2 id="the-basics">The basics</h2>
<p>Create a worktree for an existing branch:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">git worktree add ../myproject-hotfix hotfix
</span></span></code></pre></div><p>Now <code>../myproject-hotfix</code> is a full working folder with the <code>hotfix</code> branch checked out. Your original folder is untouched, half-finished feature and all.</p>
<p>Create a worktree <em>and</em> a new branch at the same time:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">git worktree add -b fix-login ../myproject-fix-login main
</span></span></code></pre></div><p>That makes a new branch <code>fix-login</code> starting from <code>main</code>, checked out in a new folder.</p>
<p>See all your worktrees:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">git worktree list
</span></span></code></pre></div><p>When you&rsquo;re done, remove it:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">git worktree remove ../myproject-hotfix
</span></span></code></pre></div><p>If you deleted a worktree folder by hand instead, clean up Git&rsquo;s records of it:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">git worktree prune
</span></span></code></pre></div><h2 id="daily-uses">Daily uses</h2>
<p><strong>Emergency fixes without disruption.</strong> The scenario above. Your feature work stays exactly where you left it.</p>
<p><strong>Reviewing a pull request while you keep working.</strong> Check out the PR branch in its own worktree, run it, test it, and delete the folder when you&rsquo;re done.</p>
<p><strong>Comparing behavior side by side.</strong> Run the old version and the new version at the same time in two terminals.</p>
<p><strong>Long builds.</strong> Kick off a slow build or test suite in one worktree and keep coding in another. Your edits won&rsquo;t change files out from under the running build.</p>
<p><strong>Running AI coding agents in parallel.</strong> Give each agent its own worktree so they don&rsquo;t overwrite each other&rsquo;s changes.</p>
<h2 id="things-you-should-know">Things you should know</h2>
<p><strong>One branch, one worktree.</strong> Git won&rsquo;t let you check out the same branch in two worktrees at once, because commits in one would confuse the other. If you try, you&rsquo;ll get an error. (There&rsquo;s a <code>--force</code> flag, but you almost never want it.)</p>
<p><strong>Some things are shared, some aren&rsquo;t.</strong> Commits, branches, tags, remotes, config, and the stash are shared across all worktrees. The working files, the index (staging area), and <code>HEAD</code> are per-worktree.</p>
<p><strong>Untracked files don&rsquo;t come along.</strong> A new worktree starts clean. Your <code>.env</code>, <code>node_modules</code>, or build folders from the main worktree won&rsquo;t be there. You&rsquo;ll need to reinstall dependencies or copy config.</p>
<p><strong>Keep worktrees outside the main folder.</strong> Putting a worktree inside your main working directory makes tools, file watchers, and searches see duplicate files. A sibling folder (<code>../project-something</code>) is the usual convention.</p>
<p><strong>Lock worktrees on removable drives.</strong> If a worktree lives on a USB drive or network share that isn&rsquo;t always mounted, lock it so <code>prune</code> doesn&rsquo;t delete its records:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">git worktree lock ../usb-worktree --reason <span class="s2">&#34;on external drive&#34;</span>
</span></span></code></pre></div><h2 id="a-bare-repo-trick">A bare-repo trick</h2>
<p>Some people go further: they clone a repo as <em>bare</em> (no working files at all) and do everything through worktrees:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">git clone --bare git@github.com:you/project.git project/.bare
</span></span><span class="line"><span class="cl"><span class="nb">cd</span> project
</span></span><span class="line"><span class="cl"><span class="nb">echo</span> <span class="s2">&#34;gitdir: ./.bare&#34;</span> &gt; .git
</span></span><span class="line"><span class="cl">git config remote.origin.fetch <span class="s2">&#34;+refs/heads/*:refs/remotes/origin/*&#34;</span>
</span></span><span class="line"><span class="cl">git fetch
</span></span><span class="line"><span class="cl">git worktree add main
</span></span><span class="line"><span class="cl">git worktree add feature-x
</span></span></code></pre></div><p>The <code>git config</code> line matters. A bare clone doesn&rsquo;t set up remote-tracking branches, so without it, <code>git fetch</code> quietly never updates them.</p>
<p>Now every branch you work on is a tidy subfolder of <code>project/</code>. It takes a little getting used to, but it makes &ldquo;which branch am I on?&rdquo; a question your file manager answers.</p>
<h2 id="closing-thought">Closing thought</h2>
<p><code>git stash</code> is fine for thirty seconds. For anything longer, stop juggling and give each branch its own folder.</p>
]]></content:encoded></item><item><title>The Best Git Feature You've Never Used</title><link>https://londo.me/blog/posts/gitattributes/</link><pubDate>Thu, 24 Sep 2026 01:50:00 -0700</pubDate><guid>https://londo.me/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>