<?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>Londopy</title><link>https://londo.me/blog/</link><description>Recent content 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>Sat, 26 Sep 2026 02:30:55 -0700</lastBuildDate><atom:link href="https://londo.me/blog/index.xml" rel="self" type="application/rss+xml"/><item><title>Things GitHub Actions Can Do That Aren't CI</title><link>https://londo.me/blog/posts/github-actions-beyond-ci/</link><pubDate>Thu, 22 Oct 2026 09:00:00 -0700</pubDate><guid>https://londo.me/blog/posts/github-actions-beyond-ci/</guid><description>GitHub Actions is a free, event-driven computer attached to every repo. Running tests is just the start.</description><content:encoded><![CDATA[<p>Most people meet GitHub Actions as &ldquo;the thing that runs tests on pull requests.&rdquo; But under the hood it&rsquo;s much more general: a computer that wakes up when something happens, runs whatever you tell it, and goes back to sleep. For public repos, it&rsquo;s free.</p>
<p>Here are things you can do with it that have nothing to do with CI.</p>
<h2 id="how-it-works-in-30-seconds">How it works in 30 seconds</h2>
<p>A workflow is a YAML file in <code>.github/workflows/</code>. It has:</p>
<ul>
<li><strong>triggers</strong> (<code>on:</code>): a push, a new issue, a schedule, a button click</li>
<li><strong>jobs</strong> that run on a fresh virtual machine</li>
<li><strong>steps</strong>, each either a shell command or a reusable action</li>
</ul>
<p>That&rsquo;s it. Anything you can script, you can trigger.</p>
<h2 id="1-host-a-website">1. Host a website</h2>
<p>This blog is built by Actions. Every push to <code>main</code> builds the site with Hugo and publishes it to GitHub Pages. No server, no bill.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">push</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">branches</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">main]</span><span class="w">
</span></span></span></code></pre></div><p>The official <code>actions/upload-pages-artifact</code> and <code>actions/deploy-pages</code> actions do the publishing.</p>
<h2 id="2-run-things-on-a-schedule">2. Run things on a schedule</h2>
<p>Actions supports cron:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">schedule</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="nt">cron</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;0 14 * * 1&#34;</span><span class="w">   </span><span class="c"># every Monday at 14:00 UTC</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">workflow_dispatch</span><span class="p">:</span><span class="w">        </span><span class="c"># plus a manual &#34;Run&#34; button</span><span class="w">
</span></span></span></code></pre></div><p>Uses: nightly data scrapes, weekly reports, checking whether a website is up, rebuilding a site so &ldquo;posted 3 days ago&rdquo; stays accurate.</p>
<p><strong>Know this:</strong></p>
<ul>
<li>Times are in <strong>UTC</strong>.</li>
<li>Scheduled runs can start late when GitHub is busy, sometimes by many minutes. Don&rsquo;t use it for anything that must happen at an exact second.</li>
<li>In public repos, scheduled workflows are <strong>automatically disabled after 60 days without repository activity</strong>. GitHub emails you first.</li>
</ul>
<h2 id="3-keep-a-file-updated-automatically">3. Keep a file updated automatically</h2>
<p>A workflow can edit files and commit them back. For example, a profile README that always lists your latest blog posts:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">permissions</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">contents</span><span class="p">:</span><span class="w"> </span><span class="l">write</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">jobs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">update</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v7</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">./scripts/update-readme.sh</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="p">|</span><span class="sd">
</span></span></span><span class="line"><span class="cl"><span class="sd">          git config user.name &#34;github-actions[bot]&#34;
</span></span></span><span class="line"><span class="cl"><span class="sd">          git config user.email &#34;41898282+github-actions[bot]@users.noreply.github.com&#34;
</span></span></span><span class="line"><span class="cl"><span class="sd">          git add README.md
</span></span></span><span class="line"><span class="cl"><span class="sd">          git diff --cached --quiet || git commit -m &#34;Update latest posts&#34;
</span></span></span><span class="line"><span class="cl"><span class="sd">          git push</span><span class="w">
</span></span></span></code></pre></div><p>The <code>git diff --cached --quiet ||</code> part only commits if something actually changed, so you don&rsquo;t get empty commits.</p>
<h2 id="4-manage-issues-and-pull-requests">4. Manage issues and pull requests</h2>
<ul>
<li><strong>Auto-label</strong> PRs by which files they touch (<code>actions/labeler</code>).</li>
<li><strong>Welcome first-time contributors</strong> with a friendly comment.</li>
<li><strong>Close stale issues</strong> after a period of inactivity (<code>actions/stale</code>). Use gently; closing people&rsquo;s issues automatically can feel rude.</li>
<li><strong>Enforce PR title formats</strong> for changelogs.</li>
</ul>
<p>Triggers like <code>issues</code>, <code>issue_comment</code>, and <code>pull_request</code> make this easy.</p>
<h2 id="5-build-a-chatops-bot">5. Build a ChatOps bot</h2>
<p>A workflow can react to comments. Someone types <code>/deploy staging</code> on a PR, and a workflow checks their permissions and runs the deploy. Check the comment author&rsquo;s permission level before doing anything, or anyone on the internet can trigger it.</p>
<h2 id="6-watch-other-things-on-the-internet">6. Watch other things on the internet</h2>
<p>A scheduled job can:</p>
<ul>
<li>check whether your site returns 200 and open an issue if not</li>
<li>check whether a dependency released a new version</li>
<li>watch a page for changes and notify you</li>
</ul>
<p>GitHub isn&rsquo;t built for real-time monitoring, but for &ldquo;check once an hour and tell me,&rdquo; it&rsquo;s plenty.</p>
<h2 id="7-trigger-workflows-from-outside-github">7. Trigger workflows from outside GitHub</h2>
<p>With <code>repository_dispatch</code>, anything that can send an HTTP request with a GitHub token can start a workflow: a script on your server, a webhook from another service, a phone shortcut. The token needs write access to the repo, so treat it like a password.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">repository_dispatch</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">types</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">rebuild-site]</span><span class="w">
</span></span></span></code></pre></div><h2 id="8-generate-release-artifacts">8. Generate release artifacts</h2>
<p>Build binaries for Windows, macOS, and Linux in parallel with a matrix, then attach them to a GitHub Release automatically when you push a version tag.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">push</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">tags</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&#34;v*&#34;</span><span class="p">]</span><span class="w">
</span></span></span></code></pre></div><h2 id="things-you-should-know">Things you should know</h2>
<p><strong>Least privilege.</strong> Set <code>permissions:</code> explicitly in every workflow. Give each workflow only what it needs.</p>
<p><strong>Secrets and forks.</strong> Workflows triggered by pull requests from forks don&rsquo;t get your secrets, on purpose. Be very careful with the <code>pull_request_target</code> trigger: it runs with your secrets and write permissions, and if it checks out and runs the fork&rsquo;s code, a stranger&rsquo;s PR can steal your secrets.</p>
<p><strong>Pin actions.</strong> A third-party action is someone else&rsquo;s code running with your permissions. Pin it to a full commit hash rather than a tag if you don&rsquo;t fully trust the author:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl">- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">some-author/some-action@8f4b7f84864484a7bf31766abe9204da3cbe65b3</span><span class="w">
</span></span></span></code></pre></div><p><strong>Free isn&rsquo;t unlimited.</strong> Public repos get free minutes on standard runners. Private repos get a monthly allowance, then usage is billed. Check the current limits before you build something heavy.</p>
<h2 id="closing-thought">Closing thought</h2>
<p>Think of every repo as coming with a small, free, scriptable computer that listens for events. CI is one thing you can do with it. Your imagination and the terms of service are the rest.</p>
]]></content:encoded></item><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 App That Reports You Missing</title><link>https://londo.me/blog/posts/diresq/</link><pubDate>Sat, 26 Sep 2026 09:00:00 -0700</pubDate><guid>https://londo.me/blog/posts/diresq/</guid><description>During Hurricane Harvey, volunteers in fishing boats saved thousands of people, and nobody kept track of the volunteers. We built the first version of DiresQ in one 14-hour night at our first hackathon ever, kept building it for four more hackathons, and it placed 3rd in the Software Development track at Reverie Hacks 2026. Here&amp;#39;s how it works, and the three times our own code lied to us.</description><content:encoded><![CDATA[<p>During Hurricane Harvey in 2017, ordinary people took their own fishing boats into the floodwater and pulled thousands of their neighbors off roofs. Some of them didn&rsquo;t come back.</p>
<p>Here&rsquo;s the detail that stuck with me: when a volunteer like that heads out, <strong>nobody writes down that they went</strong>. There&rsquo;s no dispatcher, no roster, no log. If they stop answering their phone, nothing notices. Eventually, someone realizes they haven&rsquo;t heard from them in a while.</p>
<p>One dispatcher, interviewed afterward by disaster researchers, described trying to keep track of who was out on a boat and who had come back, and asking the question nobody could answer: &ldquo;can we account for everyone?&rdquo;</p>
<p>So my teammate and I built an app that answers it. If you go out and stop checking in, it notices. And if you stay quiet long enough, it files a report about you, at the last place anyone knew you were, so someone can come find you.</p>
<p>It&rsquo;s called <strong>DiresQ</strong>. We built the first version in a single 14-hour night for Katy Youth Hacks 2026, the first hackathon either of us had ever entered. Then we kept building it and entered it in four more. It placed <strong>3rd in the Software Development track at Reverie Hacks 2026</strong>, an online hackathon with more than 2,100 participants and 423 submitted projects.</p>
<p>But placing isn&rsquo;t the interesting part. The interesting part is the three times our own code lied to us, and looked completely fine doing it.</p>
<p><img alt="A responder goes quiet, the accountability board turns red, and a report files itself" loading="lazy" src="/blog/posts/diresq/demo.gif"></p>
<p><em>Five responders on scene. One goes quiet. At fifteen minutes the board turns red, and the report on the right files itself.</em></p>
<h2 id="every-disaster-app-tracks-the-disaster">Every disaster app tracks the disaster</h2>
<p>Crowdsourced flood maps show where the water is. Social media shows who&rsquo;s asking for help. Agencies have dispatch systems, but only for their own people: on a roster, carrying a radio, answering to an incident commander.</p>
<p>A neighbor with a boat is none of those things. Every tool we looked at mapped the incident. We couldn&rsquo;t find one that kept a list of the people walking into it.</p>
<p>Here&rsquo;s DiresQ in about sixty seconds:</p>
<ol>
<li>A neighbor&rsquo;s street is flooding. She files a report: &ldquo;Water rising, 2 trapped.&rdquo; It lands at the top of the feed, reading <strong>0 responding</strong>.</li>
<li>You have a boat. You see her report sitting above one that already has six people on it, so you join hers and type how long before anyone should start worrying about you: &ldquo;30 min.&rdquo; Your row on the accountability board turns blue.</li>
<li>You arrive and mark yourself on scene. Your row turns green. It&rsquo;s worse than she said, so you tag the report <strong>needs more help</strong>, and it climbs the feed.</li>
<li>Then you go into a flooded house and stop checking in. Thirty minutes pass. Your row turns <strong>red</strong>, showing your last known position and how long since anyone heard from you.</li>
<li>Fifteen minutes after that, the server stops waiting for someone to notice. It files a new report, about you, at your last known position.</li>
</ol>
<p>Nobody had to notice you went quiet. That&rsquo;s the product. Everything else is how you get there.</p>
<p>But the first version we designed would have made things worse.</p>
<h2 id="our-first-design-was-wrong">Our first design was wrong</h2>
<p>We started with the obvious model: dispatch. One responder claims one report, it&rsquo;s locked, and nobody else can take it. No two people driving to the same address.</p>
<p>Then we read about Kathmandu in 2015 and Mexico City in 1985. In both, huge numbers of volunteers converged on a few highly visible collapse sites, while sites nearby had nobody at all. People dug at the building that was on television.</p>
<p>The real failure in a disaster isn&rsquo;t two people going to the same address. It&rsquo;s <strong>six hundred people going to the same address</strong> while the street two blocks over has no one. A claim lock fights the wrong problem, and sometimes a collapse genuinely needs forty people.</p>
<p>So we deleted it. Any number of people can join any report. Instead of preventing convergence, DiresQ makes it visible:</p>
<ul>
<li>Every report shows how many people are already on it.</li>
<li>People on scene can say &ldquo;we need more&rdquo; or &ldquo;we&rsquo;re overstaffed,&rdquo; and the feed reorders so the next person goes where the help isn&rsquo;t.</li>
<li>When people on scene disagree, the most cautious answer wins. An optimistic report should never be able to drown out a call for help.</li>
<li>A report nobody is going to sorts <em>above</em> one that&rsquo;s merely short-handed. A gap is worse than a queue.</li>
</ul>
<p>That was the design. Building it was a different story.</p>
<h2 id="the-first-night-on-two-clocks">The first night, on two clocks</h2>
<p>The first version was built for Katy Youth Hacks, in one 14-hour window. For me, it ran from 6pm to 8am. For my teammate, <a href="https://github.com/Skythe7">Skythe</a>, the same fourteen hours ran from 8am to 10pm. We were building the same app at opposite ends of the day. We met on the <a href="https://discord.gg/zARY5CAvkh">Coding for Teens Discord server</a>, if you&rsquo;re looking for a teammate of your own.</p>
<p>It was also my first time being part of a real team, and it felt like a preview of an actual software job. We started with a call to introduce ourselves and plan, then hung up and got to work. From there it was a steady stream of DMs, with a quick call every so often to check in. And in between came those long stretches where you just lock in and code for hours.</p>
<p>The first time the app actually worked was amazing. But it didn&rsquo;t feel like a finish line. It felt like the start of a fire. It felt good, and I immediately wanted more.</p>
<p>We split the code by folder, not by feature. Skythe owned the frontend: the page templates and styles. I owned the backend: the Flask app, the database schema, and the API. The one rule: <strong>new files are free, but editing someone else&rsquo;s needs a message first.</strong> We changed code in parallel all night and never once had a merge conflict.</p>
<p>We did have one very strange commit. At some point that night, a single commit touched 39 files, added 10,462 lines, deleted 10,462 lines, and changed nothing at all. We develop on Windows and deploy on Linux, and every line ending in the repo had silently flipped. The fix was a file most repos don&rsquo;t have: <code>.gitattributes</code>. (I liked it enough to write <a href="/blog/posts/gitattributes/">a whole post about it</a>.)</p>
<p>We also leaned on four small libraries I&rsquo;d written and published to PyPI before the hackathon: <strong>timefuzz</strong> turns &ldquo;back in a couple hours&rdquo; into a real deadline, <strong>vitalscore</strong> runs the START triage protocol, <strong>pygeospy</strong> does the map math, and <strong>patchnotes</strong> checks our changelog on every push.</p>
<p>By 8am, the git log had 65 commits from that night, and the repo had 12,579 lines of code.</p>
<p>Then our plan fell apart, and it was my fault.</p>
<h2 id="the-plan-didnt-survive-contact">The plan didn&rsquo;t survive contact</h2>
<p>The plan said the backend would put up fake &ldquo;stub&rdquo; API endpoints in the first hour, so the frontend could build against them without waiting.</p>
<p>The stubs were my job. I never wrote them. I was building the interesting part instead.</p>
<p>Skythe didn&rsquo;t wait, which was the right call. The git log tells the story: Skythe&rsquo;s first commit landed at 6:05pm my time, and by the time my first backend commit showed up at 7:40pm, there were already five complete pages in the repo, built as server-rendered templates that don&rsquo;t need the API at all. That turned out to be the better design: <strong>the whole app works with JavaScript switched off.</strong> That&rsquo;s exactly what you want from a tool for the worst day of someone&rsquo;s life, running on an old phone with one bar of signal.</p>
<p>What it cost us was agreement. With no stubs and no conversation, we each guessed at the other&rsquo;s names, and three came out different:</p>
<table>
	<thead>
			<tr>
					<th>Frontend</th>
					<th>Backend</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><code>HIGH</code> / <code>MEDIUM</code> / <code>LOW</code></td>
					<td>integers 1 to 4</td>
			</tr>
			<tr>
					<td><code>latitude</code> / <code>longitude</code></td>
					<td><code>lat</code> / <code>lng</code></td>
			</tr>
			<tr>
					<td><code>needs_more</code></td>
					<td><code>need_more</code></td>
			</tr>
	</tbody>
</table>
<p>The frontend&rsquo;s names won all three times, because Skythe&rsquo;s templates were real, working software and my schema was still a document nobody had run.</p>
<p>The lesson I took: <strong>unblock other people before you build your favorite part.</strong> Fifteen minutes agreeing on names would have saved every one of those mismatches.</p>
<p>And then the code started lying to us.</p>
<h2 id="lie-1-the-classifier-that-scored-100">Lie #1: the classifier that scored 100%</h2>
<p>Someone filing a report at 2am from a flooded house gets asked to pick a severity from a dropdown. They don&rsquo;t know. They&rsquo;re scared, and they aren&rsquo;t trained.</p>
<p>So DiresQ suggests one. I hand-wrote a small naive Bayes classifier, trained on 55 hand-labeled example reports. It reads your description, suggests a priority, and shows you the exact words that drove the decision. It runs in about a tenth of a millisecond with no network, and the moment you touch the dropdown yourself, it stops touching it.</p>
<p>We tested it the obvious way first: run it over its training data. It scored <strong>100%</strong>.</p>
<p>That number was worthless. It had simply memorized its 55 examples. So we measured it properly: hold one report out, train on the other 54, predict the one it had never seen, and repeat that 55 times.</p>
<table>
	<thead>
			<tr>
					<th></th>
					<th>Accuracy on unseen reports</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Always guess the most common label</td>
					<td>36%</td>
			</tr>
			<tr>
					<td>Naive Bayes alone</td>
					<td>45%</td>
			</tr>
			<tr>
					<td>Naive Bayes plus a severity word list</td>
					<td>75%</td>
			</tr>
	</tbody>
</table>
<p>Nine points better than guessing, and wrong in the worst possible direction. &ldquo;Child not breathing properly&rdquo; came back MEDIUM. &ldquo;Gas smell, whole street evacuating&rdquo; came back LOW.</p>
<p>The fix was a list of the phrases the START triage protocol treats as immediate, which took it to 75%. That measurement now runs on every push, and the build fails if it ever drops below 68%. It&rsquo;s still wrong one time in four, which is survivable only because it&rsquo;s a suggestion in a dropdown you control, not a decision.</p>
<p>Of everything that weekend, this is what taught me the most: not building the model, but learning how to test one. Working out why it scored 100%, fixing it, and publishing both numbers instead of the flattering one.</p>
<p>Then we found a subtler lie. A report typed in Spanish, &ldquo;mi madre no puede respirar&rdquo; (&ldquo;my mother can&rsquo;t breathe&rdquo;), came back LOW, 51% confident. Naive Bayes can&rsquo;t say &ldquo;I don&rsquo;t know.&rdquo; When every word is unfamiliar, it falls back to its base rates and outputs a label that looks like knowledge. Katy, Texas, where we set our demo, is more than a quarter Hispanic or Latino, so that isn&rsquo;t a hypothetical. Now, when the model doesn&rsquo;t recognize the wording, it gives no suggestion and says why.</p>
<p><strong>Why not just use an AI model?</strong> Three reasons, in order:</p>
<ol>
<li><strong>It has to explain itself.</strong> The words DiresQ shows you are the actual math behind the decision, not a separately generated explanation that could disagree with it.</li>
<li><strong>It has to be honest about being wrong.</strong> A confident paragraph from a language model is much harder to doubt, and confidently wrong sends boats to the wrong street.</li>
<li><strong>It has to work with the cell towers down.</strong> No download, no API key, no network. So the same trained model runs on the phone, too.</li>
</ol>
<p>(We did use AI tools while building DiresQ, for code and documentation. There&rsquo;s just none inside the product.)</p>
<p>Putting the model on the phone led to the strangest bug of the whole project. We&rsquo;ll get there. First, the alarm.</p>
<h2 id="lie-2-the-alarm-that-could-die-quietly">Lie #2: the alarm that could die quietly</h2>
<p>The dead man&rsquo;s switch, the part that files a report when you go quiet, started life as a background timer inside the app.</p>
<p>Then we thought about what happens when a background timer crashes: nothing. No error on screen. The board keeps rendering, every row stays green, and green reads as &ldquo;everyone&rsquo;s fine&rdquo; when it really means &ldquo;nobody&rsquo;s checking.&rdquo;</p>
<p>So we deleted the timer. DiresQ never stores whether someone is overdue. It works that out fresh every time someone loads the board, and runs the escalation check at the same moment. There&rsquo;s nothing to forget to start, and nothing that can silently die.</p>
<p>That left one honest dependency: if nobody has the board open, nothing gets checked. So the board shows when the check last ran, <strong>&ldquo;checked 2s ago,&rdquo;</strong> and turns amber if it ever stops. The thing that notices when people go quiet won&rsquo;t go quiet without saying so. (There&rsquo;s also a command you can run on a schedule, so the alarm doesn&rsquo;t depend on someone having a tab open.)</p>
<p>That became the theme of the whole project: <strong>the failure that matters is the one that leaves no trace.</strong></p>
<h2 id="lie-3-bugs-that-looked-like-working-features">Lie #3: bugs that looked like working features</h2>
<p>Every bug below passed its tests, threw no errors, and looked fine. We only found them by using the code for real.</p>
<ul>
<li><strong>Error messages that went nowhere.</strong> The backend reported errors in five places, like a wrong password, but no page ever displayed them. A rejected form just sat there looking frozen.</li>
<li><strong>A required field that wasn&rsquo;t.</strong> The report form required hidden latitude and longitude fields. Browsers skip validation on hidden fields, so a report submitted without a map pin saved with no location: invisible on the map, forever.</li>
<li><strong>A database that broke on the second run.</strong> A table added late got created but never dropped, so rebuilding an existing database stopped halfway and left it in pieces. Every test passed, because tests start from an empty database, and empty is the one case that always works.</li>
<li><strong>A phishing hole we built by accident.</strong> After you logged in, the app sent you to whatever address the link&rsquo;s <code>?next=</code> parameter said. So a link to our real login page, on our real domain, could hand you straight to someone else&rsquo;s site. The tests only checked that logging in redirected you, which it did.</li>
<li><strong>A map that would run whatever people typed.</strong> Report titles went into the map&rsquo;s pop-ups as raw HTML. A title containing the right HTML, like an image tag with an error handler, would have run its code in the browser of everyone who opened the map. We caught it at 4:36am and rebuilt the pop-ups so that text is only ever treated as text.</li>
<li><strong>A security check that searched nothing.</strong> Our CI scanned for hardcoded secrets. The fix landed four minutes after the scan did: a quoting mistake in the YAML crashed it before it searched a single file, and the failure looked like any other. We rewrote it in Python and tested it against a deliberately leaky file to prove it catches real ones.</li>
</ul>
<p>And the strangest one, from putting the classifier on the phone. We ran every example through both versions, Python on the server and JavaScript in the browser, with a test that fails on any disagreement. It found a bug within minutes: the words shown behind a suggestion were sorted by floating-point noise, because Python and V8 (the JavaScript engine in Chrome and Node) round logarithms differently in the very last bit. That bug had been in the Python since day one. With only one implementation, it was invisible.</p>
<h2 id="the-things-we-refused-to-build">The things we refused to build</h2>
<p>A tool that looks like an emergency service can do real harm by looking more capable than it is. So we wrote down everything DiresQ doesn&rsquo;t do, and put that list inside the app.</p>
<ul>
<li><strong>No identity verification.</strong> Anyone can sign up. Doing it properly needs real ID checks, and there&rsquo;s no honest weekend version. A fake check, like email confirmation, would be worse than nothing, because it looks like verification without being it.</li>
<li><strong>No calling 911.</strong> DiresQ never contacts emergency services or implies that it has. The worst failure would be someone filing a report and believing help is on the way.</li>
<li><strong>No radio, yet.</strong> A check-in packs into 22 signed bytes, small enough for a LoRa long-range radio, with a replay counter so a captured packet can&rsquo;t simply be sent again. There&rsquo;s even a gateway script to forward packets to the server. What doesn&rsquo;t exist is the radio: we don&rsquo;t have the hardware, and untested code isn&rsquo;t a feature. It&rsquo;s a file that looks like one.</li>
</ul>
<p>DiresQ has never been used in a real disaster, and it shouldn&rsquo;t be until someone who does this professionally has taken it apart.</p>
<h2 id="it-wasnt-one-night">It wasn&rsquo;t one night</h2>
<p>The 14 hours were only Katy Youth Hacks. That wanting-more feeling from the first night never went away: we kept building DiresQ and entered it in four more hackathons: STEMist Hacks IV, Reverie Hacks, the Girls In STEM Global Hackathon, and the CSC Summer Impactathon. Here&rsquo;s roughly what the git log says, in my time zone:</p>
<table>
	<thead>
			<tr>
					<th>When (Pacific time)</th>
					<th>Hours</th>
					<th>Commits</th>
					<th>What happened</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Sat Aug 1, 6pm to Sun 8am</td>
					<td>14.0</td>
					<td>65</td>
					<td>Katy Youth Hacks: the whole app, from an empty repo</td>
			</tr>
			<tr>
					<td>Sun 3pm to Mon 6am</td>
					<td>14.7</td>
					<td>40</td>
					<td>Works offline and installs on a phone; releases 1.0.0 to 1.0.2</td>
			</tr>
			<tr>
					<td>Mon 6pm to Tue 2am</td>
					<td>7.6</td>
					<td>30</td>
					<td>Map fixes, then reading the research and writing a paper</td>
			</tr>
			<tr>
					<td>Wednesday</td>
					<td>2.5</td>
					<td>12</td>
					<td>A demo clock for filming, an accessibility pass, release 1.1.0</td>
			</tr>
	</tbody>
</table>
<p>The hours run from the first commit to the last in each session, so the real total is higher: reading, testing, and filming between commits don&rsquo;t show up. Even so, that&rsquo;s about 39 hours of commits over just over four days: 147 commits (plus five setup commits from the week before), four tagged releases, and a docs site that rebuilds itself from the repo on every push. The test suite grew from 344 tests in our Katy Youth Hacks submission to 606 test functions. GitHub Actions has run 288 times so far, mostly the tests, the security scan, and the changelog check that run on every push.</p>
<p>Here&rsquo;s what those extra hours bought:</p>
<ul>
<li><strong>It works with no signal.</strong> Reports and check-ins save to your phone first and send themselves when you reconnect. Each one gets its ID before the first attempt, so a phone that dies mid-send can&rsquo;t file the same emergency twice.</li>
<li><strong>It passed an accessibility audit.</strong> A WCAG 2.1 AA audit found 22 issues across four passes, six of them critical. We fixed all 22, and a test holds each fix in place.</li>
<li><strong>It turned into a paper.</strong> By the third evening, we realized the interesting claim wasn&rsquo;t the app. It was the gap. So we read the research to check whether the gap was real, going back to the primary sources. One commit message from that night: &ldquo;Read the 1957 original instead of citing it secondhand.&rdquo; That reading became an eleven-page preprint, which says plainly that DiresQ has no users, no deployment, and no evaluation.</li>
<li><strong>It learned to be filmed.</strong> The dead man&rsquo;s switch only does something after fifteen minutes of silence, which makes for a terrible demo video. So DiresQ got a scaled clock: at 60x speed, a responder goes overdue five seconds after the page loads, and the report about them arrives fifteen seconds after that. It&rsquo;s the exact same escalation code, and a banner on screen admits that time is sped up.</li>
</ul>
<p>Then we entered Reverie Hacks 2026.</p>
<h2 id="3rd-place">3rd place</h2>
<p>I found out in the middle of a marine biology lecture. We got a two-minute break to drink some water and rest our brains, I checked my phone, and there was a DM from Skythe: we&rsquo;d placed.</p>
<p>I was freaking out internally. All I wanted to do was watch the hackathon&rsquo;s showcase video on YouTube, check Discord, check Devpost, check everything. Instead, I had to sit through the rest of the lecture.</p>
<p>The second it ended, it all hit me, and every hour felt worth it. I even told my parents, even though they don&rsquo;t understand any of the coding and tech stuff.</p>
<p>Reverie Hacks drew more than 2,100 participants and 423 submitted projects across six tracks. DiresQ took 3rd place in the Software Development track. Not bad for a project that started as our first hackathon ever.</p>
<h3 id="what-the-judges-said">What the judges said</h3>
<p>Two judges scored DiresQ out of 105 points. One gave it 96, the other 94.</p>
<table>
	<thead>
			<tr>
					<th>Category</th>
					<th>Max</th>
					<th>Judge 1</th>
					<th>Judge 2</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Real-world problem and impact</td>
					<td>25</td>
					<td>24</td>
					<td>25</td>
			</tr>
			<tr>
					<td>Technical execution</td>
					<td>25</td>
					<td>24</td>
					<td>25</td>
			</tr>
			<tr>
					<td>Innovation and originality</td>
					<td>15</td>
					<td>14</td>
					<td>12</td>
			</tr>
			<tr>
					<td>User experience and design</td>
					<td>15</td>
					<td>13</td>
					<td>12</td>
			</tr>
			<tr>
					<td>Sustainability and scalability</td>
					<td>10</td>
					<td>8</td>
					<td>6</td>
			</tr>
			<tr>
					<td>Presentation and communication</td>
					<td>10</td>
					<td>10</td>
					<td>10</td>
			</tr>
			<tr>
					<td>Bonus: exceptionality</td>
					<td>+5</td>
					<td>3</td>
					<td>4</td>
			</tr>
			<tr>
					<td><strong>Total</strong></td>
					<td><strong>105</strong></td>
					<td><strong>96</strong></td>
					<td><strong>94</strong></td>
			</tr>
	</tbody>
</table>
<p>The first judge summed up the project better than I could. The strongest part, they wrote, wasn&rsquo;t the amount of functionality but the reasoning behind it: &ldquo;the team repeatedly identifies dangerous failure modes, measures them, and designs around them.&rdquo; That&rsquo;s the whole project in one sentence.</p>
<p>The bonus points stand out, too. The rubric told judges that the bonus was reserved for the rare project that goes beyond it, and that most projects should get zero, by design. Both judges gave DiresQ some: 3 points and 4. The second judge&rsquo;s reason was how complete and disciplined the whole project was for our level.</p>
<p>They also read the code, not just the writeup. The second judge noted the exact commit they reviewed, which was the last one we pushed, and admitted to getting through about half the files.</p>
<p>The criticism was fair, too:</p>
<ul>
<li><strong>It&rsquo;s unproven.</strong> Both judges pointed out that DiresQ has never been used in a real disaster, learned from only 55 examples, and relies on self-reported locations. The first judge noted that being upfront about those limits made the submission more credible, but also means its real-world effectiveness is still unproven.</li>
<li><strong>The writeup was ahead of the interface.</strong> The second judge found that some flows we described, like joining a report and voting on staffing, looked rougher in the actual app than in our writeup. That&rsquo;s a fair hit.</li>
<li><strong>Scale isn&rsquo;t solved.</strong> Moderation, identity checks, and hosting costs at scale are all written down, but none of them are solved. Sustainability was our lowest-scoring category on both sheets.</li>
</ul>
<p>Both criticisms point to the same next step, and it isn&rsquo;t a feature. It&rsquo;s talking to people who have actually coordinated volunteers in a disaster, to find out whether the gap we designed for is as real as it looks from the research.</p>
<h2 id="what-id-tell-myself-before-the-next-hackathon">What I&rsquo;d tell myself before the next hackathon</h2>
<ul>
<li><strong>Name your dependencies out loud.</strong> If someone is waiting on you, that&rsquo;s the first thing you do, not a line in a planning doc.</li>
<li><strong>Agree on names and shapes in writing before you start.</strong> It takes fifteen minutes.</li>
<li><strong>Write the first test early.</strong> Ours found two real bugs within minutes of existing.</li>
<li><strong>Test on a database that already has data in it.</strong> Empty is the one case that always works.</li>
<li><strong>Treat 100% as a warning, not a result.</strong> Measure the clever version before you keep it.</li>
<li><strong>Write your limitations while you build.</strong> It&rsquo;s the only time you actually remember what you decided not to handle.</li>
<li><strong>Don&rsquo;t let the writeup get ahead of the interface.</strong> A judge will open the app and check.</li>
</ul>
<h2 id="try-it">Try it</h2>
<ul>
<li><strong>Live demo:</strong> <a href="https://diresq.onrender.com">diresq.onrender.com</a>. Sign in as <code>londo</code> with the password <code>diresq</code>. It&rsquo;s on a free tier, so the first load can take about a minute to wake up.</li>
<li><strong>Demo video:</strong> <a href="https://youtu.be/T0Udg9WgRYA">YouTube</a></li>
<li><strong>Docs:</strong> <a href="https://skythe7.github.io/DiresQ">skythe7.github.io/DiresQ</a></li>
<li><strong>Code:</strong> <a href="https://github.com/Skythe7/DiresQ">github.com/Skythe7/DiresQ</a></li>
<li><strong>Devpost:</strong> <a href="https://devpost.com/software/diresq-8zjngi">DiresQ on Devpost</a></li>
</ul>
<p>Built by Skythe (frontend) and me (backend).</p>
<h2 id="closing-thought">Closing thought</h2>
<p>Back to that dispatcher&rsquo;s question: can we account for everyone?</p>
<p>With DiresQ, the answer is either yes, or a red row with a name, a last known position, and a report that&rsquo;s already been filed.</p>
<p>Nobody has to notice you went quiet.</p>
]]></content:encoded></item><item><title>When Alligator Counts as Fish &amp; Why I'm a Pescatarian</title><link>https://londo.me/blog/posts/pescatarian/</link><pubDate>Thu, 24 Sep 2026 23:06:00 -0700</pubDate><guid>https://londo.me/blog/posts/pescatarian/</guid><description>For two thousand years, religions have argued about where meat ends and fish begins, with rulings on beavers, alligators, and geese that grow on driftwood. I&amp;#39;ve lived on that line for eight years. Here&amp;#39;s the history, the Daoist philosophy behind how I think about it, and how I actually eat.</description><content:encoded><![CDATA[<p>In 2010, a Catholic in Louisiana asked his archbishop a very specific question. During Lent, Catholics skip meat on Fridays. So: was it okay to eat alligator?</p>
<p>The archbishop&rsquo;s answer: yes. As far as the Church was concerned, alligator counted as seafood.</p>
<p>He wasn&rsquo;t the first religious authority to get a question like that. Over the centuries, priests, bishops, and theologians have been asked to rule on beavers, muskrats, capybaras, and a goose that medieval Europeans believed grew on driftwood.</p>
<p>People have been arguing about where &ldquo;meat&rdquo; ends and &ldquo;fish&rdquo; begins for about two thousand years.</p>
<p>I&rsquo;ve been living on that line for eight.</p>
<h2 id="the-short-version-of-me">The short version of me</h2>
<p>I&rsquo;m a <strong>pescatarian</strong>. For more than eight years, I haven&rsquo;t eaten meat or poultry: no beef, no pork, no chicken, no turkey. I do eat fish, seafood, eggs, and dairy. Sushi is my second-favorite food on Earth.</p>
<p>Spoiler: my own reasons ended up closer to a Chinese philosopher who lived 2,300 years ago, and who once got into an argument about whether fish are happy, than to any bishop&rsquo;s.</p>
<p>But first: almost every major religion has drawn this line too, and their reasons are stranger than mine.</p>
<h2 id="christianity-was-a-fish-religion-from-the-start">Christianity was a fish religion from the start</h2>
<p>Jesus&rsquo;s first disciples were fishermen. The miracle of the loaves and fishes fed thousands. In the Gospel of Luke, the resurrected Jesus proves he isn&rsquo;t a ghost by eating a piece of broiled fish. Early Christians even adopted the fish as a symbol: the Greek word for fish, <em>ichthys</em>, doubles as an acronym for &ldquo;Jesus Christ, Son of God, Savior.&rdquo;</p>
<p>So when the Church began setting fast days when meat was forbidden, fish was never on the chopping block. In the 1200s, Thomas Aquinas explained why. The flesh of land animals was richer and more pleasurable, so it was more likely to stir up exactly the appetites fasting was meant to tame. Fish was lighter, humbler food.</p>
<p>Today&rsquo;s rule works the same way. Catholics 14 and older skip meat on Ash Wednesday and the Fridays of Lent, and &ldquo;meat&rdquo; means the flesh of land animals like cows, pigs, and chickens. Fish, shellfish, and cold-blooded animals don&rsquo;t count.</p>
<p>In medieval Europe, fast days covered a huge chunk of the calendar. That created enormous demand for fish, which helped build entire fishing economies. It also created enormous demand for <strong>loopholes</strong>.</p>
<h2 id="is-a-beaver-a-fish">Is a beaver a fish?</h2>
<p>According to a much-repeated story, in the 1600s the Bishop of Quebec asked theologians whether beaver could be eaten on fast days. Beavers spend their lives in the water and have scaly tails. The verdict: close enough to a fish.</p>
<p>It didn&rsquo;t stop there:</p>
<ul>
<li><strong>Muskrat:</strong> in parts of southeast Michigan, Catholics have long been allowed to eat muskrat on Lenten Fridays, a tradition going back to French settlers along the Detroit River.</li>
<li><strong>Capybara:</strong> in Venezuela, capybara is a traditional Lenten dish, backed by a legend that the Vatican once classified the world&rsquo;s largest rodent as a fish.</li>
<li><strong>Alligator:</strong> see above. Louisiana, 2010.</li>
<li><strong>Barnacle geese:</strong> these geese breed in the Arctic, so medieval Europeans never saw them nest. The popular explanation was that they grew out of barnacles on driftwood. If they came from the sea, they were fish, and fair game on fast days. Pope Innocent III is said to have shut that one down in 1215.</li>
</ul>
<p>The pattern is always the same: if it lives in water, somebody will argue it&rsquo;s a fish.</p>
<p>And in 1962, that same fasting rule accidentally created one of the most famous sandwiches in fast food.</p>
<h2 id="the-burger-that-lost-to-a-fish-sandwich">The burger that lost to a fish sandwich</h2>
<p>Lou Groen ran a McDonald&rsquo;s in a heavily Catholic part of Cincinnati. Every Friday, his sales collapsed, because his customers went to the fish place down the road.</p>
<p>So he invented a battered fish sandwich and pitched it to Ray Kroc, the head of McDonald&rsquo;s. Kroc had his own meatless idea: the <strong>Hula Burger</strong>, a slice of grilled pineapple with cheese on a bun.</p>
<p>They made a bet. Both sandwiches would go on sale one Friday, and whichever sold more would stay on the menu. According to McDonald&rsquo;s lore, the Hula Burger got crushed.</p>
<p>The <strong>Filet-O-Fish</strong> went national in 1965. McDonald&rsquo;s has said Lent is still the sandwich&rsquo;s busiest season. A Catholic fasting rule from the Middle Ages is the reason it exists.</p>
<p>Meanwhile, the Orthodox Church read the same Bible and took the salmon off the menu too. But not the shrimp.</p>
<h2 id="shrimp-yes-salmon-no">Shrimp: yes. Salmon: no.</h2>
<p>Eastern Orthodox Christians fast even harder, and their line lands somewhere completely different.</p>
<p>During strict fasts like Great Lent, Orthodox Christians traditionally give up meat, dairy, eggs, and fish with backbones. But shrimp, squid, octopus, and other shellfish are allowed, because they&rsquo;re considered &ldquo;bloodless&rdquo; invertebrates. Fish itself comes back only on a couple of feast days, like Palm Sunday.</p>
<p>So a Catholic during Lent can eat salmon but not chicken. An Orthodox Christian during Lent can eat shrimp but not salmon.</p>
<p>And in Jewish law, shrimp is off the menu entirely. The same rules explain why your bagel works.</p>
<h2 id="why-your-bagel-works">Why your bagel works</h2>
<p>Kosher law draws its line with a physical test: fish must have <strong>fins and scales</strong> (Leviticus 11). That rules out shellfish, catfish, eels, and more.</p>
<p>And kosher fish belongs to a special category: <em>pareve</em>, meaning neither meat nor dairy. Kosher law forbids mixing meat and dairy, but fish is fine with dairy. That&rsquo;s why lox and cream cheese on a bagel is a Jewish deli classic, while a real cheeseburger could never be kosher.</p>
<p>(Fish and meat are kept apart, though: the Talmud warns against eating them together, and the Shulchan Aruch, the standard code of Jewish law, made that a rule.)</p>
<p>Islamic law has fish rules too, and one of them makes fish the easiest meal on the menu.</p>
<h2 id="the-easiest-halal-meal-on-the-menu">The easiest halal meal on the menu</h2>
<p>The Quran explicitly permits food from the sea. Unlike land animals, fish don&rsquo;t need ritual slaughter to be halal. So for Muslims in places where halal meat is hard to find, fish is often the easy option.</p>
<p>The schools of Islamic law still draw the line in different places. Most Sunni schools allow nearly all seafood. The Hanafi school generally limits it to fish, with debate over shrimp. Shia law allows fish with scales, plus shrimp, but not other shellfish.</p>
<p>Further east, the story involves a god who took the form of a fish, and an emperor who shocked his country by eating beef.</p>
<h2 id="a-fish-god-and-a-beef-eating-emperor">A fish god and a beef-eating emperor</h2>
<p>In Hindu tradition, the first avatar of the god Vishnu is <strong>Matsya</strong>, a fish, who warns Manu, the first man, of a great flood and tows his boat to safety. Many Hindus are vegetarian, but not everywhere. In Bengal, fish is so central that even many Brahmin families, who are often strictly vegetarian elsewhere in India, eat it, and a decorated whole fish is a traditional gift at Bengali weddings.</p>
<p>Buddhism&rsquo;s first precept is to refrain from taking life, but Buddhist traditions apply it to food very differently. Many East Asian Buddhist monks are strictly vegetarian, and Japanese Zen temple cooking, <em>shojin ryori</em>, uses no meat or fish at all. In Theravada tradition, monks eat whatever they&rsquo;re given as alms, including meat, as long as the animal wasn&rsquo;t killed specifically for them.</p>
<p>Japan is the most dramatic example of a whole country living near the pescatarian line. In 675, Emperor Tenmu restricted eating cattle, horses, dogs, monkeys, and chickens during part of the year. Over the following centuries, under Buddhist influence, livestock mostly disappeared from the Japanese table, and fish moved to the center of it. (Hunted game never vanished completely.) Even whales were widely treated as fish; the Japanese character for &ldquo;whale&rdquo; is still written with the fish radical.</p>
<p>Then, in 1872, Emperor Meiji publicly ate beef, a signal that the old taboo was over. Modern Japanese beef dishes like sukiyaki grew out of that change.</p>
<h2 id="so-why-am-i-a-pescatarian">So why am I a pescatarian?</h2>
<p>Here&rsquo;s what surprised me when I started digging: none of these traditions agree on where the line goes. Beaver is fish in one place, salmon is off-limits in another, and shrimp is either the safest or the most forbidden thing on the menu depending on who you ask. The line between meat and fish isn&rsquo;t found in nature. People draw it.</p>
<p>Here&rsquo;s where I drew mine.</p>
<p>The short answer: I want to cause less suffering than I otherwise would, in a way I can actually keep up for decades.</p>
<p>It didn&rsquo;t start there, though. It started with how I felt. Eating meat left me sluggish, and I wanted to get fitter, so I tried going pescatarian the way an athlete tries anything: as an experiment. It stuck. Health got me in the door, and over eight years the reasons have grown well past my own body.</p>
<p>My ethics don&rsquo;t come down to one master rule. I weigh several things at once: not causing harm, taking care of my own health, being fair, keeping the commitments I make. Reducing suffering gets the most weight. And food is where that gets concrete, every single day. Cutting out meat and poultry removes the biggest share of the suffering my diet would otherwise cause, especially from industrial farming of land animals, which has well-documented welfare problems.</p>
<h2 id="i-tried-going-further">I tried going further</h2>
<p>I&rsquo;ve done stretches as a full <strong>vegetarian</strong> and as a <strong>vegan</strong>, each for less than a year. Both times, I came back to pescatarian.</p>
<p>What brought me back was several things at once. I trained better with fish in my diet. Covering the nutrients that are hard to get without animal foods, like B12 and omega-3s, took constant planning. And eating out, family meals, and travel turned into daily negotiations. Together, they made it a diet I was fighting instead of living with.</p>
<p>The way I see it, a diet I can keep for decades does more good than a perfect one I quit after six months. Pescatarian is where the weighing lands for me. It&rsquo;s a considered compromise, not a claim to purity.</p>
<p>It&rsquo;s also a practical one. Seafood provides vitamin B12, which plants don&rsquo;t provide on their own, so vegans generally need supplements or fortified foods. And fatty fish is one of the best sources of the omega-3 fats EPA and DHA.</p>
<p>Which brings me back to that philosopher.</p>
<h2 id="what-an-ancient-book-about-fish-taught-me">What an ancient book about fish taught me</h2>
<figure class="align-center ">
    <img loading="lazy" src="zhuangzi-watson.jpg#center"
         alt="A cream hardcover of The Complete Works of Zhuangzi, translated by Burton Watson, with a plum-blossom branch on the cover" width="360"/> <figcaption>
            <p>My copy: Burton Watson&rsquo;s translation.</p>
        </figcaption>
</figure>

<p>His name was Zhuangzi, and the book of stories and arguments that carries his name, the <em>Zhuangzi</em>, is one of the two great classics of Daoist philosophy. It&rsquo;s the philosophy that shapes how I think about all of this. Daoist philosophy doesn&rsquo;t tell you what to eat. It&rsquo;s about moving with the natural grain of things instead of forcing them.</p>
<p>And conveniently for this post, it&rsquo;s full of fish.</p>
<h3 id="it-opens-with-a-fish-that-becomes-a-bird">It opens with a fish that becomes a bird</h3>
<p>The very first story in the <em>Zhuangzi</em> is about an enormous fish in the northern sea, named Kun, that transforms into an enormous bird, named Peng, and flies south.</p>
<p>Remember the barnacle goose, the bird medieval Europe thought came from the sea? Zhuangzi got there first, and on purpose. One of his big ideas is that the categories we sort the world into are ours, not the world&rsquo;s. Fish, bird, meat, not-meat: useful labels, not laws of nature. Every beaver ruling above proves his point.</p>
<h3 id="the-happiness-of-fish">The happiness of fish</h3>
<p>In another story, Zhuangzi and his friend Huizi are walking across a bridge over the Hao River. Zhuangzi looks down at the minnows darting around and says they look happy.</p>
<p>Huizi objects: you&rsquo;re not a fish, so how could you know what fish feel? Zhuangzi turns it around: you&rsquo;re not me, so how do you know what I know? They go back and forth, and the argument never really resolves.</p>
<p>That&rsquo;s exactly where I am on the hardest question people ask me: &ldquo;Fish are animals too, so why eat them?&rdquo; Scientists and philosophers still debate how fish experience pain, and I can&rsquo;t settle it from a bridge. I don&rsquo;t claim eating fish is harmless. I claim it&rsquo;s a smaller harm than the alternative I was choosing between, and that the uncertainty is a reason to eat fish with care: responsibly sourced, not wasted, not taken for granted.</p>
<h3 id="cook-ding-and-the-grain-of-things">Cook Ding and the grain of things</h3>
<p>Here&rsquo;s an irony I love: one of the most famous stories in the <em>Zhuangzi</em> is about a butcher. Cook Ding carves an ox so skillfully, following the natural gaps between the joints, that his knife stays sharp for nineteen years. Everyone else hacks through bone and needs a new blade every month.</p>
<p>The point isn&rsquo;t about meat. It&rsquo;s about attention. Cook Ding doesn&rsquo;t force anything; he pays close enough attention to find the way through. That&rsquo;s what I&rsquo;m aiming for at the table: not a list of forbidden foods, but noticing where my food comes from and what it cost.</p>
<h3 id="dont-poke-the-small-fish">Don&rsquo;t poke the small fish</h3>
<p>The <em>Daodejing</em>, the other great Daoist classic, has a famous line: ruling a big country is like cooking a small fish. Keep poking at it, and it falls apart.</p>
<p>That&rsquo;s how I think about nutrition. I track what I eat, but tracking is for noticing real gaps, not for turning every meal into an exam. Poke too much and the whole thing falls apart.</p>
<h3 id="wu-wei-the-diet-you-dont-have-to-force">Wu wei: the diet you don&rsquo;t have to force</h3>
<p>Daoism&rsquo;s central idea is <strong>wu wei</strong>, usually translated as &ldquo;effortless action&rdquo; or &ldquo;non-forcing.&rdquo; It doesn&rsquo;t mean doing nothing. It means acting in a way that fits, so you&rsquo;re not white-knuckling your way through life.</p>
<p>Diets that last are the ones that stop feeling like willpower. Eight years in, this one doesn&rsquo;t feel like a rule I&rsquo;m obeying. It&rsquo;s just how I eat.</p>
<h2 id="living-on-the-line-the-practical-stuff">Living on the line: the practical stuff</h2>
<p>These are the questions friends actually ask.</p>
<p><strong>Do you get enough protein?</strong> Easily. Fish, eggs, dairy, beans, lentils, and tofu cover it.</p>
<p><strong>What about everything else?</strong> I track my nutrition in <strong>Cronometer</strong>, which breaks food down into individual vitamins and minerals, so I know instead of guessing. When you cut out meat, the ones worth watching are iron, zinc, B12, and omega-3s. Seafood happens to be great for all four: oysters and clams for iron and zinc, fatty fish for B12 and omega-3s. Fatty fish and seafood are also some of the few good food sources of vitamin D and iodine. (I&rsquo;m not a doctor or dietitian. If you&rsquo;re changing how you eat in a big way, talk to one.)</p>
<p><strong>Is it actually healthier?</strong> Maybe. Seventh-day Adventists, many of whom are vegetarian for religious reasons, are some of the most studied eaters on the planet. The Adventist Health Study-2 followed tens of thousands of members, and it&rsquo;s where pescatarians came out ahead of vegans and vegetarians. Here&rsquo;s how each diet did against meat eaters in two of its analyses, one on <a href="https://doi.org/10.1001/jamainternmed.2013.6473">death from any cause</a> and one on <a href="https://doi.org/10.1001/jamainternmed.2015.59">colorectal cancer</a>:</p>
<table>
	<thead>
			<tr>
					<th>Diet</th>
					<th>Death rate</th>
					<th>Colorectal cancer</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Pesco-vegetarian</td>
					<td>19% lower</td>
					<td>43% lower</td>
			</tr>
			<tr>
					<td>Vegan</td>
					<td>15% lower</td>
					<td>16% lower</td>
			</tr>
			<tr>
					<td>Lacto-ovo vegetarian</td>
					<td>9% lower</td>
					<td>18% lower</td>
			</tr>
	</tbody>
</table>
<p>The death-rate analysis followed 73,308 people for about six years, and the cancer one followed 77,659 people for about seven. The gaps between the three diets fall within the study&rsquo;s margin of error, so read it as fish eaters edging ahead, not a knockout.</p>
<p>Two big British studies, with no religious angle at all, also found fish eaters doing better than meat eaters. <a href="https://doi.org/10.1136/bmj.l4897">EPIC-Oxford</a> followed about 48,000 people for 18 years, and fish eaters had a 13% lower rate of heart disease than meat eaters. <a href="https://doi.org/10.1186/s12916-022-02256-w">UK Biobank</a> followed almost half a million people for 11 years, and fish eaters had a 10% lower risk of cancer overall than regular meat eaters. In those two, vegetarians did better on some measures and worse on others (EPIC-Oxford found a higher stroke rate for them), so no single study settles which diet wins.</p>
<p>The catch: these are all observational studies, so they can&rsquo;t prove the diet caused those results. Adventists as a group also don&rsquo;t smoke and rarely drink, and even after researchers adjust for things like that, people who choose a diet like this can differ in ways a study can&rsquo;t fully measure. It&rsquo;s not proof, but it&rsquo;s not nothing.</p>
<p><strong>What about mercury?</strong> This is the real homework. Mercury builds up in big, long-lived predator fish, so I pay attention to <em>which</em> fish, not just how much. The FDA&rsquo;s list of fish to avoid, written for pregnant people and young kids, includes king mackerel, marlin, orange roughy, shark, swordfish, bigeye tuna, and tilefish from the Gulf of Mexico. For fish that are low in mercury and high in omega-3s, remember <strong>SMASH</strong>: salmon, mackerel (Atlantic, not king), anchovies, sardines, and herring.</p>
<p><strong>Isn&rsquo;t sushi full of tuna?</strong> Yes, and the big sushi tunas, like bigeye and bluefin, are some of the higher-mercury fish. I love tuna nigiri, so I just don&rsquo;t make it the whole meal. Salmon, yellowtail, and shrimp are right there.</p>
<p><strong>Is eating out hard?</strong> Not really. My three favorite cuisines are Mexican, sushi, and Indian, and they happen to be three of the easiest. Fish tacos and bean burritos, sushi, and Indian food, which has one of the deepest vegetarian traditions in the world. The one trap in Mexican food: refried beans are sometimes made with lard, and rice is sometimes cooked in chicken broth. It&rsquo;s worth asking.</p>
<p><strong>Where does meat hide?</strong> Gelatin in gummy candy and marshmallows (it usually comes from pigs or cows). Chicken broth in soups, rice, and risotto. Lard in tortillas and pie crusts. And one that goes the other way: Worcestershire sauce and Caesar dressing usually contain anchovies. Vegetarians have to skip them. Pescatarians don&rsquo;t.</p>
<h2 id="the-honest-criticisms">The honest criticisms</h2>
<p><strong>&ldquo;Fish are animals too.&rdquo;</strong> Yes. See the happy fish above: eating fish isn&rsquo;t harmless. It&rsquo;s a different place to draw the line, and after everything in this post, I&rsquo;m clearly not the only one who drew it there.</p>
<p><strong>&ldquo;Is fish better for the environment than chicken?&rdquo;</strong> Honestly, not automatically. Beef has a far bigger climate footprint than almost anything else, so cutting red meat is the big win. But farmed fish and chicken often land in a similar range, and beans, lentils, and tofu are generally the lowest of all.</p>
<p><strong>&ldquo;What about overfishing?&rdquo;</strong> It&rsquo;s real. The Monterey Bay Aquarium&rsquo;s <strong>Seafood Watch</strong> rates seafood by how sustainably it&rsquo;s caught or farmed, and the MSC (wild) and ASC (farmed) labels mark certified sources. Farmed mussels and oysters are some of the most sustainable seafood there is. They&rsquo;re filter feeders, so they don&rsquo;t even need to be fed.</p>
<h2 id="if-youre-thinking-about-trying-it">If you&rsquo;re thinking about trying it</h2>
<ul>
<li><strong>Start with a swap, not a rule.</strong> Replace one meat meal a week with fish or beans and see how it goes.</li>
<li><strong>Learn five meals you love.</strong> Diets fail when every meal feels like a sacrifice.</li>
<li><strong>Track for a few weeks.</strong> It shows you real gaps instead of vague worries. Then stop poking the fish.</li>
<li><strong>Rotate your fish.</strong> Lean on the SMASH fish, and go easy on the big predators.</li>
<li><strong>Don&rsquo;t be preachy.</strong> Zhuangzi has a line about fish stranded on dry land when the springs dry up, keeping each other alive by spitting on each other, and how much better it would be if they could just forget each other in the rivers and lakes. Nobody wants to be spat on at dinner. When people ask, answer. When they don&rsquo;t, eat your fish tacos in peace.</li>
</ul>
<h2 id="closing-thought">Closing thought</h2>
<p>The <em>Zhuangzi</em> has one more fish story, and it&rsquo;s my favorite.</p>
<p>A fish trap exists to catch fish. Once you&rsquo;ve caught the fish, you can forget the trap. Words exist to catch meaning. Once you&rsquo;ve caught the meaning, you can forget the words.</p>
<p>&ldquo;Pescatarian&rdquo; is a fish trap. It&rsquo;s a handy word for ordering at a restaurant, or for a blog title. But every tradition in this post drew its line somewhere different: fins and scales, warm blood and cold, backbones, whatever the local bishop decided about beavers. The word matters less than what&rsquo;s underneath it: paying attention to what&rsquo;s on your plate, and what it cost.</p>
<p>I drew my line eight years ago, and it&rsquo;s held.</p>
<p>And for the record: even with an archbishop&rsquo;s blessing, I&rsquo;m not eating the alligator.</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>