[{"content":"Most people meet GitHub Actions as \u0026ldquo;the thing that runs tests on pull requests.\u0026rdquo; But under the hood it\u0026rsquo;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\u0026rsquo;s free.\nHere are things you can do with it that have nothing to do with CI.\nHow it works in 30 seconds A workflow is a YAML file in .github/workflows/. It has:\ntriggers (on:): a push, a new issue, a schedule, a button click jobs that run on a fresh virtual machine steps, each either a shell command or a reusable action That\u0026rsquo;s it. Anything you can script, you can trigger.\n1. Host a website This blog is built by Actions. Every push to main builds the site with Hugo and publishes it to GitHub Pages. No server, no bill.\non: push: branches: [main] The official actions/upload-pages-artifact and actions/deploy-pages actions do the publishing.\n2. Run things on a schedule Actions supports cron:\non: schedule: - cron: \u0026#34;0 14 * * 1\u0026#34; # every Monday at 14:00 UTC workflow_dispatch: # plus a manual \u0026#34;Run\u0026#34; button Uses: nightly data scrapes, weekly reports, checking whether a website is up, rebuilding a site so \u0026ldquo;posted 3 days ago\u0026rdquo; stays accurate.\nKnow this:\nTimes are in UTC. Scheduled runs can start late when GitHub is busy, sometimes by many minutes. Don\u0026rsquo;t use it for anything that must happen at an exact second. In public repos, scheduled workflows are automatically disabled after 60 days without repository activity. GitHub emails you first. 3. Keep a file updated automatically A workflow can edit files and commit them back. For example, a profile README that always lists your latest blog posts:\npermissions: contents: write jobs: update: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - run: ./scripts/update-readme.sh - run: | git config user.name \u0026#34;github-actions[bot]\u0026#34; git config user.email \u0026#34;41898282+github-actions[bot]@users.noreply.github.com\u0026#34; git add README.md git diff --cached --quiet || git commit -m \u0026#34;Update latest posts\u0026#34; git push The git diff --cached --quiet || part only commits if something actually changed, so you don\u0026rsquo;t get empty commits.\n4. Manage issues and pull requests Auto-label PRs by which files they touch (actions/labeler). Welcome first-time contributors with a friendly comment. Close stale issues after a period of inactivity (actions/stale). Use gently; closing people\u0026rsquo;s issues automatically can feel rude. Enforce PR title formats for changelogs. Triggers like issues, issue_comment, and pull_request make this easy.\n5. Build a ChatOps bot A workflow can react to comments. Someone types /deploy staging on a PR, and a workflow checks their permissions and runs the deploy. Check the comment author\u0026rsquo;s permission level before doing anything, or anyone on the internet can trigger it.\n6. Watch other things on the internet A scheduled job can:\ncheck whether your site returns 200 and open an issue if not check whether a dependency released a new version watch a page for changes and notify you GitHub isn\u0026rsquo;t built for real-time monitoring, but for \u0026ldquo;check once an hour and tell me,\u0026rdquo; it\u0026rsquo;s plenty.\n7. Trigger workflows from outside GitHub With repository_dispatch, 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.\non: repository_dispatch: types: [rebuild-site] 8. Generate release artifacts 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.\non: push: tags: [\u0026#34;v*\u0026#34;] Things you should know Least privilege. Set permissions: explicitly in every workflow. Give each workflow only what it needs.\nSecrets and forks. Workflows triggered by pull requests from forks don\u0026rsquo;t get your secrets, on purpose. Be very careful with the pull_request_target trigger: it runs with your secrets and write permissions, and if it checks out and runs the fork\u0026rsquo;s code, a stranger\u0026rsquo;s PR can steal your secrets.\nPin actions. A third-party action is someone else\u0026rsquo;s code running with your permissions. Pin it to a full commit hash rather than a tag if you don\u0026rsquo;t fully trust the author:\n- uses: some-author/some-action@8f4b7f84864484a7bf31766abe9204da3cbe65b3 Free isn\u0026rsquo;t unlimited. 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.\nClosing thought 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.\n","permalink":"https://londo.me/blog/posts/github-actions-beyond-ci/","summary":"\u003cp\u003eMost people meet GitHub Actions as \u0026ldquo;the thing that runs tests on pull requests.\u0026rdquo; But under the hood it\u0026rsquo;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\u0026rsquo;s free.\u003c/p\u003e\n\u003cp\u003eHere are things you can do with it that have nothing to do with CI.\u003c/p\u003e\n\u003ch2 id=\"how-it-works-in-30-seconds\"\u003eHow it works in 30 seconds\u003c/h2\u003e\n\u003cp\u003eA workflow is a YAML file in \u003ccode\u003e.github/workflows/\u003c/code\u003e. It has:\u003c/p\u003e","title":"Things GitHub Actions Can Do That Aren't CI"},{"content":"Every Git repo has a hidden .git folder. Most people never open it. That\u0026rsquo;s where all of Git lives: every version of every file, every branch, every commit message. And it\u0026rsquo;s surprisingly simple once you look.\nLet\u0026rsquo;s take it apart.\nSetup Make a throwaway repo so we can poke at it safely:\nmkdir git-internals \u0026amp;\u0026amp; cd git-internals git init -b main echo \u0026#34;hello\u0026#34; \u0026gt; hello.txt git add hello.txt git commit -m \u0026#34;first commit\u0026#34; Now look inside:\nls .git You\u0026rsquo;ll see things like HEAD, config, index, objects/, and refs/. We\u0026rsquo;ll go through the important ones.\nObjects: the actual data Git stores everything as objects in .git/objects. There are four kinds:\nblob: the contents of a file (just the bytes, no name) tree: a directory listing (names, permissions, and pointers to blobs and other trees) commit: a snapshot (a pointer to one tree, parent commits, author, message) tag: an annotated tag (a pointer to an object, plus a message) Every object is named by the hash of its contents. That\u0026rsquo;s why Git is called a content-addressable store: the address is the content\u0026rsquo;s fingerprint.\nFinding our file Ask Git what hash \u0026ldquo;hello\\n\u0026rdquo; would get as a blob:\necho \u0026#34;hello\u0026#34; | git hash-object --stdin ce013625030ba8dba906f756967f9e9ca394464a You\u0026rsquo;ll get this exact hash on any machine, in any repo. Same content, same hash, always.\nNow find it on disk. Git splits the hash: the first two characters are a folder, the rest is the file name:\nls .git/objects/ce/ There it is. The file is zlib-compressed, so cat shows garbage. Git can read it for us:\ngit cat-file -t ce0136 # type git cat-file -p ce0136 # contents blob hello Or decompress it yourself with Python to see the raw format:\npython3 -c \u0026#34;import zlib,sys; print(zlib.decompress(open(sys.argv[1],\u0026#39;rb\u0026#39;).read()))\u0026#34; .git/objects/ce/013625030ba8dba906f756967f9e9ca394464a b\u0026#39;blob 6\\x00hello\\n\u0026#39; That\u0026rsquo;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.\nWalking from a commit down to a file Start at the latest commit:\ngit cat-file -p HEAD tree aaa96ced2d9a1c8e72c56b253a0e2fe78393feb7 author ... committer ... first commit Follow the tree:\ngit cat-file -p aaa96c 100644 blob ce013625030ba8dba906f756967f9e9ca394464a hello.txt That\u0026rsquo;s the whole chain: commit → tree → blob. A commit doesn\u0026rsquo;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.\n(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.)\nRefs: names for hashes Nobody wants to type 40-character hashes. Refs are files that hold them:\ncat .git/refs/heads/main That\u0026rsquo;s it. A branch is a text file containing a commit hash. Creating a branch is writing one small file. That\u0026rsquo;s why branches in Git are so cheap.\nAnd HEAD?\ncat .git/HEAD ref: refs/heads/main HEAD usually points to a branch, which points to a commit. When you\u0026rsquo;re in \u0026ldquo;detached HEAD\u0026rdquo; state, HEAD contains a commit hash directly instead.\nIf a branch seems to be missing from refs/heads, check .git/packed-refs. Git bundles refs into that one file to save space.\nPackfiles: how Git stays small Storing every version of every file as a separate compressed object would get big. So Git periodically packs objects into .git/objects/pack/, storing similar objects as deltas (differences) against each other.\ngit gc git count-objects -v Loose objects disappear into a .pack file with a matching .idx index. To peek inside:\ngit verify-pack -v .git/objects/pack/pack-*.idx | head Interesting detail: this is the only place Git uses deltas. Your history is modeled as snapshots; deltas are just a storage optimization underneath.\nThe index: the staging area is a file git add writes to .git/index, a binary file listing what the next commit will contain. You can see it in readable form:\ngit ls-files --stage 100644 ce013625030ba8dba906f756967f9e9ca394464a 0\thello.txt The staging area isn\u0026rsquo;t a concept. It\u0026rsquo;s a file.\nThe reflog: your safety net Every time HEAD or a branch moves, Git writes a line to .git/logs/:\ngit reflog 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:\ngit branch rescue \u0026lt;hash\u0026gt; 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.\nBuild a commit with no porcelain For the grand finale, make a commit using only low-level commands:\necho \u0026#34;built by hand\u0026#34; | git hash-object -w --stdin # note the hash, then: git update-index --add --cacheinfo 100644 \u0026lt;blob-hash\u0026gt; handmade.txt git write-tree # note the tree hash, then: git commit-tree \u0026lt;tree-hash\u0026gt; -p HEAD -m \u0026#34;a commit made by hand\u0026#34; # note the commit hash, then: git update-ref refs/heads/main \u0026lt;commit-hash\u0026gt; Run git log. Your hand-built commit is there, indistinguishable from any other. (git status will call handmade.txt deleted, because so far the file only exists in the repo. git restore handmade.txt writes it out.) Every friendly Git command is built from pieces like these.\nThings you should know Don\u0026rsquo;t edit .git by hand in real repos. Explore in a throwaway one. SHA-256 repos exist. Git supports git init --object-format=sha256, which gives 64-character hashes. The structure is the same. Refs won\u0026rsquo;t always be files. Git 3.0 will switch new repos to the reftable format, and you can try it today with git init --ref-format=reftable. There, branches live in binary tables under .git/reftable/, and .git/HEAD just says ref: refs/heads/.invalid. Commands like git branch and git rev-parse work the same either way. Hashes include everything. 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\u0026rsquo;s why rewriting history changes all later commit IDs. Closing thought Once you\u0026rsquo;ve seen that a branch is a text file and a commit is a pointer to a snapshot, most of Git\u0026rsquo;s \u0026ldquo;weird\u0026rdquo; behavior stops being weird. It\u0026rsquo;s a database. Now you can read it.\n","permalink":"https://londo.me/blog/posts/git-internals/","summary":"\u003cp\u003eEvery Git repo has a hidden \u003ccode\u003e.git\u003c/code\u003e folder. Most people never open it. That\u0026rsquo;s where all of Git lives: every version of every file, every branch, every commit message. And it\u0026rsquo;s surprisingly simple once you look.\u003c/p\u003e\n\u003cp\u003eLet\u0026rsquo;s take it apart.\u003c/p\u003e\n\u003ch2 id=\"setup\"\u003eSetup\u003c/h2\u003e\n\u003cp\u003eMake a throwaway repo so we can poke at it safely:\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-sh\" data-lang=\"sh\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003emkdir git-internals \u003cspan class=\"o\"\u003e\u0026amp;\u0026amp;\u003c/span\u003e \u003cspan class=\"nb\"\u003ecd\u003c/span\u003e git-internals\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003egit init -b main\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"nb\"\u003eecho\u003c/span\u003e \u003cspan class=\"s2\"\u003e\u0026#34;hello\u0026#34;\u003c/span\u003e \u0026gt; hello.txt\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003egit add hello.txt\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003egit commit -m \u003cspan class=\"s2\"\u003e\u0026#34;first commit\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eNow look inside:\u003c/p\u003e","title":"Your .git Folder Is a Database. Let's Read It."},{"content":"It worked last month. It doesn\u0026rsquo;t work now. There are 400 commits in between.\nYou could read them all. Or you could let Git find the guilty one in about nine steps.\nThe idea: binary search through history git bisect takes two points: a commit where things were good and one where they\u0026rsquo;re bad. It checks out the commit halfway between and asks you which it is. Then it throws away the half that can\u0026rsquo;t contain the bug and repeats.\nEach step halves the search. 400 commits takes at most 9 steps. 10,000 commits takes at most 14.\nManual bisect git bisect start git bisect bad # the current commit is broken git bisect good v2.3.0 # this tag was fine Git checks out a commit in the middle and tells you roughly how many steps are left. Test it, then tell Git:\ngit bisect good # this one works # or git bisect bad # this one is broken Repeat until Git prints:\na1b2c3d is the first bad commit When you\u0026rsquo;re done, go back to where you started:\ngit bisect reset You can also give both endpoints up front:\ngit bisect start HEAD v2.3.0 The real magic: git bisect run If you can write a script that tells good from bad, Git will do the whole search without you:\ngit bisect start HEAD v2.3.0 git bisect run ./check.sh Git checks out each candidate commit, runs your script, and reads its exit code:\nExit code Meaning 0 good 1 to 127 (not 125) bad 125 skip: this commit can\u0026rsquo;t be tested 128 or higher abort the whole bisect Your test suite is often the script already:\ngit bisect run pytest tests/test_login.py git bisect run cargo test parse_header git bisect run npm test Writing a good check script The script should test only the bug you\u0026rsquo;re hunting. A full test suite may fail for unrelated reasons partway through history.\n#!/bin/sh # check.sh: exit 0 if the bug is absent, 1 if present, 125 if we can\u0026#39;t tell # If it doesn\u0026#39;t build, we can\u0026#39;t test this commit. Skip it. make \u0026gt;/dev/null 2\u0026gt;\u0026amp;1 || exit 125 # The actual check ./app --version | grep -q \u0026#34;expected output\u0026#34; \u0026amp;\u0026amp; exit 0 || exit 1 A few tips:\nUse 125 for \u0026ldquo;can\u0026rsquo;t test.\u0026rdquo; Commits that don\u0026rsquo;t compile, or that predate the feature entirely, should be skipped, not marked bad. Otherwise Git blames the wrong commit. Keep the script outside the repo, or untracked. Bisect checks out old commits, and an old commit might not have your script, or might have an older version of it. Make it fast. It runs once per step. Make it deterministic. A flaky test sends the search in the wrong direction and gives a confident, wrong answer. Useful extras Skip a commit by hand:\ngit bisect skip See or replay the session. If you mark a commit wrong, save the log, edit out the mistake, and replay it:\ngit bisect log \u0026gt; bisect.log # edit bisect.log git bisect reset git bisect replay bisect.log Visualize what\u0026rsquo;s left:\ngit bisect visualize --oneline Hunt for things that aren\u0026rsquo;t bugs. \u0026ldquo;Good\u0026rdquo; and \u0026ldquo;bad\u0026rdquo; are just labels. You can rename them:\ngit bisect start --term-old=fast --term-new=slow git bisect fast v2.3.0 git bisect slow HEAD Now you can find the commit where something got slow, or the commit where a feature first appeared.\nIgnore merged-in side branches. On a repo with many merges, --first-parent only follows the main line of history:\ngit bisect start --first-parent Things you should know Bisect needs a clean working tree. Commit or stash your changes first. It finds the first bad commit, not the cause. Sometimes the \u0026ldquo;first bad commit\u0026rdquo; just exposed a bug that was already there. Read the diff before blaming anyone. Small commits make bisect better. If the guilty commit changed 3,000 lines, knowing which commit it was doesn\u0026rsquo;t help much. Another reason to commit in small, focused steps. Closing thought The next time you catch yourself scrolling through git log trying to guess when something broke, stop. Write a ten-line script, run git bisect run, and go get a coffee.\n","permalink":"https://londo.me/blog/posts/git-bisect-run/","summary":"\u003cp\u003eIt worked last month. It doesn\u0026rsquo;t work now. There are 400 commits in between.\u003c/p\u003e\n\u003cp\u003eYou could read them all. Or you could let Git find the guilty one in about nine steps.\u003c/p\u003e\n\u003ch2 id=\"the-idea-binary-search-through-history\"\u003eThe idea: binary search through history\u003c/h2\u003e\n\u003cp\u003e\u003ccode\u003egit bisect\u003c/code\u003e takes two points: a commit where things were \u003cstrong\u003egood\u003c/strong\u003e and one where they\u0026rsquo;re \u003cstrong\u003ebad\u003c/strong\u003e. It checks out the commit halfway between and asks you which it is. Then it throws away the half that can\u0026rsquo;t contain the bug and repeats.\u003c/p\u003e","title":"git bisect run: Let Git Find the Bug for You"},{"content":"You\u0026rsquo;re halfway through a feature. Files are open, tests are half-written, nothing compiles. Then someone says: \u0026ldquo;Production is broken, can you look?\u0026rdquo;\nMost people do one of these:\ngit stash, switch branches, fix, switch back, git stash pop, pray. Commit a \u0026ldquo;WIP\u0026rdquo; mess they\u0026rsquo;ll have to clean up later. Clone the whole repo again into another folder. There\u0026rsquo;s a better option that has been in Git since 2015: worktrees.\nWhat a worktree is A normal repo has one working directory: the folder where your files live. A worktree is an extra 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.\nIt\u0026rsquo;s like a second clone, except:\nIt\u0026rsquo;s instant. Nothing is downloaded or copied. It uses almost no extra disk space for history. A commit made in one worktree is immediately visible from the others. The basics Create a worktree for an existing branch:\ngit worktree add ../myproject-hotfix hotfix Now ../myproject-hotfix is a full working folder with the hotfix branch checked out. Your original folder is untouched, half-finished feature and all.\nCreate a worktree and a new branch at the same time:\ngit worktree add -b fix-login ../myproject-fix-login main That makes a new branch fix-login starting from main, checked out in a new folder.\nSee all your worktrees:\ngit worktree list When you\u0026rsquo;re done, remove it:\ngit worktree remove ../myproject-hotfix If you deleted a worktree folder by hand instead, clean up Git\u0026rsquo;s records of it:\ngit worktree prune Daily uses Emergency fixes without disruption. The scenario above. Your feature work stays exactly where you left it.\nReviewing a pull request while you keep working. Check out the PR branch in its own worktree, run it, test it, and delete the folder when you\u0026rsquo;re done.\nComparing behavior side by side. Run the old version and the new version at the same time in two terminals.\nLong builds. Kick off a slow build or test suite in one worktree and keep coding in another. Your edits won\u0026rsquo;t change files out from under the running build.\nRunning AI coding agents in parallel. Give each agent its own worktree so they don\u0026rsquo;t overwrite each other\u0026rsquo;s changes.\nThings you should know One branch, one worktree. Git won\u0026rsquo;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\u0026rsquo;ll get an error. (There\u0026rsquo;s a --force flag, but you almost never want it.)\nSome things are shared, some aren\u0026rsquo;t. Commits, branches, tags, remotes, config, and the stash are shared across all worktrees. The working files, the index (staging area), and HEAD are per-worktree.\nUntracked files don\u0026rsquo;t come along. A new worktree starts clean. Your .env, node_modules, or build folders from the main worktree won\u0026rsquo;t be there. You\u0026rsquo;ll need to reinstall dependencies or copy config.\nKeep worktrees outside the main folder. Putting a worktree inside your main working directory makes tools, file watchers, and searches see duplicate files. A sibling folder (../project-something) is the usual convention.\nLock worktrees on removable drives. If a worktree lives on a USB drive or network share that isn\u0026rsquo;t always mounted, lock it so prune doesn\u0026rsquo;t delete its records:\ngit worktree lock ../usb-worktree --reason \u0026#34;on external drive\u0026#34; A bare-repo trick Some people go further: they clone a repo as bare (no working files at all) and do everything through worktrees:\ngit clone --bare git@github.com:you/project.git project/.bare cd project echo \u0026#34;gitdir: ./.bare\u0026#34; \u0026gt; .git git config remote.origin.fetch \u0026#34;+refs/heads/*:refs/remotes/origin/*\u0026#34; git fetch git worktree add main git worktree add feature-x The git config line matters. A bare clone doesn\u0026rsquo;t set up remote-tracking branches, so without it, git fetch quietly never updates them.\nNow every branch you work on is a tidy subfolder of project/. It takes a little getting used to, but it makes \u0026ldquo;which branch am I on?\u0026rdquo; a question your file manager answers.\nClosing thought git stash is fine for thirty seconds. For anything longer, stop juggling and give each branch its own folder.\n","permalink":"https://londo.me/blog/posts/git-worktree/","summary":"\u003cp\u003eYou\u0026rsquo;re halfway through a feature. Files are open, tests are half-written, nothing compiles. Then someone says: \u0026ldquo;Production is broken, can you look?\u0026rdquo;\u003c/p\u003e\n\u003cp\u003eMost people do one of these:\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003ccode\u003egit stash\u003c/code\u003e, switch branches, fix, switch back, \u003ccode\u003egit stash pop\u003c/code\u003e, pray.\u003c/li\u003e\n\u003cli\u003eCommit a \u0026ldquo;WIP\u0026rdquo; mess they\u0026rsquo;ll have to clean up later.\u003c/li\u003e\n\u003cli\u003eClone the whole repo again into another folder.\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003eThere\u0026rsquo;s a better option that has been in Git since 2015: \u003cstrong\u003eworktrees\u003c/strong\u003e.\u003c/p\u003e\n\u003ch2 id=\"what-a-worktree-is\"\u003eWhat a worktree is\u003c/h2\u003e\n\u003cp\u003eA normal repo has one working directory: the folder where your files live. A worktree is an \u003cem\u003eextra\u003c/em\u003e 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.\u003c/p\u003e","title":"git worktree: Stop Stashing, Start Branching in Parallel"},{"content":"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\u0026rsquo;t come back.\nHere\u0026rsquo;s the detail that stuck with me: when a volunteer like that heads out, nobody writes down that they went. There\u0026rsquo;s no dispatcher, no roster, no log. If they stop answering their phone, nothing notices. Eventually, someone realizes they haven\u0026rsquo;t heard from them in a while.\nOne 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: \u0026ldquo;can we account for everyone?\u0026rdquo;\nSo 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.\nIt\u0026rsquo;s called DiresQ. 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 3rd in the Software Development track at Reverie Hacks 2026, an online hackathon with more than 2,100 participants and 423 submitted projects.\nBut placing isn\u0026rsquo;t the interesting part. The interesting part is the three times our own code lied to us, and looked completely fine doing it.\nFive responders on scene. One goes quiet. At fifteen minutes the board turns red, and the report on the right files itself.\nEvery disaster app tracks the disaster Crowdsourced flood maps show where the water is. Social media shows who\u0026rsquo;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.\nA neighbor with a boat is none of those things. Every tool we looked at mapped the incident. We couldn\u0026rsquo;t find one that kept a list of the people walking into it.\nHere\u0026rsquo;s DiresQ in about sixty seconds:\nA neighbor\u0026rsquo;s street is flooding. She files a report: \u0026ldquo;Water rising, 2 trapped.\u0026rdquo; It lands at the top of the feed, reading 0 responding. 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: \u0026ldquo;30 min.\u0026rdquo; Your row on the accountability board turns blue. You arrive and mark yourself on scene. Your row turns green. It\u0026rsquo;s worse than she said, so you tag the report needs more help, and it climbs the feed. Then you go into a flooded house and stop checking in. Thirty minutes pass. Your row turns red, showing your last known position and how long since anyone heard from you. Fifteen minutes after that, the server stops waiting for someone to notice. It files a new report, about you, at your last known position. Nobody had to notice you went quiet. That\u0026rsquo;s the product. Everything else is how you get there.\nBut the first version we designed would have made things worse.\nOur first design was wrong We started with the obvious model: dispatch. One responder claims one report, it\u0026rsquo;s locked, and nobody else can take it. No two people driving to the same address.\nThen 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.\nThe real failure in a disaster isn\u0026rsquo;t two people going to the same address. It\u0026rsquo;s six hundred people going to the same address while the street two blocks over has no one. A claim lock fights the wrong problem, and sometimes a collapse genuinely needs forty people.\nSo we deleted it. Any number of people can join any report. Instead of preventing convergence, DiresQ makes it visible:\nEvery report shows how many people are already on it. People on scene can say \u0026ldquo;we need more\u0026rdquo; or \u0026ldquo;we\u0026rsquo;re overstaffed,\u0026rdquo; and the feed reorders so the next person goes where the help isn\u0026rsquo;t. When people on scene disagree, the most cautious answer wins. An optimistic report should never be able to drown out a call for help. A report nobody is going to sorts above one that\u0026rsquo;s merely short-handed. A gap is worse than a queue. That was the design. Building it was a different story.\nThe first night, on two clocks 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, Skythe, 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 Coding for Teens Discord server, if you\u0026rsquo;re looking for a teammate of your own.\nIt 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.\nThe first time the app actually worked was amazing. But it didn\u0026rsquo;t feel like a finish line. It felt like the start of a fire. It felt good, and I immediately wanted more.\nWe 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: new files are free, but editing someone else\u0026rsquo;s needs a message first. We changed code in parallel all night and never once had a merge conflict.\nWe 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\u0026rsquo;t have: .gitattributes. (I liked it enough to write a whole post about it.)\nWe also leaned on four small libraries I\u0026rsquo;d written and published to PyPI before the hackathon: timefuzz turns \u0026ldquo;back in a couple hours\u0026rdquo; into a real deadline, vitalscore runs the START triage protocol, pygeospy does the map math, and patchnotes checks our changelog on every push.\nBy 8am, the git log had 65 commits from that night, and the repo had 12,579 lines of code.\nThen our plan fell apart, and it was my fault.\nThe plan didn\u0026rsquo;t survive contact The plan said the backend would put up fake \u0026ldquo;stub\u0026rdquo; API endpoints in the first hour, so the frontend could build against them without waiting.\nThe stubs were my job. I never wrote them. I was building the interesting part instead.\nSkythe didn\u0026rsquo;t wait, which was the right call. The git log tells the story: Skythe\u0026rsquo;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\u0026rsquo;t need the API at all. That turned out to be the better design: the whole app works with JavaScript switched off. That\u0026rsquo;s exactly what you want from a tool for the worst day of someone\u0026rsquo;s life, running on an old phone with one bar of signal.\nWhat it cost us was agreement. With no stubs and no conversation, we each guessed at the other\u0026rsquo;s names, and three came out different:\nFrontend Backend HIGH / MEDIUM / LOW integers 1 to 4 latitude / longitude lat / lng needs_more need_more The frontend\u0026rsquo;s names won all three times, because Skythe\u0026rsquo;s templates were real, working software and my schema was still a document nobody had run.\nThe lesson I took: unblock other people before you build your favorite part. Fifteen minutes agreeing on names would have saved every one of those mismatches.\nAnd then the code started lying to us.\nLie #1: the classifier that scored 100% Someone filing a report at 2am from a flooded house gets asked to pick a severity from a dropdown. They don\u0026rsquo;t know. They\u0026rsquo;re scared, and they aren\u0026rsquo;t trained.\nSo 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.\nWe tested it the obvious way first: run it over its training data. It scored 100%.\nThat 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.\nAccuracy on unseen reports Always guess the most common label 36% Naive Bayes alone 45% Naive Bayes plus a severity word list 75% Nine points better than guessing, and wrong in the worst possible direction. \u0026ldquo;Child not breathing properly\u0026rdquo; came back MEDIUM. \u0026ldquo;Gas smell, whole street evacuating\u0026rdquo; came back LOW.\nThe 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\u0026rsquo;s still wrong one time in four, which is survivable only because it\u0026rsquo;s a suggestion in a dropdown you control, not a decision.\nOf 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.\nThen we found a subtler lie. A report typed in Spanish, \u0026ldquo;mi madre no puede respirar\u0026rdquo; (\u0026ldquo;my mother can\u0026rsquo;t breathe\u0026rdquo;), came back LOW, 51% confident. Naive Bayes can\u0026rsquo;t say \u0026ldquo;I don\u0026rsquo;t know.\u0026rdquo; 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\u0026rsquo;t a hypothetical. Now, when the model doesn\u0026rsquo;t recognize the wording, it gives no suggestion and says why.\nWhy not just use an AI model? Three reasons, in order:\nIt has to explain itself. The words DiresQ shows you are the actual math behind the decision, not a separately generated explanation that could disagree with it. It has to be honest about being wrong. A confident paragraph from a language model is much harder to doubt, and confidently wrong sends boats to the wrong street. It has to work with the cell towers down. No download, no API key, no network. So the same trained model runs on the phone, too. (We did use AI tools while building DiresQ, for code and documentation. There\u0026rsquo;s just none inside the product.)\nPutting the model on the phone led to the strangest bug of the whole project. We\u0026rsquo;ll get there. First, the alarm.\nLie #2: the alarm that could die quietly The dead man\u0026rsquo;s switch, the part that files a report when you go quiet, started life as a background timer inside the app.\nThen 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 \u0026ldquo;everyone\u0026rsquo;s fine\u0026rdquo; when it really means \u0026ldquo;nobody\u0026rsquo;s checking.\u0026rdquo;\nSo 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\u0026rsquo;s nothing to forget to start, and nothing that can silently die.\nThat left one honest dependency: if nobody has the board open, nothing gets checked. So the board shows when the check last ran, \u0026ldquo;checked 2s ago,\u0026rdquo; and turns amber if it ever stops. The thing that notices when people go quiet won\u0026rsquo;t go quiet without saying so. (There\u0026rsquo;s also a command you can run on a schedule, so the alarm doesn\u0026rsquo;t depend on someone having a tab open.)\nThat became the theme of the whole project: the failure that matters is the one that leaves no trace.\nLie #3: bugs that looked like working features Every bug below passed its tests, threw no errors, and looked fine. We only found them by using the code for real.\nError messages that went nowhere. 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. A required field that wasn\u0026rsquo;t. 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. A database that broke on the second run. 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. A phishing hole we built by accident. After you logged in, the app sent you to whatever address the link\u0026rsquo;s ?next= parameter said. So a link to our real login page, on our real domain, could hand you straight to someone else\u0026rsquo;s site. The tests only checked that logging in redirected you, which it did. A map that would run whatever people typed. Report titles went into the map\u0026rsquo;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. A security check that searched nothing. 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. 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.\nThe things we refused to build 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\u0026rsquo;t do, and put that list inside the app.\nNo identity verification. Anyone can sign up. Doing it properly needs real ID checks, and there\u0026rsquo;s no honest weekend version. A fake check, like email confirmation, would be worse than nothing, because it looks like verification without being it. No calling 911. 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. No radio, yet. 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\u0026rsquo;t simply be sent again. There\u0026rsquo;s even a gateway script to forward packets to the server. What doesn\u0026rsquo;t exist is the radio: we don\u0026rsquo;t have the hardware, and untested code isn\u0026rsquo;t a feature. It\u0026rsquo;s a file that looks like one. DiresQ has never been used in a real disaster, and it shouldn\u0026rsquo;t be until someone who does this professionally has taken it apart.\nIt wasn\u0026rsquo;t one night 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\u0026rsquo;s roughly what the git log says, in my time zone:\nWhen (Pacific time) Hours Commits What happened Sat Aug 1, 6pm to Sun 8am 14.0 65 Katy Youth Hacks: the whole app, from an empty repo Sun 3pm to Mon 6am 14.7 40 Works offline and installs on a phone; releases 1.0.0 to 1.0.2 Mon 6pm to Tue 2am 7.6 30 Map fixes, then reading the research and writing a paper Wednesday 2.5 12 A demo clock for filming, an accessibility pass, release 1.1.0 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\u0026rsquo;t show up. Even so, that\u0026rsquo;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.\nHere\u0026rsquo;s what those extra hours bought:\nIt works with no signal. 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\u0026rsquo;t file the same emergency twice. It passed an accessibility audit. 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. It turned into a paper. By the third evening, we realized the interesting claim wasn\u0026rsquo;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: \u0026ldquo;Read the 1957 original instead of citing it secondhand.\u0026rdquo; That reading became an eleven-page preprint, which says plainly that DiresQ has no users, no deployment, and no evaluation. It learned to be filmed. The dead man\u0026rsquo;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\u0026rsquo;s the exact same escalation code, and a banner on screen admits that time is sped up. Then we entered Reverie Hacks 2026.\n3rd place 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\u0026rsquo;d placed.\nI was freaking out internally. All I wanted to do was watch the hackathon\u0026rsquo;s showcase video on YouTube, check Discord, check Devpost, check everything. Instead, I had to sit through the rest of the lecture.\nThe second it ended, it all hit me, and every hour felt worth it. I even told my parents, even though they don\u0026rsquo;t understand any of the coding and tech stuff.\nReverie 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.\nWhat the judges said Two judges scored DiresQ out of 105 points. One gave it 96, the other 94.\nCategory Max Judge 1 Judge 2 Real-world problem and impact 25 24 25 Technical execution 25 24 25 Innovation and originality 15 14 12 User experience and design 15 13 12 Sustainability and scalability 10 8 6 Presentation and communication 10 10 10 Bonus: exceptionality +5 3 4 Total 105 96 94 The first judge summed up the project better than I could. The strongest part, they wrote, wasn\u0026rsquo;t the amount of functionality but the reasoning behind it: \u0026ldquo;the team repeatedly identifies dangerous failure modes, measures them, and designs around them.\u0026rdquo; That\u0026rsquo;s the whole project in one sentence.\nThe 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\u0026rsquo;s reason was how complete and disciplined the whole project was for our level.\nThey 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.\nThe criticism was fair, too:\nIt\u0026rsquo;s unproven. 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. The writeup was ahead of the interface. 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\u0026rsquo;s a fair hit. Scale isn\u0026rsquo;t solved. 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. Both criticisms point to the same next step, and it isn\u0026rsquo;t a feature. It\u0026rsquo;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.\nWhat I\u0026rsquo;d tell myself before the next hackathon Name your dependencies out loud. If someone is waiting on you, that\u0026rsquo;s the first thing you do, not a line in a planning doc. Agree on names and shapes in writing before you start. It takes fifteen minutes. Write the first test early. Ours found two real bugs within minutes of existing. Test on a database that already has data in it. Empty is the one case that always works. Treat 100% as a warning, not a result. Measure the clever version before you keep it. Write your limitations while you build. It\u0026rsquo;s the only time you actually remember what you decided not to handle. Don\u0026rsquo;t let the writeup get ahead of the interface. A judge will open the app and check. Try it Live demo: diresq.onrender.com. Sign in as londo with the password diresq. It\u0026rsquo;s on a free tier, so the first load can take about a minute to wake up. Demo video: YouTube Docs: skythe7.github.io/DiresQ Code: github.com/Skythe7/DiresQ Devpost: DiresQ on Devpost Built by Skythe (frontend) and me (backend).\nClosing thought Back to that dispatcher\u0026rsquo;s question: can we account for everyone?\nWith DiresQ, the answer is either yes, or a red row with a name, a last known position, and a report that\u0026rsquo;s already been filed.\nNobody has to notice you went quiet.\n","permalink":"https://londo.me/blog/posts/diresq/","summary":"\u003cp\u003eDuring 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\u0026rsquo;t come back.\u003c/p\u003e\n\u003cp\u003eHere\u0026rsquo;s the detail that stuck with me: when a volunteer like that heads out, \u003cstrong\u003enobody writes down that they went\u003c/strong\u003e. There\u0026rsquo;s no dispatcher, no roster, no log. If they stop answering their phone, nothing notices. Eventually, someone realizes they haven\u0026rsquo;t heard from them in a while.\u003c/p\u003e","title":"The App That Reports You Missing"},{"content":"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?\nThe archbishop\u0026rsquo;s answer: yes. As far as the Church was concerned, alligator counted as seafood.\nHe wasn\u0026rsquo;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.\nPeople have been arguing about where \u0026ldquo;meat\u0026rdquo; ends and \u0026ldquo;fish\u0026rdquo; begins for about two thousand years.\nI\u0026rsquo;ve been living on that line for eight.\nThe short version of me I\u0026rsquo;m a pescatarian. For more than eight years, I haven\u0026rsquo;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.\nSpoiler: 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\u0026rsquo;s.\nBut first: almost every major religion has drawn this line too, and their reasons are stranger than mine.\nChristianity was a fish religion from the start Jesus\u0026rsquo;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\u0026rsquo;t a ghost by eating a piece of broiled fish. Early Christians even adopted the fish as a symbol: the Greek word for fish, ichthys, doubles as an acronym for \u0026ldquo;Jesus Christ, Son of God, Savior.\u0026rdquo;\nSo 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.\nToday\u0026rsquo;s rule works the same way. Catholics 14 and older skip meat on Ash Wednesday and the Fridays of Lent, and \u0026ldquo;meat\u0026rdquo; means the flesh of land animals like cows, pigs, and chickens. Fish, shellfish, and cold-blooded animals don\u0026rsquo;t count.\nIn 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 loopholes.\nIs a beaver a fish? 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.\nIt didn\u0026rsquo;t stop there:\nMuskrat: 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. Capybara: in Venezuela, capybara is a traditional Lenten dish, backed by a legend that the Vatican once classified the world\u0026rsquo;s largest rodent as a fish. Alligator: see above. Louisiana, 2010. Barnacle geese: 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. The pattern is always the same: if it lives in water, somebody will argue it\u0026rsquo;s a fish.\nAnd in 1962, that same fasting rule accidentally created one of the most famous sandwiches in fast food.\nThe burger that lost to a fish sandwich Lou Groen ran a McDonald\u0026rsquo;s in a heavily Catholic part of Cincinnati. Every Friday, his sales collapsed, because his customers went to the fish place down the road.\nSo he invented a battered fish sandwich and pitched it to Ray Kroc, the head of McDonald\u0026rsquo;s. Kroc had his own meatless idea: the Hula Burger, a slice of grilled pineapple with cheese on a bun.\nThey made a bet. Both sandwiches would go on sale one Friday, and whichever sold more would stay on the menu. According to McDonald\u0026rsquo;s lore, the Hula Burger got crushed.\nThe Filet-O-Fish went national in 1965. McDonald\u0026rsquo;s has said Lent is still the sandwich\u0026rsquo;s busiest season. A Catholic fasting rule from the Middle Ages is the reason it exists.\nMeanwhile, the Orthodox Church read the same Bible and took the salmon off the menu too. But not the shrimp.\nShrimp: yes. Salmon: no. Eastern Orthodox Christians fast even harder, and their line lands somewhere completely different.\nDuring 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\u0026rsquo;re considered \u0026ldquo;bloodless\u0026rdquo; invertebrates. Fish itself comes back only on a couple of feast days, like Palm Sunday.\nSo a Catholic during Lent can eat salmon but not chicken. An Orthodox Christian during Lent can eat shrimp but not salmon.\nAnd in Jewish law, shrimp is off the menu entirely. The same rules explain why your bagel works.\nWhy your bagel works Kosher law draws its line with a physical test: fish must have fins and scales (Leviticus 11). That rules out shellfish, catfish, eels, and more.\nAnd kosher fish belongs to a special category: pareve, meaning neither meat nor dairy. Kosher law forbids mixing meat and dairy, but fish is fine with dairy. That\u0026rsquo;s why lox and cream cheese on a bagel is a Jewish deli classic, while a real cheeseburger could never be kosher.\n(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.)\nIslamic law has fish rules too, and one of them makes fish the easiest meal on the menu.\nThe easiest halal meal on the menu The Quran explicitly permits food from the sea. Unlike land animals, fish don\u0026rsquo;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.\nThe 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.\nFurther east, the story involves a god who took the form of a fish, and an emperor who shocked his country by eating beef.\nA fish god and a beef-eating emperor In Hindu tradition, the first avatar of the god Vishnu is Matsya, 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.\nBuddhism\u0026rsquo;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, shojin ryori, uses no meat or fish at all. In Theravada tradition, monks eat whatever they\u0026rsquo;re given as alms, including meat, as long as the animal wasn\u0026rsquo;t killed specifically for them.\nJapan 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 \u0026ldquo;whale\u0026rdquo; is still written with the fish radical.\nThen, 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.\nSo why am I a pescatarian? Here\u0026rsquo;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\u0026rsquo;t found in nature. People draw it.\nHere\u0026rsquo;s where I drew mine.\nThe short answer: I want to cause less suffering than I otherwise would, in a way I can actually keep up for decades.\nIt didn\u0026rsquo;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.\nMy ethics don\u0026rsquo;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.\nI tried going further I\u0026rsquo;ve done stretches as a full vegetarian and as a vegan, each for less than a year. Both times, I came back to pescatarian.\nWhat 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.\nThe 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\u0026rsquo;s a considered compromise, not a claim to purity.\nIt\u0026rsquo;s also a practical one. Seafood provides vitamin B12, which plants don\u0026rsquo;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.\nWhich brings me back to that philosopher.\nWhat an ancient book about fish taught me My copy: Burton Watson\u0026rsquo;s translation.\nHis name was Zhuangzi, and the book of stories and arguments that carries his name, the Zhuangzi, is one of the two great classics of Daoist philosophy. It\u0026rsquo;s the philosophy that shapes how I think about all of this. Daoist philosophy doesn\u0026rsquo;t tell you what to eat. It\u0026rsquo;s about moving with the natural grain of things instead of forcing them.\nAnd conveniently for this post, it\u0026rsquo;s full of fish.\nIt opens with a fish that becomes a bird The very first story in the Zhuangzi is about an enormous fish in the northern sea, named Kun, that transforms into an enormous bird, named Peng, and flies south.\nRemember 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\u0026rsquo;s. Fish, bird, meat, not-meat: useful labels, not laws of nature. Every beaver ruling above proves his point.\nThe happiness of fish 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.\nHuizi objects: you\u0026rsquo;re not a fish, so how could you know what fish feel? Zhuangzi turns it around: you\u0026rsquo;re not me, so how do you know what I know? They go back and forth, and the argument never really resolves.\nThat\u0026rsquo;s exactly where I am on the hardest question people ask me: \u0026ldquo;Fish are animals too, so why eat them?\u0026rdquo; Scientists and philosophers still debate how fish experience pain, and I can\u0026rsquo;t settle it from a bridge. I don\u0026rsquo;t claim eating fish is harmless. I claim it\u0026rsquo;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.\nCook Ding and the grain of things Here\u0026rsquo;s an irony I love: one of the most famous stories in the Zhuangzi 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.\nThe point isn\u0026rsquo;t about meat. It\u0026rsquo;s about attention. Cook Ding doesn\u0026rsquo;t force anything; he pays close enough attention to find the way through. That\u0026rsquo;s what I\u0026rsquo;m aiming for at the table: not a list of forbidden foods, but noticing where my food comes from and what it cost.\nDon\u0026rsquo;t poke the small fish The Daodejing, 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.\nThat\u0026rsquo;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.\nWu wei: the diet you don\u0026rsquo;t have to force Daoism\u0026rsquo;s central idea is wu wei, usually translated as \u0026ldquo;effortless action\u0026rdquo; or \u0026ldquo;non-forcing.\u0026rdquo; It doesn\u0026rsquo;t mean doing nothing. It means acting in a way that fits, so you\u0026rsquo;re not white-knuckling your way through life.\nDiets that last are the ones that stop feeling like willpower. Eight years in, this one doesn\u0026rsquo;t feel like a rule I\u0026rsquo;m obeying. It\u0026rsquo;s just how I eat.\nLiving on the line: the practical stuff These are the questions friends actually ask.\nDo you get enough protein? Easily. Fish, eggs, dairy, beans, lentils, and tofu cover it.\nWhat about everything else? I track my nutrition in Cronometer, 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\u0026rsquo;m not a doctor or dietitian. If you\u0026rsquo;re changing how you eat in a big way, talk to one.)\nIs it actually healthier? 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\u0026rsquo;s where pescatarians came out ahead of vegans and vegetarians. Here\u0026rsquo;s how each diet did against meat eaters in two of its analyses, one on death from any cause and one on colorectal cancer:\nDiet Death rate Colorectal cancer Pesco-vegetarian 19% lower 43% lower Vegan 15% lower 16% lower Lacto-ovo vegetarian 9% lower 18% lower 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\u0026rsquo;s margin of error, so read it as fish eaters edging ahead, not a knockout.\nTwo big British studies, with no religious angle at all, also found fish eaters doing better than meat eaters. EPIC-Oxford followed about 48,000 people for 18 years, and fish eaters had a 13% lower rate of heart disease than meat eaters. UK Biobank 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.\nThe catch: these are all observational studies, so they can\u0026rsquo;t prove the diet caused those results. Adventists as a group also don\u0026rsquo;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\u0026rsquo;t fully measure. It\u0026rsquo;s not proof, but it\u0026rsquo;s not nothing.\nWhat about mercury? This is the real homework. Mercury builds up in big, long-lived predator fish, so I pay attention to which fish, not just how much. The FDA\u0026rsquo;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 SMASH: salmon, mackerel (Atlantic, not king), anchovies, sardines, and herring.\nIsn\u0026rsquo;t sushi full of tuna? 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\u0026rsquo;t make it the whole meal. Salmon, yellowtail, and shrimp are right there.\nIs eating out hard? 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\u0026rsquo;s worth asking.\nWhere does meat hide? 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\u0026rsquo;t.\nThe honest criticisms \u0026ldquo;Fish are animals too.\u0026rdquo; Yes. See the happy fish above: eating fish isn\u0026rsquo;t harmless. It\u0026rsquo;s a different place to draw the line, and after everything in this post, I\u0026rsquo;m clearly not the only one who drew it there.\n\u0026ldquo;Is fish better for the environment than chicken?\u0026rdquo; 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.\n\u0026ldquo;What about overfishing?\u0026rdquo; It\u0026rsquo;s real. The Monterey Bay Aquarium\u0026rsquo;s Seafood Watch rates seafood by how sustainably it\u0026rsquo;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\u0026rsquo;re filter feeders, so they don\u0026rsquo;t even need to be fed.\nIf you\u0026rsquo;re thinking about trying it Start with a swap, not a rule. Replace one meat meal a week with fish or beans and see how it goes. Learn five meals you love. Diets fail when every meal feels like a sacrifice. Track for a few weeks. It shows you real gaps instead of vague worries. Then stop poking the fish. Rotate your fish. Lean on the SMASH fish, and go easy on the big predators. Don\u0026rsquo;t be preachy. 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\u0026rsquo;t, eat your fish tacos in peace. Closing thought The Zhuangzi has one more fish story, and it\u0026rsquo;s my favorite.\nA fish trap exists to catch fish. Once you\u0026rsquo;ve caught the fish, you can forget the trap. Words exist to catch meaning. Once you\u0026rsquo;ve caught the meaning, you can forget the words.\n\u0026ldquo;Pescatarian\u0026rdquo; is a fish trap. It\u0026rsquo;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\u0026rsquo;s underneath it: paying attention to what\u0026rsquo;s on your plate, and what it cost.\nI drew my line eight years ago, and it\u0026rsquo;s held.\nAnd for the record: even with an archbishop\u0026rsquo;s blessing, I\u0026rsquo;m not eating the alligator.\n","permalink":"https://londo.me/blog/posts/pescatarian/","summary":"\u003cp\u003eIn 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?\u003c/p\u003e\n\u003cp\u003eThe archbishop\u0026rsquo;s answer: yes. As far as the Church was concerned, alligator counted as seafood.\u003c/p\u003e\n\u003cp\u003eHe wasn\u0026rsquo;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.\u003c/p\u003e","title":"When Alligator Counts as Fish \u0026 Why I'm a Pescatarian"},{"content":"Every repo has a .gitignore. Almost nobody has a .gitattributes. That\u0026rsquo;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\u0026rsquo;ll want one in every project.\nWhat it is .gitattributes is a plain text file that assigns attributes to paths. Each line is a pattern followed by one or more attributes:\n*.sh text eol=lf *.png binary docs/** linguist-documentation The patterns work much like .gitignore, with two catches. Negation (!pattern) isn\u0026rsquo;t allowed. And a directory pattern like vendor/ does not apply to the files inside it, so you need vendor/**.\nEach attribute can be in one of four states:\nSet: text Unset: -text Set to a value: eol=lf Unspecified: !text, which resets it to as if you never mentioned it Where it lives Git reads attributes from several places, from highest priority to lowest:\n.git/info/attributes: local to your clone and never committed. Good for personal rules. .gitattributes files in the repo. A file in a subdirectory overrides one in a parent directory. Your global file (set with core.attributesFile, usually ~/.config/git/attributes). The system-wide file. When a file behaves strangely, ask Git what it thinks:\ngit check-attr -a path/to/file 1. End the line-ending wars This is the reason most people should add a .gitattributes today. Windows uses CRLF, everything else uses LF, and without rules you end up with diffs where every line \u0026ldquo;changed.\u0026rdquo;\n* text=auto *.sh text eol=lf *.bat text eol=crlf *.ps1 text eol=crlf text=auto lets Git detect text files and store them with LF in the repo. The eol= rules pin specific file types, which matters for shell scripts that break with CRLF and batch files that expect it.\nKnow this: adding these rules doesn\u0026rsquo;t fix files that are already committed. Run this once and commit the result:\ngit add --renormalize . To see the current line-ending state of every file:\ngit ls-files --eol 2. Mark binaries as binary *.png binary *.jpg binary *.zip binary binary is a built-in macro that expands to -text -diff -merge. Git won\u0026rsquo;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 \u0026ldquo;succeeded.\u0026rdquo;\n3. Hunk headers that actually help When you run git diff, each hunk starts with a line like @@ -12,7 +12,8 @@, 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:\n*.py diff=python *.rs diff=rust *.go diff=golang *.java diff=java *.md diff=markdown *.tex diff=tex These drivers are built in. There\u0026rsquo;s nothing to install. The Markdown one shows the nearest heading, which is great for docs.\n4. Diff files that aren\u0026rsquo;t text With a textconv driver, Git runs a command that turns a binary file into text, then diffs that text.\n*.png diff=exif *.pdf diff=pdf *.docx diff=docx git config --global diff.exif.textconv exiftool git config --global diff.docx.textconv \u0026#34;pandoc --from=docx --to=plain\u0026#34; Git appends the file path to the command, and the command must print text to stdout. exiftool and pandoc already do that. pdftotext doesn\u0026rsquo;t; by default it writes a .txt file next to the input. Wrap it in a tiny script:\n#!/bin/sh # save as ~/bin/pdf2txt and chmod +x it pdftotext -layout \u0026#34;$1\u0026#34; - git config --global diff.pdf.textconv pdf2txt Now git diff on an image shows which metadata changed, and git log -p on a Word doc or PDF shows actual prose changes. Add git config --global diff.pdf.cachetextconv true so Git doesn\u0026rsquo;t reconvert unchanged files every time.\n5. Silence noise you don\u0026rsquo;t care about package-lock.json -diff Cargo.lock -diff *.min.js -diff The files are still tracked and versioned normally. git diff just prints \u0026ldquo;Binary files differ\u0026rdquo; instead of four thousand lines of dependency churn.\n6. Merge strategies per file CHANGELOG.md merge=union union keeps the lines from both sides instead of creating a conflict. It\u0026rsquo;s perfect for append-only files like changelogs or lists of contributors. It\u0026rsquo;s dangerous for anything where order or structure matters, like code or JSON.\nYou can also keep your own version of a file on merge:\nconfig/local.yml merge=ours git config merge.ours.driver true Know this: a merge driver only runs when both 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.\n7. Clean and smudge filters This is the most powerful feature in the file. A filter has two halves:\nclean runs when a file is staged, transforming the working copy into what gets stored. smudge runs on checkout, transforming the stored version back into your working copy. *.ipynb filter=nbstrip git config filter.nbstrip.clean \u0026#34;jupyter nbconvert --clear-output --to notebook --stdout --stdin\u0026#34; git config filter.nbstrip.smudge cat git config filter.nbstrip.required true Now notebook outputs never get committed, but you keep them locally.\nReal-world tools built on filters:\nGit LFS stores large files elsewhere and commits small pointers: *.psd filter=lfs diff=lfs merge=lfs -text git-crypt transparently encrypts files in the repo and decrypts them on checkout nbstripout does the notebook trick above Know this: filter definitions live in Git config, not in the repo. .gitattributes only says \u0026ldquo;use the filter named X.\u0026rdquo; 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\u0026rsquo;s repo would be a security risk. Document your filters in the README.\n8. Shape your release archives git archive builds a tarball or zip of your repo. Attributes control what goes in it:\ntests/ export-ignore .github/ export-ignore .gitattributes export-ignore VERSION export-subst export-ignore leaves files out of release archives. GitHub\u0026rsquo;s \u0026ldquo;Download ZIP\u0026rdquo; and release source tarballs respect this too. It\u0026rsquo;s also one of the few attributes where a directory pattern like tests/ works, because git archive checks each directory as it walks the tree.\nexport-subst expands placeholders at archive time. Put this in VERSION:\n$Format:%H$ $Format:%cd$ The archive will contain the real commit hash and date.\n9. The GitHub extras GitHub reads a set of linguist-* attributes that change how your repo is displayed:\nvendor/** linguist-vendored dist/** linguist-generated docs/** linguist-documentation *.nx linguist-language=Rust scripts/*.txt linguist-detectable linguist-vendored and linguist-documentation exclude files from the language bar. linguist-generated also collapses those files in pull request diffs. For generated code, lockfiles, or build output, this makes reviews much faster. linguist-language fixes misdetection, or lets a custom language borrow another\u0026rsquo;s highlighting. linguist-detectable forces a language to count toward the stats when it normally wouldn\u0026rsquo;t. 10. Lesser-known attributes whitespace sets per-file rules for what git diff --check flags:\n*.md whitespace=-trailing-space *.py whitespace=trailing-space,tab-in-indent Markdown uses two trailing spaces as a line break, so you don\u0026rsquo;t want those flagged there.\nworking-tree-encoding stores UTF-16 files as UTF-8 in the repo so they diff properly, then converts them back on checkout:\n*.ps1 working-tree-encoding=UTF-16LE eol=crlf Only use it on files that really are UTF-16. With this line, git add refuses a .ps1 saved with a byte order mark (declare UTF-16LE-BOM for those) and one saved as UTF-8, which is what most editors write today.\n-delta skips delta compression for huge binaries that don\u0026rsquo;t compress well anyway, which speeds up packing:\n*.mp4 -delta ident expands $Id$ in a file to $Id: \u0026lt;blob hash\u0026gt;$ on checkout. It\u0026rsquo;s old-school, but handy for embedding a file\u0026rsquo;s exact version.\nlockable 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:\n*.psd lockable 11. Macros In the top-level .gitattributes only, you can define your own bundles of attributes:\n[attr]lockfile -diff linguist-generated package-lock.json lockfile yarn.lock lockfile Cargo.lock lockfile One name, one place to change it later.\nKnow this: don\u0026rsquo;t put merge=ours 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\u0026rsquo;s entries. Regenerate lockfiles after a merge instead.\nA sane starter file If you take one thing from this post, drop this into your next project:\n# Normalize line endings * text=auto # Scripts that care about endings *.sh text eol=lf *.bat text eol=crlf *.ps1 text eol=crlf # Better diff hunk headers *.py diff=python *.md diff=markdown *.rs diff=rust # Binaries *.png binary *.jpg binary *.gif binary *.ico binary *.zip binary *.pdf binary # Quiet noisy files *.lock -diff package-lock.json -diff # Keep release archives clean .github/ export-ignore .gitattributes export-ignore Closing thought .gitignore tells Git what to leave out. .gitattributes tells Git how to understand everything you keep. It\u0026rsquo;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.\n","permalink":"https://londo.me/blog/posts/gitattributes/","summary":"\u003cp\u003eEvery repo has a \u003ccode\u003e.gitignore\u003c/code\u003e. Almost nobody has a \u003ccode\u003e.gitattributes\u003c/code\u003e. That\u0026rsquo;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\u0026rsquo;ll want one in every project.\u003c/p\u003e\n\u003ch2 id=\"what-it-is\"\u003eWhat it is\u003c/h2\u003e\n\u003cp\u003e\u003ccode\u003e.gitattributes\u003c/code\u003e is a plain text file that assigns \u003cem\u003eattributes\u003c/em\u003e to paths. Each line is a pattern followed by one or more attributes:\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-gitattributes\" data-lang=\"gitattributes\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"s\"\u003e*.sh\u003c/span\u003e    \u003cspan class=\"na\"\u003etext\u003c/span\u003e \u003cspan class=\"na\"\u003eeol\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"l\"\u003elf\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"s\"\u003e*.png\u003c/span\u003e   \u003cspan class=\"na\"\u003ebinary\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"s\"\u003edocs/**\u003c/span\u003e \u003cspan class=\"na\"\u003elinguist-documentation\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\n\u003cp\u003eThe patterns work much like \u003ccode\u003e.gitignore\u003c/code\u003e, with two catches. Negation (\u003ccode\u003e!pattern\u003c/code\u003e) isn\u0026rsquo;t allowed. And a directory pattern like \u003ccode\u003evendor/\u003c/code\u003e does not apply to the files inside it, so you need \u003ccode\u003evendor/**\u003c/code\u003e.\u003c/p\u003e","title":"The Best Git Feature You've Never Used"},{"content":"I\u0026rsquo;m Londopy. I\u0026rsquo;m studying engineering with a focus on field work, and heading toward OT security: the computers that run power grids, water plants, and factories. Most of what I build starts as a question I can\u0026rsquo;t stop turning over.\nAway from the keyboard, I surf, climb, and freedive. I\u0026rsquo;ve been pescatarian for more than eight years, and Daoist philosophy shapes how I think about most things.\nClimbing by headlamp.\nWhat I write about Mostly security and systems: git internals, tooling, and the industrial control systems I\u0026rsquo;m heading toward. Some radio. And now and then, whatever I\u0026rsquo;ve been reading or eating, like the history of which animals religions have counted as fish.\nHow this blog works New posts go up on Thursdays, and you can get each one by email or RSS. Everything publishes here first, and copies on other sites link back to the original. If you spot a mistake, tell me through the contact page and I\u0026rsquo;ll fix it.\nElsewhere Projects: londo.me Code: github.com/Londopy Feed: RSS API: every post as JSON Contact: message form, IRC, Discord License Writing on this site is licensed CC BY 4.0. Code snippets are MIT. The source for the whole site is at Londopy/blog.\n","permalink":"https://londo.me/blog/about/","summary":"\u003cp\u003eI\u0026rsquo;m Londopy. I\u0026rsquo;m studying engineering with a focus on field work, and heading toward OT security: the computers that run power grids, water plants, and factories. Most of what I build starts as a question I can\u0026rsquo;t stop turning over.\u003c/p\u003e\n\u003cp\u003eAway from the keyboard, I surf, climb, and freedive. I\u0026rsquo;ve been pescatarian for more than eight years, and Daoist philosophy shapes how I think about most things.\u003c/p\u003e\n\u003cfigure\u003e\n    \u003cimg loading=\"lazy\" src=\"climbing-headlamp.jpg\"\n         alt=\"A climbing shoe on the rock and a trad rack of cams and nuts, lit by a headlamp at night, with a purple rope running up the wall\"/\u003e \u003cfigcaption\u003e\n            \u003cp\u003eClimbing by headlamp.\u003c/p\u003e","title":"About"},{"content":"Everything on this blog is also available as JSON, for your own reader, a widget, or a script. It\u0026rsquo;s a set of static files rebuilt with the blog on every push and every day, so there\u0026rsquo;s no key and no sign-up, and any website can fetch it (CORS is open). GitHub Pages may cache a file for up to ten minutes.\nOnly published posts appear. A scheduled post joins the API with the build that publishes it.\nQuick start # every post, newest first curl -s https://londo.me/blog/api/v1/posts.json # one post in full curl -s https://londo.me/blog/api/v1/posts/gitattributes.json # the list, readable in a terminal curl -s https://londo.me/blog/api/v1/posts.txt From a web page:\nconst res = await fetch(\u0026#34;https://londo.me/blog/api/v1/latest.json\u0026#34;); const { data: post } = await res.json(); console.log(post.title, post.url); With jq:\ncurl -s https://londo.me/blog/api/v1/posts.json | jq -r \u0026#39;.data[] | \u0026#34;\\(.published[:10]) \\(.title)\u0026#34;\u0026#39; Endpoints EndpointWhat you get Discovery /blog/api/v1/index.json Every endpoint, with its URL /blog/api/openapi.json This API, described in OpenAPI 3.1 /blog/api/v1/search.json Every post, page and tag, in one small file for search Site /blog/api/v1/site.json Title, tagline, links, license and publishing schedule Posts /blog/api/v1/posts.json Every published post, newest first /blog/api/v1/posts/{slug}.json One post in full, with its text, headings, code, links and images\n{slug}: A post\u0026#39;s slug, from posts.json /blog/api/v1/latest.json The newest post, in full Taxonomy /blog/api/v1/tags.json Tags, most used first, with the posts carrying each /blog/api/v1/tags/{tag}.json One tag, with its posts\n{tag}: A tag\u0026#39;s slug, from tags.json /blog/api/v1/archive.json Posts grouped by year and month Activity /blog/api/v1/stats.json The numbers, all in one place Feeds /blog/api/v1/feed.json JSON Feed 1.1, with full post content /blog/index.xml RSS 2.0, with full post content Badges /blog/api/v1/badges/{metric}.json A shields.io endpoint badge\n{metric}: posts, words, latest or updated Terminal /blog/api/v1/posts.txt The post list, for curl The same list is available as JSON at /blog/api/v1/index.json. The whole API is described in OpenAPI 3.1 at /blog/api/openapi.json, which you can load into Swagger UI, Postman, or a client generator.\nThe envelope Every response wraps its payload the same way as my portfolio\u0026rsquo;s API:\n{ \u0026#34;meta\u0026#34;: { \u0026#34;api\u0026#34;: \u0026#34;londopy-blog\u0026#34;, \u0026#34;version\u0026#34;: \u0026#34;1.0.0\u0026#34;, \u0026#34;generated\u0026#34;: \u0026#34;2026-09-25T20:39:31Z\u0026#34;, \u0026#34;self\u0026#34;: \u0026#34;https://londo.me/blog/api/v1/posts.json\u0026#34;, \u0026#34;docs\u0026#34;: \u0026#34;https://londo.me/blog/api/\u0026#34; }, \u0026#34;data\u0026#34;: [] } The standard formats skip the envelope, so their usual tools work on them: the JSON Feed, the shields.io badges, RSS, and posts.txt.\nA post The lists (posts.json, tags/{tag}.json) carry a summary of each post:\nField What it is slug, title, description The post\u0026rsquo;s slug, title, and my one-paragraph description summary The opening of the post, as plain text url The post on this blog, which is always the original api_url The post in full, as JSON published, updated ISO 8601 dates; updated comes from git tags Each tag\u0026rsquo;s name, slug, url and api_url reading_time_minutes, word_count How long it is cover The cover image\u0026rsquo;s url, width, height and alt posts/{slug}.json and latest.json add the rest:\nField What it is content_html The rendered post, with every link and image URL made absolute content_text The same post as plain text headings Each heading\u0026rsquo;s level, id, title, and a url straight to it code How many code blocks, and in which languages links Every link in the post, split into internal and external images Each image\u0026rsquo;s url, width and height, and whether it\u0026rsquo;s the cover older, newer The posts on either side, or null license CC-BY-4.0 for the writing, MIT for the code source The Markdown on GitHub, the raw file, and its history Feeds and badges The JSON Feed at /blog/api/v1/feed.json and the RSS feed at /blog/index.xml both carry full posts, so either works in a feed reader.\nThe badges plug into shields.io, for a README or anywhere else:\n![blog posts](https://img.shields.io/endpoint?url=https://londo.me/blog/api/v1/badges/posts.json) The metrics are posts, words, latest (the newest post\u0026rsquo;s title) and updated (its date).\nVersioning This is version 1. Within v1, changes only add things: new fields, new endpoints. Anything that would break a client gets a new /api/v2/, and v1 stays up alongside it.\nBefore every deploy, scripts/check_api.py validates every response against the OpenAPI description, so what the docs promise is what you get.\nUsing the writing The posts are licensed CC BY 4.0: quote them, translate them, or republish them, as long as you credit Londopy and link to the original post. Code in the posts is MIT. The whole site\u0026rsquo;s source is on GitHub.\nMy portfolio\u0026rsquo;s API covers everything else on londo.me: the projects, pull requests, and more.\n","permalink":"https://londo.me/blog/api/","summary":"\u003cp\u003eEverything on this blog is also available as JSON, for your own reader, a widget, or a script. It\u0026rsquo;s a set of static files rebuilt with the blog on every push and every day, so there\u0026rsquo;s no key and no sign-up, and any website can fetch it (CORS is open). GitHub Pages may cache a file for up to ten minutes.\u003c/p\u003e\n\u003cp\u003eOnly published posts appear. A scheduled post joins the API with the build that publishes it.\u003c/p\u003e","title":"API"},{"content":"Recruiter, collaborator, or just want to talk shop? Any of these reaches me.\nMessage form: londo.me/contact. No account and no login, and your note goes only to me. Tick \u0026ldquo;encrypt my message\u0026rdquo; to encrypt it with PGP in your browser, end to end. IRC: Londopy on irc.libera.chat. Send /msg Londopy from any client, or straight from web.libera.chat with no account. I stay connected, so it reaches me even when I\u0026rsquo;m away from the keyboard. Discord: _londo. GitHub: github.com/Londopy My PGP key\u0026rsquo;s fingerprint is F010 53B8 6FE7 D60A 0A1F B790 B11E 02F1 12FA F497. The full public key is on the contact page.\n","permalink":"https://londo.me/blog/contact/","summary":"\u003cp\u003eRecruiter, collaborator, or just want to talk shop? Any of these reaches me.\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003eMessage form:\u003c/strong\u003e \u003ca href=\"https://londo.me/contact/\"\u003elondo.me/contact\u003c/a\u003e.\nNo account and no login, and your note goes only to me. Tick \u0026ldquo;encrypt my\nmessage\u0026rdquo; to encrypt it with PGP in your browser, end to end.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eIRC:\u003c/strong\u003e \u003ccode\u003eLondopy\u003c/code\u003e on \u003ccode\u003eirc.libera.chat\u003c/code\u003e. Send \u003ccode\u003e/msg Londopy\u003c/code\u003e from any client,\nor straight from \u003ca href=\"https://web.libera.chat\"\u003eweb.libera.chat\u003c/a\u003e with no account.\nI stay connected, so it reaches me even when I\u0026rsquo;m away from the keyboard.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eDiscord:\u003c/strong\u003e \u003ccode\u003e_londo.\u003c/code\u003e\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eGitHub:\u003c/strong\u003e \u003ca href=\"https://github.com/Londopy\"\u003egithub.com/Londopy\u003c/a\u003e\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003eMy PGP key\u0026rsquo;s fingerprint is \u003ccode\u003eF010 53B8 6FE7 D60A 0A1F B790 B11E 02F1 12FA F497\u003c/code\u003e.\nThe full public key is on the \u003ca href=\"https://londo.me/contact/\"\u003econtact page\u003c/a\u003e.\u003c/p\u003e","title":"Contact"},{"content":"New posts go up on Thursdays, and you don\u0026rsquo;t have to remember to check back. Pick whichever of these suits you.\nBy email Email address Subscribe You get one email per new post: its title, a short description, and a link to read it here. The first email asks you to confirm, and every email has a link to unsubscribe. The emails are sent through Buttondown, a small newsletter service.\nIn a feed reader A feed reader is an app that checks the sites you follow and gathers their new posts in one place, like an inbox you control. Paste this address into one:\nhttps://londo.me/blog/index.xml\nOr use the JSON Feed, if your reader prefers it. There are free readers for every platform, for example NetNewsWire on iPhone and Mac, Feeder on Android, and Inoreader or Feedly on the web.\n","permalink":"https://londo.me/blog/subscribe/","summary":"\u003cp\u003eNew posts go up on Thursdays, and you don\u0026rsquo;t have to remember to check back.\nPick whichever of these suits you.\u003c/p\u003e\n\u003ch2 id=\"by-email\"\u003eBy email\u003c/h2\u003e\n\n\u003cform class=\"subscribe-form\" action=\"https://buttondown.com/api/emails/embed-subscribe/londopy\" method=\"post\" target=\"_blank\"\u003e\n  \u003clabel for=\"subscribe-email\"\u003eEmail address\u003c/label\u003e\n  \u003cinput id=\"subscribe-email\" type=\"email\" name=\"email\" placeholder=\"you@example.com\" autocomplete=\"email\" required\u003e\n  \u003cinput type=\"hidden\" name=\"embed\" value=\"1\"\u003e\n  \u003cbutton type=\"submit\"\u003eSubscribe\u003c/button\u003e\n\u003c/form\u003e\n\n\u003cp\u003eYou get one email per new post: its title, a short description, and a link to\nread it here. The first email asks you to confirm, and every email has a link\nto unsubscribe. The emails are sent through\n\u003ca href=\"https://buttondown.com\"\u003eButtondown\u003c/a\u003e, a small newsletter service.\u003c/p\u003e\n\u003ch2 id=\"in-a-feed-reader\"\u003eIn a feed reader\u003c/h2\u003e\n\u003cp\u003eA feed reader is an app that checks the sites you follow and gathers their new\nposts in one place, like an inbox you control. Paste this address into one:\u003c/p\u003e","title":"Subscribe"}]