<?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>Git on Londopy</title><link>https://londo.me/blog/tags/git/</link><description>Recent content in Git 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/git/index.xml" rel="self" type="application/rss+xml"/><item><title>Your .git Folder Is a Database. Let's Read It.</title><link>https://londo.me/blog/posts/git-internals/</link><pubDate>Thu, 15 Oct 2026 09:00:00 -0700</pubDate><guid>https://londo.me/blog/posts/git-internals/</guid><description>Git isn&amp;#39;t magic. It&amp;#39;s a small, clever key-value store with a few text files on top. Here&amp;#39;s how to explore it by hand.</description><content:encoded><![CDATA[<p>Every Git repo has a hidden <code>.git</code> folder. Most people never open it. That&rsquo;s where all of Git lives: every version of every file, every branch, every commit message. And it&rsquo;s surprisingly simple once you look.</p>
<p>Let&rsquo;s take it apart.</p>
<h2 id="setup">Setup</h2>
<p>Make a throwaway repo so we can poke at it safely:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">mkdir git-internals <span class="o">&amp;&amp;</span> <span class="nb">cd</span> git-internals
</span></span><span class="line"><span class="cl">git init -b main
</span></span><span class="line"><span class="cl"><span class="nb">echo</span> <span class="s2">&#34;hello&#34;</span> &gt; hello.txt
</span></span><span class="line"><span class="cl">git add hello.txt
</span></span><span class="line"><span class="cl">git commit -m <span class="s2">&#34;first commit&#34;</span>
</span></span></code></pre></div><p>Now look inside:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">ls .git
</span></span></code></pre></div><p>You&rsquo;ll see things like <code>HEAD</code>, <code>config</code>, <code>index</code>, <code>objects/</code>, and <code>refs/</code>. We&rsquo;ll go through the important ones.</p>
<h2 id="objects-the-actual-data">Objects: the actual data</h2>
<p>Git stores everything as <strong>objects</strong> in <code>.git/objects</code>. There are four kinds:</p>
<ul>
<li><strong>blob:</strong> the contents of a file (just the bytes, no name)</li>
<li><strong>tree:</strong> a directory listing (names, permissions, and pointers to blobs and other trees)</li>
<li><strong>commit:</strong> a snapshot (a pointer to one tree, parent commits, author, message)</li>
<li><strong>tag:</strong> an annotated tag (a pointer to an object, plus a message)</li>
</ul>
<p>Every object is named by the hash of its contents. That&rsquo;s why Git is called a <strong>content-addressable</strong> store: the address <em>is</em> the content&rsquo;s fingerprint.</p>
<h2 id="finding-our-file">Finding our file</h2>
<p>Ask Git what hash &ldquo;hello\n&rdquo; would get as a blob:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl"><span class="nb">echo</span> <span class="s2">&#34;hello&#34;</span> <span class="p">|</span> git hash-object --stdin
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">ce013625030ba8dba906f756967f9e9ca394464a
</span></span></code></pre></div><p>You&rsquo;ll get this exact hash on any machine, in any repo. Same content, same hash, always.</p>
<p>Now find it on disk. Git splits the hash: the first two characters are a folder, the rest is the file name:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">ls .git/objects/ce/
</span></span></code></pre></div><p>There it is. The file is zlib-compressed, so <code>cat</code> shows garbage. Git can read it for us:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">git cat-file -t ce0136   <span class="c1"># type</span>
</span></span><span class="line"><span class="cl">git cat-file -p ce0136   <span class="c1"># contents</span>
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">blob
</span></span><span class="line"><span class="cl">hello
</span></span></code></pre></div><p>Or decompress it yourself with Python to see the raw format:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">python3 -c <span class="s2">&#34;import zlib,sys; print(zlib.decompress(open(sys.argv[1],&#39;rb&#39;).read()))&#34;</span> .git/objects/ce/013625030ba8dba906f756967f9e9ca394464a
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">b&#39;blob 6\x00hello\n&#39;
</span></span></code></pre></div><p>That&rsquo;s the entire format: the type, a space, the size in bytes, a null byte, then the content. The hash is SHA-1 of exactly that string.</p>
<h2 id="walking-from-a-commit-down-to-a-file">Walking from a commit down to a file</h2>
<p>Start at the latest commit:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">git cat-file -p HEAD
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">tree aaa96ced2d9a1c8e72c56b253a0e2fe78393feb7
</span></span><span class="line"><span class="cl">author ...
</span></span><span class="line"><span class="cl">committer ...
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">first commit
</span></span></code></pre></div><p>Follow the tree:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">git cat-file -p aaa96c
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">100644 blob ce013625030ba8dba906f756967f9e9ca394464a    hello.txt
</span></span></code></pre></div><p>That&rsquo;s the whole chain: <strong>commit → tree → blob</strong>. A commit doesn&rsquo;t store differences; it points to a full snapshot. Git saves space because unchanged files keep the same hash, so every commit just points to the same blob again.</p>
<p>(Your commit hash will differ from mine, because a commit includes your name and the exact time. The tree and blob hashes will match, because they depend only on file names and contents.)</p>
<h2 id="refs-names-for-hashes">Refs: names for hashes</h2>
<p>Nobody wants to type 40-character hashes. <strong>Refs</strong> are files that hold them:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">cat .git/refs/heads/main
</span></span></code></pre></div><p>That&rsquo;s it. A branch is a text file containing a commit hash. Creating a branch is writing one small file. That&rsquo;s why branches in Git are so cheap.</p>
<p>And <code>HEAD</code>?</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">cat .git/HEAD
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">ref: refs/heads/main
</span></span></code></pre></div><p><code>HEAD</code> usually points to a branch, which points to a commit. When you&rsquo;re in &ldquo;detached HEAD&rdquo; state, <code>HEAD</code> contains a commit hash directly instead.</p>
<p>If a branch seems to be missing from <code>refs/heads</code>, check <code>.git/packed-refs</code>. Git bundles refs into that one file to save space.</p>
<h2 id="packfiles-how-git-stays-small">Packfiles: how Git stays small</h2>
<p>Storing every version of every file as a separate compressed object would get big. So Git periodically <strong>packs</strong> objects into <code>.git/objects/pack/</code>, storing similar objects as deltas (differences) against each other.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">git gc
</span></span><span class="line"><span class="cl">git count-objects -v
</span></span></code></pre></div><p>Loose objects disappear into a <code>.pack</code> file with a matching <code>.idx</code> index. To peek inside:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">git verify-pack -v .git/objects/pack/pack-*.idx <span class="p">|</span> head
</span></span></code></pre></div><p>Interesting detail: this is the <em>only</em> place Git uses deltas. Your history is modeled as snapshots; deltas are just a storage optimization underneath.</p>
<h2 id="the-index-the-staging-area-is-a-file">The index: the staging area is a file</h2>
<p><code>git add</code> writes to <code>.git/index</code>, a binary file listing what the next commit will contain. You can see it in readable form:</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 --stage
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">100644 ce013625030ba8dba906f756967f9e9ca394464a 0	hello.txt
</span></span></code></pre></div><p>The staging area isn&rsquo;t a concept. It&rsquo;s a file.</p>
<h2 id="the-reflog-your-safety-net">The reflog: your safety net</h2>
<p>Every time <code>HEAD</code> or a branch moves, Git writes a line to <code>.git/logs/</code>:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">git reflog
</span></span></code></pre></div><p>This is how you recover from almost anything: a bad reset, a deleted branch, a rebase gone wrong. Find the hash from before the mistake and:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">git branch rescue &lt;hash&gt;
</span></span></code></pre></div><p>Unreachable objects are only deleted by garbage collection after a grace period (two weeks by default for loose objects). Reflog entries last 90 days, but only 30 for commits that are no longer on any branch, which is what a bad reset or rebase leaves behind. You have time.</p>
<h2 id="build-a-commit-with-no-porcelain">Build a commit with no porcelain</h2>
<p>For the grand finale, make a commit using only low-level commands:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl"><span class="nb">echo</span> <span class="s2">&#34;built by hand&#34;</span> <span class="p">|</span> git hash-object -w --stdin
</span></span><span class="line"><span class="cl"><span class="c1"># note the hash, then:</span>
</span></span><span class="line"><span class="cl">git update-index --add --cacheinfo <span class="m">100644</span> &lt;blob-hash&gt; handmade.txt
</span></span><span class="line"><span class="cl">git write-tree
</span></span><span class="line"><span class="cl"><span class="c1"># note the tree hash, then:</span>
</span></span><span class="line"><span class="cl">git commit-tree &lt;tree-hash&gt; -p HEAD -m <span class="s2">&#34;a commit made by hand&#34;</span>
</span></span><span class="line"><span class="cl"><span class="c1"># note the commit hash, then:</span>
</span></span><span class="line"><span class="cl">git update-ref refs/heads/main &lt;commit-hash&gt;
</span></span></code></pre></div><p>Run <code>git log</code>. Your hand-built commit is there, indistinguishable from any other. (<code>git status</code> will call <code>handmade.txt</code> deleted, because so far the file only exists in the repo. <code>git restore handmade.txt</code> writes it out.) Every friendly Git command is built from pieces like these.</p>
<h2 id="things-you-should-know">Things you should know</h2>
<ul>
<li><strong>Don&rsquo;t edit <code>.git</code> by hand in real repos.</strong> Explore in a throwaway one.</li>
<li><strong>SHA-256 repos exist.</strong> Git supports <code>git init --object-format=sha256</code>, which gives 64-character hashes. The structure is the same.</li>
<li><strong>Refs won&rsquo;t always be files.</strong> Git 3.0 will switch new repos to the reftable format, and you can try it today with <code>git init --ref-format=reftable</code>. There, branches live in binary tables under <code>.git/reftable/</code>, and <code>.git/HEAD</code> just says <code>ref: refs/heads/.invalid</code>. Commands like <code>git branch</code> and <code>git rev-parse</code> work the same either way.</li>
<li><strong>Hashes include everything.</strong> Change one byte of a file, one character of a commit message, or a timestamp, and the hash changes, along with every commit after it. That&rsquo;s why rewriting history changes all later commit IDs.</li>
</ul>
<h2 id="closing-thought">Closing thought</h2>
<p>Once you&rsquo;ve seen that a branch is a text file and a commit is a pointer to a snapshot, most of Git&rsquo;s &ldquo;weird&rdquo; behavior stops being weird. It&rsquo;s a database. Now you can read it.</p>
]]></content:encoded></item><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>