<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://haacked.com/atom.xml" rel="self" type="application/atom+xml" /><link href="https://haacked.com/" rel="alternate" type="text/html" /><updated>2026-07-20T18:00:08+00:00</updated><id>https://haacked.com/atom.xml</id><title type="html">You’ve Been Haacked</title><subtitle>You&apos;ve been Haacked is a blog about Technology, Software, Management, and Open Source. It&apos;s full of good stuff.
</subtitle><author><name>Phil Haack</name></author><entry><title type="html">Sign Commits from Anywhere Without Your Keys Going Anywhere</title><link href="https://haacked.com/archive/2026/07/20/remote-commit-signing/" rel="alternate" type="text/html" title="Sign Commits from Anywhere Without Your Keys Going Anywhere" /><published>2026-07-20T00:00:00+00:00</published><updated>2026-07-20T00:00:00+00:00</updated><id>https://haacked.com/archive/2026/07/20/remote-commit-signing</id><content type="html" xml:base="https://haacked.com/archive/2026/07/20/remote-commit-signing/"><![CDATA[<p>These days, my main MacBook Pro does most of its coding without me. I mostly show up to take the credit. For example, every day I walk with my daughter to a coffee shop while Claude Code churns through tasks in <a href="https://haacked.com/archive/2025/11/21/tree-me/">worktrees</a>. At the coffee shop, I’ll remote in on a cheap MacBook Neo (upgraded from an iPad) to continue working. It’s pretty much just a remote control for my main MacBook. <a href="https://jumpdesktop.com/">Jump Desktop</a> gives me a screen share, <a href="https://blink.sh/">Blink Shell</a> gives me a terminal on the iPad, and the work keeps going whether I’m there or not.</p>

<p>There was one snag: signed commits.</p>

<p>I sign all my commits, and my signing keys live in <a href="https://support.apple.com/guide/security/the-secure-enclave-sec59b0b31ff/web">the Secure Enclave</a> via <a href="https://github.com/maxgoedjen/secretive">Secretive</a>. That’s great for security. The key physically cannot leave the machine. It’s less great when git asks the Secure Enclave to sign a commit and the Secure Enclave asks for a Touch ID press from a person who is forty miles away from the keyboard.</p>

<p><img src="https://i.haacked.com/blog/2026-07-20-remote-commit-signing/image1.png" alt="Declaration of Independenc by John Turmbull - Public Domain" title="The first group commit signing" /></p>

<p>This post walks through the setup I landed on. I can be on my iPad or the cheap laptop, remote into the main Mac, and every commit gets signed by a hardware-backed key. Even commits made by a Claude Code session that’s been running since morning. The approval prompt shows up on the device in my hands.</p>

<h2 id="why-secretive-instead-of-a-key-file">Why Secretive Instead of a Key File?</h2>

<p>The traditional approach stores your SSH private key in a file like <code class="language-plaintext highlighter-rouge">~/.ssh/id_ed25519</code>. The problem is that it’s a file. Any process running as you can read it. A malicious npm package can quietly copy it. So can a compromised build script or a sloppy backup. Once it’s copied, it’s gone, and you won’t even know. A passphrase helps less than you’d think because the decrypted key sits in ssh-agent’s memory anyway. Besides, you type that passphrase into enough prompts that you eventually stop looking at them.</p>

<p>Secretive generates keys inside the Secure Enclave, the same hardware chip that guards Touch ID and Apple Pay. The private key never exists on disk or in process memory, and there’s no export button, even for you. When something wants a signature, it has to ask the enclave, and the enclave asks you for Touch ID or Apple Watch approval first. The enclave trusts nobody, which I respect.</p>

<p>That changes the threat model. Malware can’t steal a key it can’t read, so the worst it can do is trigger a Touch ID prompt I wasn’t expecting, which is its own alarm bell. If I lose the laptop, its not a big deal as the key is still sealed inside it. Revoking a key for a laptop I sold only requires that I delete a line from <code class="language-plaintext highlighter-rouge">allowed_signers</code>, not a fire drill.</p>

<div class="markdown-alert markdown-alert-important"><p class="markdown-alert-title"><svg class="octicon octicon-report" viewBox="0 0 16 16" version="1.1" width="16" height="16" aria-hidden="true"><path d="M0 1.75C0 .784.784 0 1.75 0h12.5C15.216 0 16 .784 16 1.75v9.5A1.75 1.75 0 0 1 14.25 13H8.06l-2.573 2.573A1.458 1.458 0 0 1 3 14.543V13H1.75A1.75 1.75 0 0 1 0 11.25Zm1.75-.25a.25.25 0 0 0-.25.25v9.5c0 .138.112.25.25.25h2a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h6.5a.25.25 0 0 0 .25-.25v-9.5a.25.25 0 0 0-.25-.25Zm7 2.25v2.5a.75.75 0 0 1-1.5 0v-2.5a.75.75 0 0 1 1.5 0ZM9 9a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z"></path></svg> Important</p><p>That alarm bell only rings if you check “Requires Authentication” when you create the key in Secretive. You can’t change it later, only create a new key. Without it, the enclave signs silently for any process running as you, which defeats most of the point. And when Secretive prompts you, it offers to keep the approval active for a period of time. During that window, signing is silent too. Keep it short.</p>
</div>

<p>The tradeoff, of course, is that the key can’t leave the machine even when I have a good reason to want it to. Which brings us back to my problem.</p>

<h2 id="the-setup">The Setup</h2>

<p>Each of my Macs has its own Secretive key. Git signs with SSH keys these days (no GPG ceremony required), so the base config is small:</p>

<div class="language-ini highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nn">[user]</span>
    <span class="py">localSigningKey</span> <span class="p">=</span> <span class="s">~/Library/Containers/com.maxgoedjen.Secretive.SecretAgent/Data/PublicKeys/&lt;hash&gt;.pub</span>
<span class="nn">[gpg]</span>
    <span class="py">format</span> <span class="p">=</span> <span class="s">ssh</span>
<span class="nn">[gpg "ssh"]</span>
    <span class="py">program</span> <span class="p">=</span> <span class="s">/usr/bin/ssh-keygen</span>
    <span class="py">allowedSignersFile</span> <span class="p">=</span> <span class="s">~/.dotfiles/git/allowed_signers</span>
<span class="nn">[commit]</span>
    <span class="py">gpgsign</span> <span class="p">=</span> <span class="s">true</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">allowed_signers</code> file maps my email addresses to every machine’s public key, so any machine can verify commits signed by any other:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>haacked@gmail.com ecdsa-sha2-nistp256 AAAA… GitHub-Commit-Signing@secretive.HaackBook-Pro.local
phil.h@posthog.com ecdsa-sha2-nistp256 AAAA… GitHub-Commit-Signing@secretive.HaackBook-Pro.local
haacked@gmail.com ecdsa-sha2-nistp256 AAAA… GitHub-Signing-Key@secretive.Phils-MacBook-Pro.local
…
</code></pre></div></div>

<p>Each public key is also registered on GitHub as a signing key, so GitHub shows the Verified badge no matter which machine did the signing.</p>

<div class="markdown-alert markdown-alert-note"><p class="markdown-alert-title"><svg class="octicon octicon-info" viewBox="0 0 16 16" version="1.1" width="16" height="16" aria-hidden="true"><path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.5 7.75A.75.75 0 0 1 7.25 7h1a.75.75 0 0 1 .75.75v2.75h.25a.75.75 0 0 1 0 1.5h-2a.75.75 0 0 1 0-1.5h.25v-2h-.25a.75.75 0 0 1-.75-.75ZM8 6a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z"></path></svg> Note</p><p>That’s <code class="language-plaintext highlighter-rouge">localSigningKey</code>, not <code class="language-plaintext highlighter-rouge">signingkey</code>. It’s a custom config key I made up, and the difference matters. More on that in a moment.</p>
</div>

<p>So far, so standard. Sitting at any one of these machines, <code class="language-plaintext highlighter-rouge">git commit</code> triggers a Touch ID prompt (or on my MacBook Neo, which lacks Touch ID, a double-click on my Apple Watch) and produces a signed commit. The interesting part is what happens when nobody is sitting at the machine.</p>

<h2 id="the-problem-with-being-remote">The Problem with Being Remote</h2>

<p>When I remote into the main Mac, there are two ways in, and both break signing in their own way.</p>

<p><strong>Screen sharing (Jump Desktop)</strong>: I’m looking at the Mac’s actual desktop, driving its actual shells. When git commits, it asks the Mac’s local Secretive for a signature, and Secretive pops a Touch ID prompt on a laptop sitting alone at a desk in a room I’m not in, its screen long asleep. My iPad can show me the prompt. It can’t press a fingerprint through the glass, no matter how hard I tap it.</p>

<p><strong>SSH</strong>: SSH has agent forwarding, which is almost the answer. With <code class="language-plaintext highlighter-rouge">ForwardAgent yes</code>, my client device’s SSH agent (Secretive on the cheap MacBook, Blink’s Secure Enclave keys on the iPad) becomes reachable on the main Mac through a socket that sshd creates. Signature requests travel back over the SSH connection to the device in my hands, which is exactly what I want.</p>

<p>But naive agent forwarding has three gaps:</p>

<ol>
  <li><strong>The socket dies with the session.</strong> sshd puts it at a random path, exports it as <code class="language-plaintext highlighter-rouge">SSH_AUTH_SOCK</code> in that one session, and deletes it when the connection drops. Every other shell on the machine has no idea it exists.</li>
  <li><strong>Long-lived processes never see it.</strong> A Claude Code session started this morning inherited whatever <code class="language-plaintext highlighter-rouge">SSH_AUTH_SOCK</code> existed this morning. It’s not going to re-read my zshrc because I SSHed in after lunch. Neither is the shell inside the Jump Desktop screen share.</li>
  <li><strong>Git needs to know which key to ask for.</strong> SSH signing requires the specific public key up front. When I’m remote, the right key is the client device’s key, not the Mac’s. A key baked into git config is wrong half the time.</li>
</ol>

<p>I went looking for a way to remotely approve the Mac’s own Secretive prompts. There isn’t one. Secretive’s approval is deliberately local-only, which is kind of the whole point of it. So instead of trying to weaken the Mac’s key, I stopped using the Mac’s key when I’m not at the Mac and started signing with the key of the device I’m actually touching.</p>

<h2 id="one-symlink-zero-cached-state">One Symlink, Zero Cached State</h2>

<p>The whole system comes down to two decisions:</p>

<ol>
  <li>There is exactly one stable agent path on the machine: <code class="language-plaintext highlighter-rouge">~/.ssh/agent.sock</code>, a symlink. Shells, GUI apps, and git all point at the symlink, and the symlink points at whichever real agent socket is currently alive.</li>
  <li>Git figures out the signing key at the moment of signing, not ahead of time, so there’s no cached key file to go stale.</li>
</ol>

<p>Everything else is plumbing to keep those two things true. All of it lives in <a href="https://github.com/haacked/dotfiles/">my dotfiles</a>.</p>

<h3 id="the-symlink-healer">The Symlink Healer</h3>

<p>A small script, <a href="https://github.com/haacked/dotfiles/blob/main/bin/ssh-agent-sync"><code class="language-plaintext highlighter-rouge">bin/ssh-agent-sync</code></a>, decides where the symlink points. Handed a live forwarded socket, it points the symlink there. Otherwise, if the symlink is dangling, it heals back to the local Secretive socket:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">sock</span><span class="o">=</span><span class="s2">"</span><span class="nv">$HOME</span><span class="s2">/.ssh/agent.sock"</span>
<span class="nv">secretive</span><span class="o">=</span><span class="s2">"</span><span class="nv">$HOME</span><span class="s2">/Library/Containers/com.maxgoedjen.Secretive.SecretAgent/Data/socket.ssh"</span>
<span class="nv">forwarded</span><span class="o">=</span><span class="s2">"</span><span class="k">${</span><span class="nv">1</span><span class="k">:-}</span><span class="s2">"</span>

<span class="k">if</span> <span class="o">[[</span> <span class="nt">-n</span> <span class="s2">"</span><span class="nv">$forwarded</span><span class="s2">"</span> <span class="o">&amp;&amp;</span> <span class="nt">-S</span> <span class="s2">"</span><span class="nv">$forwarded</span><span class="s2">"</span> <span class="o">]]</span><span class="p">;</span> <span class="k">then</span>
  <span class="o">[[</span> <span class="s2">"</span><span class="nv">$sock</span><span class="s2">"</span> <span class="nt">-ef</span> <span class="s2">"</span><span class="nv">$forwarded</span><span class="s2">"</span> <span class="o">]]</span> <span class="o">||</span> <span class="nb">ln</span> <span class="nt">-sf</span> <span class="s2">"</span><span class="nv">$forwarded</span><span class="s2">"</span> <span class="s2">"</span><span class="nv">$sock</span><span class="s2">"</span>
<span class="k">elif</span> <span class="o">[[</span> <span class="o">!</span> <span class="nt">-S</span> <span class="s2">"</span><span class="nv">$sock</span><span class="s2">"</span> <span class="o">]]</span> <span class="o">&amp;&amp;</span> <span class="o">[[</span> <span class="nt">-S</span> <span class="s2">"</span><span class="nv">$secretive</span><span class="s2">"</span> <span class="o">]]</span><span class="p">;</span> <span class="k">then
  </span><span class="nb">ln</span> <span class="nt">-sf</span> <span class="s2">"</span><span class="nv">$secretive</span><span class="s2">"</span> <span class="s2">"</span><span class="nv">$sock</span><span class="s2">"</span>
<span class="k">fi

if</span> <span class="o">[[</span> <span class="nt">-S</span> <span class="s2">"</span><span class="nv">$sock</span><span class="s2">"</span> <span class="o">]]</span> <span class="o">&amp;&amp;</span> <span class="o">[[</span> <span class="s2">"</span><span class="nv">$sock</span><span class="s2">"</span> <span class="nt">-ef</span> <span class="s2">"</span><span class="nv">$secretive</span><span class="s2">"</span> <span class="o">]]</span><span class="p">;</span> <span class="k">then
  </span><span class="nb">echo</span> <span class="s2">"local"</span>
<span class="k">elif</span> <span class="o">[[</span> <span class="nt">-S</span> <span class="s2">"</span><span class="nv">$sock</span><span class="s2">"</span> <span class="o">]]</span><span class="p">;</span> <span class="k">then
  </span><span class="nb">echo</span> <span class="s2">"forwarded"</span>
<span class="k">else
  </span><span class="nb">echo</span> <span class="s2">"none"</span>
<span class="k">fi</span>
</code></pre></div></div>

<p>It’s idempotent. It only touches the symlink when the symlink is wrong, and it reports which mode it resolved to (<code class="language-plaintext highlighter-rouge">local</code>, <code class="language-plaintext highlighter-rouge">forwarded</code>, or <code class="language-plaintext highlighter-rouge">none</code>) so callers don’t have to figure that out themselves.</p>

<h3 id="the-shell-hook">The Shell Hook</h3>

<p>My zshrc captures the forwarded socket and keeps the symlink synced. The subtle bits are when it captures and how often it syncs:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Capture the sshd-provided socket once, at startup, before the first</span>
<span class="c"># sync overwrites SSH_AUTH_SOCK with the symlink path.</span>
<span class="o">[[</span> <span class="nt">-n</span> <span class="s2">"</span><span class="nv">$SSH_CONNECTION</span><span class="s2">"</span> <span class="o">]]</span> <span class="o">&amp;&amp;</span> <span class="nv">_SSH_FWD_SOCK</span><span class="o">=</span><span class="s2">"</span><span class="nv">$SSH_AUTH_SOCK</span><span class="s2">"</span>

_ssh_agent_sync<span class="o">()</span> <span class="o">{</span>
  <span class="s2">"</span><span class="nv">$HOME</span><span class="s2">/.dotfiles/bin/ssh-agent-sync"</span> <span class="s2">"</span><span class="nv">$_SSH_FWD_SOCK</span><span class="s2">"</span> <span class="o">&gt;</span>/dev/null
  <span class="o">[[</span> <span class="nt">-S</span> <span class="s2">"</span><span class="nv">$HOME</span><span class="s2">/.ssh/agent.sock"</span> <span class="o">]]</span> <span class="o">&amp;&amp;</span> <span class="nb">export </span><span class="nv">SSH_AUTH_SOCK</span><span class="o">=</span><span class="s2">"</span><span class="nv">$HOME</span><span class="s2">/.ssh/agent.sock"</span>
<span class="o">}</span>
_ssh_agent_sync
autoload <span class="nt">-Uz</span> add-zsh-hook
add-zsh-hook precmd _ssh_agent_sync
</code></pre></div></div>

<p>The forwarded socket path has to be captured before the first sync. After that, <code class="language-plaintext highlighter-rouge">SSH_AUTH_SOCK</code> is the symlink, and re-deriving the forwarded socket from it would just compare the symlink against itself.</p>

<p>It also re-syncs on every <code class="language-plaintext highlighter-rouge">precmd</code>, not just at shell startup. The symlink is shared machine-wide, and a local shell that was running before my SSH session started will never source zshrc again when that session ends. Without the recheck, that shell would keep pointing at a forwarding socket sshd already tore down. Since the healer is idempotent, idle prompts don’t pay for a redundant <code class="language-plaintext highlighter-rouge">ln</code>.</p>

<p>Every shell exports the symlink, never the raw socket. That’s the indirection that makes long-lived sessions work. A Claude Code session started hours ago holds a path that never changes, even though what’s on the other end of it comes and goes.</p>

<h3 id="resolving-the-key-at-sign-time">Resolving the Key at Sign Time</h3>

<p>Here’s where <code class="language-plaintext highlighter-rouge">localSigningKey</code> pays off. Git has an escape hatch: if <code class="language-plaintext highlighter-rouge">user.signingkey</code> is unset, git runs <code class="language-plaintext highlighter-rouge">gpg.ssh.defaultKeyCommand</code> to ask which key to use, fresh, on every signature:</p>

<div class="language-ini highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nn">[gpg "ssh"]</span>
    <span class="py">defaultKeyCommand</span> <span class="p">=</span> <span class="s">~/.dotfiles/bin/git-signing-key</span>
</code></pre></div></div>

<p><a href="https://github.com/haacked/dotfiles/blob/main/bin/git-signing-key"><code class="language-plaintext highlighter-rouge">bin/git-signing-key</code></a> re-runs the healer, then answers based on what the symlink currently points to:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">mode</span><span class="o">=</span><span class="si">$(</span><span class="s2">"</span><span class="nv">$bin_dir</span><span class="s2">/ssh-agent-sync"</span><span class="si">)</span> <span class="o">||</span> <span class="nv">mode</span><span class="o">=</span><span class="s2">"none"</span>

<span class="k">if</span> <span class="o">[[</span> <span class="s2">"</span><span class="nv">$mode</span><span class="s2">"</span> <span class="o">==</span> <span class="s2">"forwarded"</span> <span class="o">]]</span><span class="p">;</span> <span class="k">then
  </span><span class="nv">forwarded_key</span><span class="o">=</span><span class="si">$(</span><span class="nv">SSH_AUTH_SOCK</span><span class="o">=</span><span class="s2">"</span><span class="nv">$sock</span><span class="s2">"</span> ssh-add <span class="nt">-L</span> 2&gt;/dev/null | <span class="nb">head</span> <span class="nt">-1</span><span class="si">)</span> <span class="o">||</span> <span class="nb">true
  </span><span class="k">if</span> <span class="o">[[</span> <span class="s2">"</span><span class="nv">$forwarded_key</span><span class="s2">"</span> <span class="o">==</span> ssh-<span class="k">*</span> <span class="o">||</span> <span class="s2">"</span><span class="nv">$forwarded_key</span><span class="s2">"</span> <span class="o">==</span> ecdsa-<span class="k">*</span> <span class="o">||</span> <span class="s2">"</span><span class="nv">$forwarded_key</span><span class="s2">"</span> <span class="o">==</span> sk-<span class="k">*</span> <span class="o">]]</span><span class="p">;</span> <span class="k">then
    </span><span class="nb">printf</span> <span class="s1">'key::%s\n'</span> <span class="s2">"</span><span class="nv">$forwarded_key</span><span class="s2">"</span>
    <span class="nb">exit </span>0
  <span class="k">fi
fi

</span><span class="nv">local_signing_key</span><span class="o">=</span><span class="si">$(</span>git config <span class="nt">--global</span> <span class="nt">--includes</span> <span class="nt">--get</span> user.localSigningKey 2&gt;/dev/null<span class="si">)</span> <span class="o">||</span> <span class="nv">local_signing_key</span><span class="o">=</span><span class="s2">""</span>
<span class="k">if</span> <span class="o">[[</span> <span class="nt">-n</span> <span class="s2">"</span><span class="nv">$local_signing_key</span><span class="s2">"</span> <span class="o">&amp;&amp;</span> <span class="nt">-f</span> <span class="s2">"</span><span class="nv">$local_signing_key</span><span class="s2">"</span> <span class="o">]]</span><span class="p">;</span> <span class="k">then
  </span><span class="nb">printf</span> <span class="s1">'key::%s\n'</span> <span class="s2">"</span><span class="si">$(</span><span class="nb">cat</span> <span class="s2">"</span><span class="nv">$local_signing_key</span><span class="s2">"</span><span class="si">)</span><span class="s2">"</span>
  <span class="nb">exit </span>0
<span class="k">fi</span>
</code></pre></div></div>

<p>If a forwarded agent is live, sign with whatever key it offers. That’s the client device’s Secure Enclave key. Otherwise, fall back to this machine’s own Secretive key via <code class="language-plaintext highlighter-rouge">user.localSigningKey</code>. Whichever agent is alive when git signs is the one that gets asked.</p>

<p>An earlier version of this setup cached the resolved key in a file that shell hooks kept up to date. That had a hole in it. Anything that signed without a shell prompt having been drawn since the last agent change (a background agent session, a GUI git client) would sign with a stale key or fail. Deleting the cache and resolving at use time made that whole category of bugs go away.</p>

<h3 id="gui-apps">GUI Apps</h3>

<p>One LaunchAgent seeds the environment for everything that never sources zshrc:</p>

<div class="language-xml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;key&gt;</span>ProgramArguments<span class="nt">&lt;/key&gt;</span>
<span class="nt">&lt;array&gt;</span>
    <span class="nt">&lt;string&gt;</span>/bin/sh<span class="nt">&lt;/string&gt;</span>
    <span class="nt">&lt;string&gt;</span>-c<span class="nt">&lt;/string&gt;</span>
    <span class="nt">&lt;string&gt;</span>"$HOME/.dotfiles/bin/ssh-agent-sync" &gt;/dev/null <span class="err">&amp;&amp;</span> /bin/launchctl setenv SSH_AUTH_SOCK "$HOME/.ssh/agent.sock"<span class="nt">&lt;/string&gt;</span>
<span class="nt">&lt;/array&gt;</span>
<span class="nt">&lt;key&gt;</span>RunAtLoad<span class="nt">&lt;/key&gt;</span>
<span class="nt">&lt;true/&gt;</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">launchctl setenv</code> makes <code class="language-plaintext highlighter-rouge">SSH_AUTH_SOCK</code> visible to GUI apps. And because it’s set to the symlink rather than a raw socket, those apps track agent transitions too, without ever knowing transitions happen.</p>

<h3 id="the-ssh-client-side">The SSH Client Side</h3>

<p>The last piece is the client config, which is refreshingly boring:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Host phils-macbook-pro macbook
    HostName 100.x.y.z
    ForwardAgent yes

Host *
    IdentityAgent "~/Library/Containers/com.maxgoedjen.Secretive.SecretAgent/Data/socket.ssh"
</code></pre></div></div>

<p>The IP is a <a href="https://tailscale.com/">Tailscale</a> address, so <code class="language-plaintext highlighter-rouge">ssh macbook</code> works from anywhere without exposing sshd to the internet. <code class="language-plaintext highlighter-rouge">ForwardAgent yes</code> does the forwarding. <code class="language-plaintext highlighter-rouge">IdentityAgent</code> points outbound SSH at Secretive, so authenticating to the Mac is enclave-backed too. On the iPad, Blink Shell does the equivalent with its own Secure Enclave keys.</p>

<h2 id="what-it-looks-like-in-practice">What It Looks Like in Practice</h2>

<p>From the iPad or the cheap MacBook: <code class="language-plaintext highlighter-rouge">ssh macbook</code>. Tmux auto-attaches to my persistent session, and from that moment the entire machine signs through the device in my hands. The SSH session, sure. But also the Jump Desktop screen share I have open next to it, and the Claude Code session that’s been grinding away since breakfast. They all hold the same symlink, and the symlink now points at the forwarded agent.</p>

<p>When any of them commits, the approval prompt appears right here. A double-click on my Apple Watch for the MacBook Neo. Face ID via Blink on the iPad. The commit gets signed with this device’s key, which is a different key than the main Mac would use, but <code class="language-plaintext highlighter-rouge">allowed_signers</code> and GitHub know all my keys, so every commit verifies the same.</p>

<p>When I close the connection, the next shell prompt on the Mac notices the dead socket and heals the symlink back to local Secretive. I sit down at the desk and Touch ID works like nothing happened. There’s no remote mode to toggle, just a symlink that points at whatever agent is currently real.</p>

<h2 id="how-it-bit-me">How It Bit Me</h2>

<p>Not long after I drafted this post, signing started failing on me. Sometimes the approval prompt showed up on the device in my hands like it should. Other times git would sit there waiting on an approval that never came, because the Touch ID prompt was on the Mac’s screen in a room I wasn’t in.</p>

<p>The culprit was me.</p>

<p>At some point I had typed <code class="language-plaintext highlighter-rouge">ssh macbook</code> inside a tmux pane that was already on the main Mac. Every tmux pane looks exactly like every other tmux pane, and I thought I was on the other laptop. Reader, I was not. So the Mac ssh’d into itself, forwarded its own Secretive agent back to itself, and the shell hook in that nested session adopted the looped socket as if it were a genuine forwarded agent. From then on, the machine believed it was remote. My Mac was having an out-of-body experience, and my commits were paying for it. Every signature request went out through the “forwarded” socket and arrived right back at the local Secretive, which wanted a Touch ID press at a desk nobody was sitting at. Meanwhile my actual remote session held a perfectly good forwarded agent that nothing was using.</p>

<p>The fix has two parts, both in the dotfiles now. The SSH config disables agent forwarding when the destination is the machine you’re already on. And <code class="language-plaintext highlighter-rouge">ssh-agent-sync</code> compares a new forwarded socket’s key list against the local Secretive’s before adopting it. An exact match means it’s a loop, and the script treats it as local.</p>

<h2 id="caveats">Caveats</h2>

<p><strong>Agent forwarding is a trust decision.</strong> A forwarded agent means anyone with root on the host can send signature requests to your client’s agent. Forwarding into a box you don’t control is how people get their keys borrowed. I’m forwarding into my own Mac over my own tailnet, and every request still requires biometric approval on the client, so I sleep fine. I wouldn’t <code class="language-plaintext highlighter-rouge">ForwardAgent yes</code> to a shared server.</p>

<p><strong>Pure screen share can’t sign unattended.</strong> If there’s no live SSH connection, the symlink points at the Mac’s local Secretive, and that still wants a human at the machine. Usually the fix is one <code class="language-plaintext highlighter-rouge">ssh macbook</code> from whatever device I’m holding. For the rare case where even that’s not an option, I commit with <code class="language-plaintext highlighter-rouge">--no-gpg-sign</code> and batch re-sign later:</p>

<div class="language-ini highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nn">[alias]</span>
    <span class="py">sign-unpushed</span> <span class="p">=</span> <span class="s">"!f() { DEFAULT=$(git default); UPSTREAM=${1-origin/$DEFAULT}; git rebase --exec </span><span class="se">\"</span><span class="s">git commit --amend --no-edit -S</span><span class="se">\"</span><span class="s"> $UPSTREAM; }; f"</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">git sign-unpushed</code> rebases the unpushed commits and re-signs each one. Back at the desk, it’s a satisfying little drumroll of Touch ID prompts.</p>

<h2 id="steal-it">Steal It</h2>

<p>At no point in any of this does a private key exist as a file. Each device has its own key sealed in its own Secure Enclave, and every signature requires a biometric approval on a device I’m physically touching. If I lose a device, I revoke one key and move on.</p>

<p>And I didn’t give up anything to get here. Claude Code works on the big machine, I supervise from whatever screen is nearby, and commits come out signed and verified. The whole thing is two small scripts, a symlink, and one underappreciated git config setting. The symlink is doing most of the work, which feels right for a technology from 1983.</p>

<p>Everything is in my <a href="https://github.com/haacked/dotfiles/">dotfiles repo</a>: <code class="language-plaintext highlighter-rouge">bin/ssh-agent-sync</code>, <code class="language-plaintext highlighter-rouge">bin/git-signing-key</code>, the zshrc hook, and the LaunchAgent. That’s what dotfiles are for.</p>]]></content><author><name>Phil Haack</name></author><category term="git" /><category term="security" /><category term="ssh" /><category term="productivity" /><category term="dotfiles" /><summary type="html"><![CDATA[How I sign git commits with Secure Enclave keys while remoted into my main Mac from an iPad or a cheap laptop. All it takes is Secretive, SSH agent forwarding, and one well-placed symlink.]]></summary></entry><entry><title type="html">Resolve Merge Conflicts the Easy Way</title><link href="https://haacked.com/archive/2026/03/25/resolve-merge-conflicts/" rel="alternate" type="text/html" title="Resolve Merge Conflicts the Easy Way" /><published>2026-03-25T00:00:00+00:00</published><updated>2026-03-25T00:00:00+00:00</updated><id>https://haacked.com/archive/2026/03/25/resolve-merge-conflicts</id><content type="html" xml:base="https://haacked.com/archive/2026/03/25/resolve-merge-conflicts/"><![CDATA[<p>Git is great at merging until it isn’t. Most of the time, when I rebase my feature branch against the main branch, it all goes to plan. Nothing to do for me. But when it doesn’t go to plan, it can be a big mess. Git dumps a wall of conflict markers on you. You resolve those, continue the rebase, and the next commit has conflicts too. Depending on the scope of changes, resolving merge conflicts can be a very tedious chore. The temptation to <code class="language-plaintext highlighter-rouge">git rebase --abort</code> and pretend this never happened is overwhelming.</p>

<p>It turns out, we have some great tools now for dealing with tedious chores. In particular, I’ve set up two tools that turned merge conflicts from a dreaded chore into a minor speed bump. Most of the time, they resolve themselves before I even see them. For the ones that don’t, automation handles the tedious parts so I only deal with the genuinely ambiguous cases.</p>

<p><img src="https://i.haacked.com/blog/2026-03-25-resolve-merge-conflicts/merge-conflict-resolution.png" alt="A friendly robot referee untangling two git branches" /></p>

<h2 id="the-problem-with-textual-merging">The Problem with Textual Merging</h2>

<p>Git’s built-in merge is purely textual. It compares lines of text and looks for overlapping changes. It doesn’t understand your code. It doesn’t know what a function is, or an import statement, or a class definition. It just sees lines.</p>

<p>This means Git reports conflicts that aren’t actually conflicts. Two developers add different imports to the same file, near the same spot. Git sees overlapping line changes and panics:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>&lt;&lt;&lt;&lt;&lt;&lt;&lt; HEAD
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
=======
import { useState } from 'react';
import { useEffect } from 'react';
&gt;&gt;&gt;&gt;&gt;&gt;&gt; feature/dashboard
</code></pre></div></div>

<p>A human can see instantly that both changes are independent additions. One added <code class="language-plaintext highlighter-rouge">useQuery</code>, the other added <code class="language-plaintext highlighter-rouge">useEffect</code>. The correct resolution is to keep all three imports. But Git can’t see that because Git doesn’t understand syntax. It only sees text.</p>

<p>These false conflicts add up. On a large rebase, they can turn a five-minute task into a thirty-minute slog.</p>

<h2 id="layer-1-mergiraf">Layer 1: Mergiraf</h2>

<p><a href="https://mergiraf.org/">Mergiraf</a> is a structural merge driver for Git. Instead of comparing lines of text, it parses your files using language grammars and merges at the syntax tree level. If two changes touch different parts of the syntax tree, it merges them cleanly. If they genuinely conflict at the structural level, it falls back to standard conflict markers.</p>

<p>That import example above? Mergiraf resolves it automatically. It understands that those are independent additions to an import list and combines them.</p>

<p>Mergiraf supports over 25 languages: Java, Rust, Go, Python, JavaScript, TypeScript, C, C++, C#, Ruby, Elixir, PHP, Dart, Scala, Haskell, OCaml, Lua, Nix, YAML, TOML, HTML, XML, and more. For file types it doesn’t support, it returns a non-zero exit code and Git falls back to its default textual merge. No harm done.</p>

<h3 id="setup">Setup</h3>

<p>Three steps.</p>

<p><strong>1. Install mergiraf:</strong></p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>brew <span class="nb">install </span>mergiraf
</code></pre></div></div>

<p><strong>2. Register the merge driver in your <code class="language-plaintext highlighter-rouge">~/.gitconfig</code>:</strong></p>

<div class="language-ini highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nn">[merge "mergiraf"]</span>
    <span class="py">name</span> <span class="p">=</span> <span class="s">mergiraf</span>
    <span class="py">driver</span> <span class="p">=</span> <span class="s">mergiraf merge --git %O %A %B -s %S -x %X -y %Y -p %P -l %L</span>
</code></pre></div></div>

<p><strong>3. Apply it globally in <code class="language-plaintext highlighter-rouge">~/.config/git/attributes</code>:</strong></p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>* merge=mergiraf
</code></pre></div></div>

<p>The wildcard (<code class="language-plaintext highlighter-rouge">*</code>) tells Git to run every file through mergiraf. This might sound aggressive, but it’s fine. If mergiraf doesn’t recognize the file type, it steps aside and Git handles it normally.</p>

<div class="markdown-alert markdown-alert-note"><p class="markdown-alert-title"><svg class="octicon octicon-info" viewBox="0 0 16 16" version="1.1" width="16" height="16" aria-hidden="true"><path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.5 7.75A.75.75 0 0 1 7.25 7h1a.75.75 0 0 1 .75.75v2.75h.25a.75.75 0 0 1 0 1.5h-2a.75.75 0 0 1 0-1.5h.25v-2h-.25a.75.75 0 0 1-.75-.75ZM8 6a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z"></path></svg> Note</p><p>If you use my <a href="https://github.com/haacked/dotfiles">dotfiles</a>, the <code class="language-plaintext highlighter-rouge">git/install.sh</code> script creates the attributes file for you. Run it once and you’re done.</p>
</div>

<h3 id="companion-settings">Companion Settings</h3>

<p>Two additional Git settings complement mergiraf nicely.</p>

<p><strong>diff3 conflict style</strong>: By default, Git’s conflict markers only show your version and their version. With <code class="language-plaintext highlighter-rouge">diff3</code>, you also see the common ancestor (the “base”). This gives both mergiraf and humans more context to resolve conflicts correctly.</p>

<div class="language-ini highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nn">[merge]</span>
    <span class="py">conflictStyle</span> <span class="p">=</span> <span class="s">diff3</span>
</code></pre></div></div>

<p>Here’s the difference. Standard conflict markers:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>&lt;&lt;&lt;&lt;&lt;&lt;&lt; HEAD
const timeout = 5000;
=======
const timeout = 10000;
&gt;&gt;&gt;&gt;&gt;&gt;&gt; feature
</code></pre></div></div>

<p>With diff3:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>&lt;&lt;&lt;&lt;&lt;&lt;&lt; HEAD
const timeout = 5000;
||||||| base
const timeout = 3000;
=======
const timeout = 10000;
&gt;&gt;&gt;&gt;&gt;&gt;&gt; feature
</code></pre></div></div>

<p>The base section tells you the original value was 3000. Now you can see that HEAD changed it to 5000 and the feature branch changed it to 10000. Without the base, you’re guessing.</p>

<p><strong>rerere</strong> (reuse recorded resolution): Rerere records how you resolve conflicts and automatically replays those resolutions if the same conflict comes up again. This is useful during rebases where you might encounter the same conflict multiple times.</p>

<div class="language-ini highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nn">[rerere]</span>
    <span class="py">enabled</span> <span class="p">=</span> <span class="s">true</span>
</code></pre></div></div>

<h2 id="layer-2-automating-the-rest">Layer 2: Automating the Rest</h2>

<p>Mergiraf handles the structural conflicts, but some conflicts are genuinely ambiguous. And some aren’t ambiguous at all, they’re just tedious. Lock files, database migrations, stacked PR duplicates. Each of these has a clear resolution strategy, but you still have to do the work manually.</p>

<p>This is drudgery. Drudgery that follows clear rules. Perfect for automation.</p>

<p>I built a <a href="https://github.com/haacked/dotfiles/tree/main/ai/skills/resolve-conflicts">Claude Code skill</a> called <code class="language-plaintext highlighter-rouge">/resolve-conflicts</code> that handles the entire conflict resolution workflow. Type <code class="language-plaintext highlighter-rouge">/resolve-conflicts</code> and it takes over.</p>

<h3 id="how-it-works">How It Works</h3>

<p>The skill follows a three-step loop:</p>

<ol>
  <li>
    <p><strong>Detect context.</strong> It reads Git’s internal state files to determine whether you’re in a rebase, merge, cherry-pick, or revert, and how far along you are (e.g., step 3 of 12 in a rebase).</p>
  </li>
  <li>
    <p><strong>Categorize and resolve.</strong> It runs a categorization script on every conflicted file, sorting them into buckets: lock file, migration, mergiraf-supported, or other. Then it resolves each bucket with the appropriate strategy.</p>
  </li>
  <li>
    <p><strong>Continue.</strong> It regenerates any lock files, runs the appropriate continue command (<code class="language-plaintext highlighter-rouge">git rebase --continue</code>, <code class="language-plaintext highlighter-rouge">git commit --no-edit</code>, etc.), and loops back to step 1 if more conflicts appear.</p>
  </li>
</ol>

<h3 id="resolution-strategies">Resolution Strategies</h3>

<p>Each category gets its own treatment:</p>

<p><strong>Lock files</strong>: Accept theirs to clear the conflict markers, then regenerate. The content of a lock file is derived from the dependency manifest, so there’s no point resolving individual lines. The skill runs the appropriate package manager (<code class="language-plaintext highlighter-rouge">npm install</code>, <code class="language-plaintext highlighter-rouge">cargo generate-lockfile</code>, <code class="language-plaintext highlighter-rouge">poetry lock --no-update</code>, etc.) to produce a correct lock file from the resolved manifest.</p>

<p><strong>Migrations</strong>: Ask the human. Migration files represent sequential schema changes where order matters. Getting this wrong can break your database. The skill flags these and asks you how to proceed.</p>

<p><strong>Mergiraf files</strong>: Run mergiraf as a second pass. Even though mergiraf already ran as a merge driver during the git operation, sometimes conflicts remain (partial resolutions, complex restructuring). The skill runs <code class="language-plaintext highlighter-rouge">mergiraf solve</code> on the file. If conflict markers remain after that, it falls through to AI analysis.</p>

<p><strong>Everything else</strong>: Read the conflict, analyze both sides, and resolve it. This is where the skill earns its keep on the genuinely tricky ones.</p>

<h3 id="stacked-pr-duplicate-detection">Stacked PR Duplicate Detection</h3>

<p>If you work with stacked PRs, you’ve probably hit this one. You have a feature branch with sub-PRs stacked on top of each other. You merge a sub-PR into main. Now when you rebase the parent branch, Git produces conflicts where both sides have nearly identical code and the base section is empty.</p>

<p>Here’s what that looks like with diff3:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>&lt;&lt;&lt;&lt;&lt;&lt;&lt; HEAD
function calculateTotal(items) {
  return items.reduce((sum, item) =&gt; sum + item.price, 0);
}
||||||| base
=======
function calculateTotal(items) {
  return items.reduce((sum, item) =&gt; sum + item.price, 0);
}
&gt;&gt;&gt;&gt;&gt;&gt;&gt; feature/add-checkout
</code></pre></div></div>

<p>Empty base. Both sides identical. This isn’t a real conflict. It’s just Git seeing that code appeared on both sides independently (once from the merged sub-PR, once from the feature branch that originally authored it).</p>

<p>The skill detects this pattern. When the base is empty but HEAD and the incoming side are more than 95% similar (after normalizing whitespace), it auto-resolves by keeping the HEAD version and tells you what it did. For 70-95% similarity, it shows both versions and asks you to confirm. Below 70%, it’s a genuine divergence and presents both options for you to decide.</p>

<h3 id="a-realistic-session">A Realistic Session</h3>

<p>Here’s what it looks like in practice. You’re rebasing and hit conflicts:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>&gt; /resolve-conflicts

Conflicts (rebase step 3/12):
- 1 lockfile: package-lock.json
- 2 mergiraf: src/app.ts, src/utils.ts
- 1 other: README.md

Resolving lockfile: package-lock.json... accepted theirs (will regenerate)
Resolving mergiraf: src/app.ts... resolved structurally
Resolving mergiraf: src/utils.ts... 1 conflict remains, analyzing...
  Auto-resolved src/utils.ts hunk at line 42: stacked PR duplicate
  (HEAD and incoming 98% similar with empty base). Kept HEAD version.
Resolving other: README.md... [presents conflict for user review]

Regenerating package-lock.json... done
Continuing rebase (step 4/12)...
</code></pre></div></div>

<p>If step 4 has conflicts, it resolves those too. All the way through step 12. You just watch it work and only chime in when it needs a human decision.</p>

<h3 id="setup-1">Setup</h3>

<p>The skill lives in my <a href="https://github.com/haacked/dotfiles/tree/main/ai/skills/resolve-conflicts">dotfiles repo</a>. If you use my dotfiles, it’s already available via symlink. Otherwise, grab the files and drop them into <code class="language-plaintext highlighter-rouge">~/.claude/skills/resolve-conflicts/</code>.</p>

<div class="markdown-alert markdown-alert-note"><p class="markdown-alert-title"><svg class="octicon octicon-info" viewBox="0 0 16 16" version="1.1" width="16" height="16" aria-hidden="true"><path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.5 7.75A.75.75 0 0 1 7.25 7h1a.75.75 0 0 1 .75.75v2.75h.25a.75.75 0 0 1 0 1.5h-2a.75.75 0 0 1 0-1.5h.25v-2h-.25a.75.75 0 0 1-.75-.75ZM8 6a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z"></path></svg> Note</p><p>The skill works best with <a href="https://mergiraf.org/">mergiraf</a> installed for the structural merging step. Without it, those files fall through to AI analysis, which still works but is less precise for structural changes.</p>
</div>

<h2 id="putting-it-together">Putting It Together</h2>

<p>The two layers complement each other:</p>

<ol>
  <li>During any git merge, rebase, or cherry-pick, mergiraf runs automatically as the merge driver. It silently resolves structural conflicts before you ever see them. You don’t have to do anything.</li>
  <li>For the conflicts that remain, <code class="language-plaintext highlighter-rouge">/resolve-conflicts</code> categorizes them, applies the right strategy for each type, and continues the operation.</li>
</ol>

<p>The result: most rebases that used to require manual intervention now complete with zero or minimal human input. The conflicts that do need your attention are the genuinely ambiguous ones that deserve it.</p>

<h2 id="try-it">Try It</h2>

<p>Both tools are open source and available in my <a href="https://github.com/haacked/dotfiles">dotfiles repo</a>. Mergiraf is available at <a href="https://mergiraf.org/">mergiraf.org</a> and installs in minutes. The resolve-conflicts skill requires <a href="https://docs.anthropic.com/en/docs/claude-code/overview">Claude Code</a>.</p>

<p>Merge conflicts are an inevitable part of collaborative development. The suffering is optional.</p>]]></content><author><name>Phil Haack</name></author><category term="git" /><category term="productivity" /><category term="open-source" /><summary type="html"><![CDATA[Merge conflicts don't have to ruin your day. I use mergiraf for structural merging and a Claude Code skill for everything else. Here's how to set it up.]]></summary></entry><entry><title type="html">One Year at PostHog</title><link href="https://haacked.com/archive/2026/01/06/one-year-at-posthog/" rel="alternate" type="text/html" title="One Year at PostHog" /><published>2026-01-06T00:00:00+00:00</published><updated>2026-01-06T00:00:00+00:00</updated><id>https://haacked.com/archive/2026/01/06/one-year-at-posthog</id><content type="html" xml:base="https://haacked.com/archive/2026/01/06/one-year-at-posthog/"><![CDATA[<p>The last thing an engineer wants to see is their GitHub avatar next to the pull request that caused an outage.</p>

<p>Yet there it was. My smiling face. On <em>that</em> PR.</p>

<p>This was my first year at PostHog, and it felt like a real test of the culture. Would they blame me? Let it quietly color opinions of my work? Fire me over a mistake?</p>

<p><img src="https://i.haacked.com/blog/2026-01-06-one-year-at-posthog/hogzilla.png" alt="Hogzilla" title="Hogzilla" /></p>

<p>Nah.</p>

<p>My colleagues were supportive and understanding. The question wasn’t “who do we blame?” but “what allowed this to happen and how do we prevent it?” This is a company that looks at failure through the lens of systems and incentives, not individual fault. It embraces the ideas of <a href="https://www.etsy.com/codeascraft/blameless-postmortems">blameless post-mortems</a>.</p>

<blockquote>
  <p>If we go with “blame” as the predominant approach, then we’re implicitly accepting that deterrence is how organizations become safer. This is founded in the belief that individuals, not situations, cause errors.</p>
</blockquote>

<p>That moment is just one of many reasons why I love working here. This is a company that backs up its principles with action.</p>

<h2 id="a-year-of-posthog">A Year of PostHog</h2>

<p>A year ago today, I <a href="https://haacked.com/archive/2025/01/07/new-year-new-job/">started at PostHog</a>, and it has been even better than I hoped. When I joined, I wrote:</p>

<blockquote>
  <p><a href="https://posthog.com/handbook">Their company handbook</a> really impressed me. What it communicates to me is that this is a remote-friendly company that values transparency, autonomy, and trust. It’s a company that treats its employees like adults and tries to minimize overhead.</p>
</blockquote>

<p>It is easy to be cynical about company handbooks and the values they claim to uphold. Enron famously promoted Integrity, Respect, Communication, and Excellence (RICE) as its values, but we all know how that turned out.</p>

<p>What has stood out to me at PostHog is that the handbook reflects reality. The company thinks deeply about how it wants to operate and is willing to adjust when things drift. At our most recent all-company off-site in Tulum, we even revisited and changed some of our values to better reflect how we actually work.</p>

<p>That kind of self-awareness is rare. It’s one big reason why I love working here. Also, Tulum.</p>

<h2 id="retirement-job">Retirement Job?</h2>

<p>When Microsoft acquired GitHub, it gave me a lot of options. I took time off. I started a YC company. That company did not work out, so I took more time off. I was not in a hurry to get back to work, but I did miss the camaraderie of building things with other people. That is what led me to interview at PostHog.</p>

<p>In my interview, one of the founders asked an interesting question. Given that it seemed like I did not <em>need</em> to work, would I be motivated to work hard at PostHog? In other words, was this a “retirement job”?</p>

<p>Of course I said I would work hard. What else could I say?</p>

<p>But here’s what I didn’t say: writing code and building product doesn’t feel like hard work to me, at least not compared to what I did before. As a Director of Engineering, so much of my energy went into bureaucracy, politics, and navigating bad incentives.</p>

<p>That was exhausting.</p>

<p>Writing code is how I unwind.</p>

<p>So in a way, yes, this <em>is</em> a retirement job. Because I love doing it.</p>

<h2 id="polyglot">Polyglot</h2>

<p>I also still have a lot to learn, and this has been a great place to do it.</p>

<p>My team builds the Feature Flags product, which spans the backend service, the frontend UI, and a large collection of SDKs. Over the past year, I have written production code in Python, TypeScript, Rust, Go, Ruby, Elixir, C#, and even PHP.</p>

<p>Yes, PHP. I held out for thirty years. The streak is over.</p>

<p>At one point, I shipped the same ETag caching feature to seven different SDKs in seven different languages in three days.</p>

<p>I am not an expert in all of these languages. But with a solid foundation in programming principles, and a lot of help from LLMs, I have been able to ramp up quickly.</p>

<p>Working across so many languages and paradigms has stretched my thinking and deepened my understanding of how software systems are built.</p>

<h2 id="team-lead">Team Lead</h2>

<p>A while back I wrote <a href="https://haacked.com/archive/2024/12/10/chutes-and-ladder/">Chutes and Ladder career path</a> about how careers do not have to follow a neat, linear ladder. True to that idea, after a year as an individual contributor, I am stepping into a team lead role.</p>

<p>This was not something I actively sought out. I genuinely love being an IC. But, as has often happened in my career, this change grew out of a real need. We are carving out a new <a href="https://posthog.com/teams/flags-platform">Flags Platform team</a> from the Feature Flags team. The good news is that a <a href="https://posthog.com/handbook/company/management">team lead role</a> at PostHog is not a traditional management role. It’s effectively an IC with a bit more responsibilities for the team such as hiring, mentoring, and planning.</p>

<p>What excites me most about this split is the chance to focus deeply on the platform itself.</p>

<p>I am excited to see what year two brings. I will be spending a lot more time in Rust, pushing our flags platform to be faster and more resilient, and working my butt off to ensure my face stays out of future root cause analysis reports.</p>]]></content><author><name>Phil Haack</name></author><category term="posthog" /><category term="work" /><category term="career" /><summary type="html"><![CDATA[Reflections on my first year at PostHog.]]></summary></entry><entry><title type="html">tree-me: Because git worktrees shouldn’t be a chore</title><link href="https://haacked.com/archive/2025/11/21/tree-me/" rel="alternate" type="text/html" title="tree-me: Because git worktrees shouldn’t be a chore" /><published>2025-11-21T00:00:00+00:00</published><updated>2025-11-21T00:00:00+00:00</updated><id>https://haacked.com/archive/2025/11/21/tree-me</id><content type="html" xml:base="https://haacked.com/archive/2025/11/21/tree-me/"><![CDATA[<p>I firmly believe that Git worktrees are one of the most underrated features of Git. I ignored them for years because I didn’t understand them or how to adapt them to my workflow.</p>

<p><img src="https://i.haacked.com/blog/2025-11-21-tree-me/git-worktrees.png" alt="Tree with multiple branches representing git worktrees" /></p>

<p>But that changed as I began using LLM coding tools to do more work in parallel. Being able to work on multiple branches simultaneously is a game changer.</p>

<p>Without git worktrees, working on multiple branches at the same time in the same repository is a pain. There’s the serial approach where you stash your changes, context switch, and pray you didn’t break anything. Or worse, just commit half-finished work with “WIP” messages (looking at you, past me). Or you can have multiple clones of the same repository, but that’s a pain to manage and can take up a lot of disk space.</p>

<p>Git worktrees solve this. They let you have multiple branches checked out simultaneously in different directories that all share the same git database (aka the .git directory). For me, this means I can work on a feature in one terminal, review a PR in another, have Claude Code work on another feature in another terminal, and have all of them share the same git history.</p>

<p>But here’s the thing: creating worktrees manually is tedious. You need to remember where you put them, what to name them, and clean them up later. Also, by default the git worktree is created in the root of the repository unless you specify a different directory.</p>

<p>I wanted something simpler. I wanted something that would work across any repository. No setup, no configuration files, just sensible defaults.</p>

<h2 id="enter-tree-me">Enter tree-me</h2>

<p>I built <a href="https://github.com/haacked/dotfiles/blob/main/bin/tree-me">tree-me</a>, a minimal wrapper around git’s native worktree commands. It adds organizational convention while letting git handle all the complexity.</p>

<p>Instead of this:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Create worktree manually</span>
<span class="nb">mkdir</span> <span class="nt">-p</span> ~/worktrees/my-project
git worktree add ~/worktrees/my-project/fix-bug <span class="nt">-b</span> haacked/fix-bug main
<span class="nb">cd</span> ~/worktrees/my-project/fix-bug
<span class="c"># Now repeat for every repo...</span>
</code></pre></div></div>

<p>You do this:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>tree-me create haacked/fix-bug
<span class="c"># Creates: ~/dev/worktrees/my-project/haacked/fix-bug</span>
<span class="c"># And automatically cds into it</span>
</code></pre></div></div>

<h2 id="how-it-works">How it works</h2>

<p>tree-me uses git-like subcommands and follows conventions so you don’t have to think:</p>

<ul>
  <li><strong>Auto-detects repository name</strong> from your git remote</li>
  <li><strong>Auto-detects default branch</strong> (checks for origin/HEAD, falls back to main)</li>
  <li><strong>Organizes by repo</strong>: <code class="language-plaintext highlighter-rouge">$WORKTREE_ROOT/&lt;repo-name&gt;/&lt;branch-name&gt;</code></li>
  <li><strong>Delegates to git</strong> for all validation, errors, and edge cases</li>
  <li><strong>PR support</strong>: Fetches GitHub PRs using git’s native PR refs (requires <code class="language-plaintext highlighter-rouge">gh</code> CLI)</li>
  <li><strong>Auto-CD</strong>: Automatically changes to the worktree directory after creation</li>
  <li><strong>Tab completion</strong>: Complete commands and branch names in bash/zsh</li>
</ul>

<p>Commands:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>tree-me create &lt;branch&gt; <span class="o">[</span>base]        <span class="c"># Create new branch in worktree</span>
tree-me checkout &lt;branch&gt;             <span class="c"># Checkout existing branch (alias: co)</span>
tree-me <span class="nb">pr</span> &lt;number|url&gt;               <span class="c"># Checkout GitHub PR (uses gh CLI)</span>
tree-me list                          <span class="c"># List all worktrees (alias: ls)</span>
tree-me remove &lt;branch&gt;               <span class="c"># Remove a worktree (alias: rm)</span>
tree-me prune                         <span class="c"># Prune stale worktree files</span>
tree-me shellenv                      <span class="c"># Output shell function for auto-cd</span>
</code></pre></div></div>

<p>Examples:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>tree-me create haacked/fix-bug              <span class="c"># Create from main/master</span>
tree-me create haacked/fix-bug develop      <span class="c"># Create from develop</span>
tree-me co existing-feature                 <span class="c"># Checkout existing branch</span>
tree-me <span class="nb">pr </span>123                              <span class="c"># Checkout PR #123</span>
tree-me <span class="nb">pr </span>https://github.com/org/repo/pull/456
tree-me <span class="nb">ls</span>                                  <span class="c"># Show all worktrees</span>
tree-me <span class="nb">rm </span>haacked/fix-bug                  <span class="c"># Clean up (supports tab completion)</span>
</code></pre></div></div>

<h2 id="conventions">Conventions</h2>

<p>tree-me is a minimal wrapper around git’s native commands. Works with any repo, any language, any setup. The only convention is where worktrees live and how they’re named.</p>

<p>Want worktrees in a different location? Set <code class="language-plaintext highlighter-rouge">WORKTREE_ROOT</code>. Need to branch from develop instead of main? Pass it as an argument: <code class="language-plaintext highlighter-rouge">tree-me create my-feature develop</code>. Conventions with escape hatches.</p>

<h2 id="setup">Setup</h2>

<p>To enable auto-cd and tab completion, add this to your <code class="language-plaintext highlighter-rouge">~/.bashrc</code> or <code class="language-plaintext highlighter-rouge">~/.zshrc</code>:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">source</span> &lt;<span class="o">(</span>tree-me shellenv<span class="o">)</span>
</code></pre></div></div>

<p>This makes <code class="language-plaintext highlighter-rouge">tree-me create</code>, <code class="language-plaintext highlighter-rouge">tree-me checkout</code>, and <code class="language-plaintext highlighter-rouge">tree-me pr</code> automatically cd into the worktree directory. It also enables tab completion for commands and branch names (try <code class="language-plaintext highlighter-rouge">tree-me rm &lt;TAB&gt;</code>).</p>

<p>View the full implementation at <a href="https://github.com/haacked/dotfiles/blob/main/bin/tree-me">github.com/haacked/dotfiles/blob/main/bin/tree-me</a>.</p>

<h2 id="the-pr-workflow">The PR workflow</h2>

<p>Here’s where it shines. Someone asks you to review a PR while you’re deep in a feature:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>tree-me <span class="nb">pr </span>123                  <span class="c"># Fetches, checks out PR, and cds into it</span>
<span class="c"># You're now in: ~/dev/worktrees/dotfiles/pr-123</span>
<span class="c"># Review the code, test it, leave comments</span>
<span class="c"># When done, switch back</span>
tree-me co haacked/my-feature   <span class="c"># Checks out and cds back to your feature</span>
<span class="c"># Back to your work, no stash needed</span>
</code></pre></div></div>

<p>When you’re done reviewing:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>tree-me <span class="nb">rm </span>pr-123               <span class="c"># Tab complete to see available branches</span>
</code></pre></div></div>

<p>Gone. Clean. No accidentally committing review changes to your feature branch.</p>

<div class="markdown-alert markdown-alert-note"><p class="markdown-alert-title"><svg class="octicon octicon-info" viewBox="0 0 16 16" version="1.1" width="16" height="16" aria-hidden="true"><path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.5 7.75A.75.75 0 0 1 7.25 7h1a.75.75 0 0 1 .75.75v2.75h.25a.75.75 0 0 1 0 1.5h-2a.75.75 0 0 1 0-1.5h.25v-2h-.25a.75.75 0 0 1-.75-.75ZM8 6a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z"></path></svg> Note</p><p>tree-me uses the <code class="language-plaintext highlighter-rouge">gh</code> CLI to fetch PRs. If you don’t have it installed, you can install it with <code class="language-plaintext highlighter-rouge">brew install gh</code>.</p>
</div>

<h2 id="installation">Installation</h2>

<p>Download <a href="https://github.com/haacked/dotfiles/blob/main/bin/tree-me">tree-me</a> and put it somewhere in your <code class="language-plaintext highlighter-rouge">PATH</code>:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Example: copy to ~/bin or ~/.local/bin</span>
curl <span class="nt">-o</span> ~/bin/tree-me https://raw.githubusercontent.com/haacked/dotfiles/main/bin/tree-me
<span class="nb">chmod</span> +x ~/bin/tree-me
</code></pre></div></div>

<p>Then enable auto-cd and tab completion (see Setup section above).</p>

<p>That’s it. No dependencies beyond git, bash, and optionally the <code class="language-plaintext highlighter-rouge">gh</code> CLI for PR checkout.</p>

<h2 id="why-i-built-this">Why I built this</h2>

<p>I work on multiple repos daily—PostHog, my blog, various open source projects. I was tired of remembering project-specific worktree scripts and hunting for that worktree I created last week.</p>

<p>The philosophy: <strong>Don’t recreate what git does well. Add only the minimal convention needed.</strong></p>

<p>Git already handles worktrees perfectly. I just needed organized paths, sensible defaults, and a consistent interface across all my projects.</p>

<h2 id="directory-structure">Directory structure</h2>

<p>Everything is organized predictably based on the repository name and branch name:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>~/dev/worktrees/&lt;repo-name&gt;/&lt;branch-name&gt;
</code></pre></div></div>

<p>For example:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>~/dev/worktrees/
├── dotfiles/
│   ├── haacked/vim-improvements/
│   ├── haacked/git-tools/
│   └── main/
├── posthog/
│   ├── haacked/feature-flags/
│   ├── pr-789-contributor/
│   └── main/
└── spelungit/
    └── haacked/performance/
</code></pre></div></div>

<p>One glance and you know where everything is.</p>

<h2 id="what-it-doesnt-do">What it doesn’t do</h2>

<p>tree-me doesn’t copy environment files, install dependencies, or set up project-specific tools. That’s deliberate. Those concerns belong in your project’s setup scripts, not in a generic git tool.</p>

<p>Want to automate environment setup? Add a script to your repo that runs after checkout. Want to copy <code class="language-plaintext highlighter-rouge">.env</code> files? Put it in your project’s onboarding docs. tree-me just handles the git worktree ceremony.</p>

<h2 id="try-it">Try it</h2>

<p>If you work with multiple branches regularly, give worktrees a try. If you work with multiple repos, give tree-me a try. If you hate it, at least you learned about git worktrees (and that’s probably worth more than the script).</p>

<p>Find tree-me at <a href="https://github.com/haacked/dotfiles/blob/main/bin/tree-me">github.com/haacked/dotfiles/blob/main/bin/tree-me</a>. It’s MIT licensed—copy it, modify it, improve it.</p>]]></content><author><name>Phil Haack</name></author><category term="git" /><category term="open-source" /><category term="productivity" /><category term="shell" /><summary type="html"><![CDATA[Working on multiple branches simultaneously? I built tree-me to manage git worktrees without the ceremony. Convention over configuration for the win.]]></summary></entry><entry><title type="html">Spelungit: When `git log –grep` isn’t enough</title><link href="https://haacked.com/archive/2025/09/29/announcing-spelungit/" rel="alternate" type="text/html" title="Spelungit: When `git log –grep` isn’t enough" /><published>2025-09-29T00:00:00+00:00</published><updated>2025-09-29T00:00:00+00:00</updated><id>https://haacked.com/archive/2025/09/29/announcing-spelungit</id><content type="html" xml:base="https://haacked.com/archive/2025/09/29/announcing-spelungit/"><![CDATA[<p>Supporting a large codebase is challenging. Sometimes, the questions you have can’t be answered by the code at hand. For example, “When did we switch from class components to hooks and what discussion led to that decision?” or “Did we used to have logging here for invalid keys?” or “Did we ever have code to handle Zstd compression?”</p>

<p>Git’s search is challenging for these sorts of queries. <code class="language-plaintext highlighter-rouge">git log --grep="auth"</code> assumes you remember exact words from commit messages. Want to find “commits where we improved error handling in API endpoints”? You’ll need multiple greps with regex gymnastics, and still miss commits that used different terminology. We need a better tool.</p>

<p><img src="https://i.haacked.com/blog/2025-09-29-announcing-spelungit/robot-spelunking-git.png" alt="Robot spelunking Git" /></p>

<h2 id="enter-spelungit">Enter Spelungit</h2>

<p>I built <a href="https://github.com/haacked/spelungit">Spelungit</a>, a semantic search engine for Git commit history. Instead of keyword roulette, you search using natural language through Claude Code’s MCP interface.</p>

<p>Want commits where you refactored authentication? Ask for “authentication flow refactoring.” Looking for race conditions in background processing? Search for “race conditions in job processing.” It understands intent instead of making you guess exact words from three months ago.</p>

<h2 id="how-it-works">How it works</h2>

<p>Instead of this:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># How do you even grep for "race condition fixes"?</span>
git log <span class="nt">--grep</span><span class="o">=</span><span class="s2">"race</span><span class="se">\|</span><span class="s2">concurrent</span><span class="se">\|</span><span class="s2">thread</span><span class="se">\|</span><span class="s2">lock</span><span class="se">\|</span><span class="s2">mutex"</span> <span class="nt">--all</span>
<span class="c"># Now manually read through 50 commits...</span>
</code></pre></div></div>

<p>You do this in your AI development tool of choice:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Search git <span class="nb">history </span><span class="k">for</span> <span class="s2">"race condition fixes in background jobs"</span>
</code></pre></div></div>

<p>Queries that work:</p>

<ul>
  <li>“When did we switch from REST to GraphQL?”</li>
  <li>“Commits where we improved error handling in API endpoints”</li>
  <li>“Show me refactoring of the authentication flow”</li>
  <li>“Security improvements in file upload handling”</li>
  <li>“Where did we fix that memory leak in the worker process?”</li>
</ul>

<p>It creates embeddings for both commit messages and code changes, which makes semantic search possible. It knows the difference between new features and bug fixes, even when commit messages are vague (looking at you, past me).</p>

<h2 id="installation">Installation</h2>

<p>Spelungit uses SQLite and local embeddings to make installation simple. Everything runs on your machine. No need for an API key, a database, or Docker.</p>

<p>One line:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="nt">-sSL</span> https://raw.githubusercontent.com/haacked/spelungit/main/install-remote.sh | bash
</code></pre></div></div>

<p>Downloads everything, sets up Python venv, installs dependencies, configures Claude Code. Suspicious of pipe-to-bash? Clone the repo and run <code class="language-plaintext highlighter-rouge">./install.sh</code>.</p>

<h2 id="using-it">Using it</h2>

<p>Shows up in Claude Code (or your AI tool of choice) as an MCP server. The first time you ask a question in a repository that <code class="language-plaintext highlighter-rouge">spelungit</code> responds to, spelungit will index the repository. Once it’s done, it’ll be able to answer questions.For large repos, this can take a few minutes while it analyzes commits and creates embeddings.</p>

<p>Then search naturally:</p>

<ul>
  <li>“Show me commits about error handling improvements”</li>
  <li>“Find where we refactored the authentication”</li>
  <li>“What changed in the API layer recently?”</li>
</ul>

<p>Claude calls the appropriate MCP tools (<code class="language-plaintext highlighter-rouge">index_repository</code>, <code class="language-plaintext highlighter-rouge">search_commits</code>, <code class="language-plaintext highlighter-rouge">repository_status</code>, <code class="language-plaintext highlighter-rouge">get_database_info</code>) behind the scenes.</p>

<h2 id="under-the-hood">Under the hood</h2>

<p>Analyzes commit messages and code changes. For each commit:</p>

<ul>
  <li>Semantic content from messages and diffs</li>
  <li>Code patterns like function names and classes</li>
  <li>File types and directory structure</li>
  <li>Feature vs. bug fix vs. refactoring</li>
</ul>

<p>Uses Microsoft’s <code class="language-plaintext highlighter-rouge">all-MiniLM-L6-v2</code> (384 dimensions) with local sentence-transformers to create embeddings. Embeddings are stored in SQLite. Searching for git history uses cosine similarity searches.</p>

<p>The sqlite database only stores commit SHAs and embeddings, leaving the full commit message in git. Thousands of commits = few megabytes. Handles Git worktrees if you’re into that masochism.</p>

<h2 id="why-i-built-this">Why I built this</h2>

<p>I work across multiple repositories and constantly need to understand why changes were made. Traditional Git tools are limited. Too much detective work scrolling through commits.</p>

<p>I wanted to ask Git history questions in plain English and get the right commits back. Semantic search of Git history is something I always wished GitHub would add. I got tired of waiting so I built this.</p>

<p>Also, I’ve been experimenting with MCP servers and wanted to build something useful instead of another todo app.</p>

<h2 id="future-ideas-for-improvement">Future ideas for improvement</h2>

<p><strong>Configurable models</strong>: Local embeddings work pretty well, but OpenAI’s models would be better for teams willing to trade privacy for better accuracy.
<strong>PostgreSQL support</strong>: For massive repos or shared team search.
<strong>Smarter code understanding</strong>: Better refactoring vs. feature detection, architectural changes over time.</p>

<h2 id="try-it">Try it</h2>

<p><a href="https://github.com/haacked/spelungit">Spelungit</a> is MIT licensed. The project scratched a real itch for me. If it doesn’t work perfectly, that’s what GitHub issues are for.</p>

<p>Find it at <a href="https://github.com/haacked/spelungit">https://github.com/haacked/spelungit</a>.</p>]]></content><author><name>Phil Haack</name></author><category term="git" /><category term="open-source" /><category term="mcp" /><category term="claude" /><category term="ai" /><summary type="html"><![CDATA[Ever tried to remember why you made a change six months ago? I built Spelungit so you can search Git commit history with natural language instead of praying to the regex gods.]]></summary></entry><entry><title type="html">Cleaning up gone branches</title><link href="https://haacked.com/archive/2025/04/17/git-gone/" rel="alternate" type="text/html" title="Cleaning up gone branches" /><published>2025-04-17T00:00:00+00:00</published><updated>2025-04-17T00:00:00+00:00</updated><id>https://haacked.com/archive/2025/04/17/git-gone</id><content type="html" xml:base="https://haacked.com/archive/2025/04/17/git-gone/"><![CDATA[<p>A long time ago, I wrote a <a href="https://haacked.com/archive/2014/07/28/github-flow-aliases/">useful set of git aliases</a> to support the GitHub flow. My favorite alias was <code class="language-plaintext highlighter-rouge">bdone</code> which would:</p>

<ol>
  <li>Checkout the default branch.</li>
  <li>Run <code class="language-plaintext highlighter-rouge">git up</code> to make sure you’re up to date.</li>
  <li>Run <code class="language-plaintext highlighter-rouge">git bclean</code> to delete all the branches that have been merged into the default branch.</li>
</ol>

<p>And this worked great for a long time. The way it worked was it would list all the branches that have been merged into the default branch and then delete them. In my case, I didn’t use <code class="language-plaintext highlighter-rouge">git branch --merged</code> to list merged branches because I didn’t know about it at the time.</p>

<p>However, my aliases stopped working for me recently after joining PostHog. The main reason is on pretty much all of their repositories, they use Squash and Merge when merging PRs.</p>

<p><img src="https://i.haacked.com/blog/2025-04-17-git-gone/crushed-cars.png" alt="Image of a set of cars being squashed together" /></p>

<p>When you use <code class="language-plaintext highlighter-rouge">git merge --squash</code> or GitHub’s “Squash and merge” feature, Git creates a new commit on the target branch that combines all the changes from the source branch into a single commit. This new commit doesn’t retain any reference to the original commits from the source branch. As a result, Git doesn’t consider the source branch as merged, and commands like <code class="language-plaintext highlighter-rouge">git branch --merged</code> won’t show it.</p>

<p>But here’s the thing about Git. There’s almost always a way.</p>

<h2 id="solution">Solution</h2>

<p>When you merge a PR on GitHub, it shows you a “Delete branch” button:</p>

<p><img src="https://i.haacked.com/blog/2025-04-17-git-gone/delete-repo.png" alt="image of a delete branch button" /></p>

<p>This is a great feature. It’s a good way to clean up branches that have been merged into the default branch. In fact, you can configure GitHub to “Automatically delete head branches” when merged:</p>

<p><img src="https://i.haacked.com/blog/2025-04-17-git-gone/automatically-delete.png" alt="image showing configuring a repository to automatically delete head branches" /></p>

<p>I highly recommend you do the same. When the remote branch is deleted, Git will track it as “gone”. For example, if you run <code class="language-plaintext highlighter-rouge">git branch -vv</code> you’ll see something like this:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">&gt;</span> git branch <span class="nt">-vv</span>

  haacked/decide-v4      ba39408 <span class="o">[</span>origin/haacked/decide-v4: gone] Fix demo to handle variants
  haacked/fix-sample-app ec15751 <span class="o">[</span>origin/haacked/fix-sample-app] Handle variants
<span class="k">*</span> haacked/local-only     e78d2f6 Do important stuff
  main                   ab885d0 <span class="o">[</span>origin/main] chore: Bump to v1.0.2
</code></pre></div></div>

<p>Notice that <code class="language-plaintext highlighter-rouge">haacked/decide-v4</code> is marked as <code class="language-plaintext highlighter-rouge">gone</code>.</p>

<p>We can use git’s porcelain to list branches and their tracking information in a more easily parseable format.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">&gt;</span> git <span class="k">for</span><span class="nt">-each-ref</span> <span class="nt">--format</span><span class="o">=</span><span class="s1">'%(refname:short) %(upstream:track)'</span> refs/heads/

haacked/decide-v4 <span class="o">[</span>gone]
haacked/fix-sample-app
haacked/local-only
master
</code></pre></div></div>

<p>Let’s make this an alias to list these gone branches:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">&gt;</span> git config <span class="nt">--global</span> alias.gone <span class="s2">"!git for-each-ref --format='%(refname:short) %(upstream:track)' refs/heads/ | awk '</span><span class="se">\$</span><span class="s2">2 == </span><span class="se">\"</span><span class="s2">[gone]</span><span class="se">\"</span><span class="s2"> { print </span><span class="se">\$</span><span class="s2">1 }'"</span>
</code></pre></div></div>

<p>Now we can use the alias to list gone branches:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">&gt;</span> git gone

haacked/decide-v4
</code></pre></div></div>

<p>Next step is to update my old <code class="language-plaintext highlighter-rouge">bclean</code> alias to use the new <code class="language-plaintext highlighter-rouge">gone</code> alias.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">&gt;</span> git config <span class="nt">--global</span> alias.bclean <span class="s2">"!git gone | xargs -r git branch -D"</span>
</code></pre></div></div>

<p>Unfortunately, since git doesn’t know it’s been merged, we have to do a force delete. That’s a bit scary, but this won’t touch local branches or any branches that are still tracking a remote branch. With this alias, you can run <code class="language-plaintext highlighter-rouge">git bclean</code> to delete all the branches that have been merged into the default branch. Finally, we have the old <code class="language-plaintext highlighter-rouge">bdone</code> alias to switch to the default branch, run <code class="language-plaintext highlighter-rouge">git up</code>, and then run <code class="language-plaintext highlighter-rouge">bclean</code>.</p>

<p>Here’s the complete set of aliases mentioned in this post you can cut and paste into your <code class="language-plaintext highlighter-rouge">.gitconfig</code>:</p>

<div class="language-ini highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nn">[alias]</span>
    <span class="py">default</span> <span class="p">=</span> <span class="s">"!git symbolic-ref refs/remotes/origin/HEAD | sed 's@^refs/remotes/origin/@@'"</span>
    <span class="py">bclean</span> <span class="p">=</span> <span class="s">"!git gone | xargs -r git branch -D"</span>
    <span class="c"># Switches to specified branch (or the default branch if no branch is specified), runs git up, then runs bclean.
</span>    <span class="py">bdone</span> <span class="p">=</span> <span class="s">"!f() { DEFAULT=$(git default); git checkout ${1-$DEFAULT} &amp;&amp; git up &amp;&amp; git bclean; }; f"</span>
    <span class="py">gone</span> <span class="p">=</span> <span class="s">"!git for-each-ref --format='%(refname:short) %(upstream:track)' refs/heads/ | awk '$2 == </span><span class="se">\"</span><span class="s">[gone]</span><span class="se">\"</span><span class="s"> { print $1 }'"</span>
</code></pre></div></div>

<p>Or, if you want to use the <code class="language-plaintext highlighter-rouge">git</code> command line, you can use the following:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git config <span class="nt">--global</span> alias.default <span class="s2">"!git symbolic-ref refs/remotes/origin/HEAD | sed 's@^refs/remotes/origin/@@'"</span>
git config <span class="nt">--global</span> alias.gone <span class="s2">"!git for-each-ref --format='%(refname:short) %(upstream:track)' refs/heads/ | awk '</span><span class="se">\$</span><span class="s2">2 == </span><span class="se">\"</span><span class="s2">[gone]</span><span class="se">\"</span><span class="s2"> { print </span><span class="se">\$</span><span class="s2">1 }'"</span>
git config <span class="nt">--global</span> alias.bclean <span class="s2">"!git gone | xargs -r git branch -D"</span>
git config <span class="nt">--global</span> alias.bdone <span class="s2">"!f() { DEFAULT=</span><span class="se">\$</span><span class="s2">(git default); git checkout </span><span class="se">\$</span><span class="s2">{1:-</span><span class="se">\$</span><span class="s2">DEFAULT} &amp;&amp; git up &amp;&amp; git bclean; }; f"</span>
</code></pre></div></div>

<div class="markdown-alert markdown-alert-note"><p class="markdown-alert-title"><svg class="octicon octicon-info" viewBox="0 0 16 16" version="1.1" width="16" height="16" aria-hidden="true"><path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.5 7.75A.75.75 0 0 1 7.25 7h1a.75.75 0 0 1 .75.75v2.75h.25a.75.75 0 0 1 0 1.5h-2a.75.75 0 0 1 0-1.5h.25v-2h-.25a.75.75 0 0 1-.75-.75ZM8 6a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z"></path></svg> Note</p><p>When using the <code class="language-plaintext highlighter-rouge">git default</code> alias, it’s possible you’ll encounter the following error:</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>fatal: ref refs/remotes/origin/HEAD is not a symbolic ref
</code></pre></div>

</div>


<p>This alias relies on the presence of the origin/HEAD symbolic reference. In some cases, especially with newly cloned repositories or certain configurations, this reference might not be set. To fix it:</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git remote set-head origin <span class="nt">--auto</span>
</code></pre></div>

</div>


</div>

<p>So now, my old workflow is back. When I merge a PR, I can run <code class="language-plaintext highlighter-rouge">git bdone</code> from the branch I merged to clean up the branches. All is right with the world again.</p>

<p>And as always, you can find these aliases and more in my <a href="https://github.com/haacked/dotfiles">dotfiles repo</a>. See <a href="https://haacked.com/archive/2019/02/14/including-git-aliases/">this blog post</a> for more context on how you can include them in your dotfiles.</p>]]></content><author><name>Phil Haack</name></author><category term="git" /><summary type="html"><![CDATA[A git alias to clean up gone branches. Even ones that have been squashed and merged.]]></summary></entry><entry><title type="html">Using PostHog in your .NET applications</title><link href="https://haacked.com/archive/2025/02/25/posthog-dotnet-1.0/" rel="alternate" type="text/html" title="Using PostHog in your .NET applications" /><published>2025-02-25T00:00:00+00:00</published><updated>2025-02-25T00:00:00+00:00</updated><id>https://haacked.com/archive/2025/02/25/posthog-dotnet-1.0</id><content type="html" xml:base="https://haacked.com/archive/2025/02/25/posthog-dotnet-1.0/"><![CDATA[<p>PostHog helps you build better products. It tracks what users do. It controls features in production. And now it works with .NET!</p>

<p><a href="https://haacked.com/archive/2025/01/07/new-year-new-job/">I joined PostHog at the beginning of the year</a> as a Product Engineer on <a href="https://posthog.com/teams/feature-flags">the Feature Flags team</a>. Feature flags are just one of the many tools PostHog offers to help product engineers build better products.</p>

<p>Much of my job will consist of writing Python and React with TypeScript. But when I started, I noticed they didn’t have a .NET SDK. It turns out, I know a thing or two about .NET!</p>

<p><img src="https://github.com/user-attachments/assets/b7c64431-0232-4707-b016-c0161ea6b0eb" alt="Hedgehog giving two thumbs up in front of a computer showing the .NET logo" title="Who has two thumbs and loves .NET?" /></p>

<p>So if you’ve been wanting to use PostHog in your ASP.NET Core applications, yesterday is your lucky day! The 1.0 version of the PostHog .NET SDK for ASP.NET Core is <a href="https://www.nuget.org/packages/PostHog.AspNetCore">available on NuGet</a>.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>dotnet add package PostHog.AspNetCore
</code></pre></div></div>

<p>You can find documentation for the library on <a href="https://posthog.com/docs/libraries/dotnet">the PostHog docs site</a>, but I’ll cover some of the basics here. I’ll also cover non-ASP.NET Core usage later in this post.</p>

<h2 id="configuration">Configuration</h2>

<p>To configure the client SDK, you’ll need:</p>

<ol>
  <li>Project API Key - <em>from the <a href="https://us.posthog.com/settings/project">PostHog dashboard</a></em></li>
  <li>Personal API Key - <em>for local evaluation (Optional, but recommended)</em></li>
</ol>

<div class="markdown-alert markdown-alert-note"><p class="markdown-alert-title"><svg class="octicon octicon-info" viewBox="0 0 16 16" version="1.1" width="16" height="16" aria-hidden="true"><path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.5 7.75A.75.75 0 0 1 7.25 7h1a.75.75 0 0 1 .75.75v2.75h.25a.75.75 0 0 1 0 1.5h-2a.75.75 0 0 1 0-1.5h.25v-2h-.25a.75.75 0 0 1-.75-.75ZM8 6a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z"></path></svg> Note</p><p>For better performance, enable local feature flag evaluation by adding a personal API key (found in Settings). This avoids making API calls for each flag check.</p>
</div>

<p>By default, the PostHog client looks for settings in the <code class="language-plaintext highlighter-rouge">PostHog</code> section of the configuration system such as in the <code class="language-plaintext highlighter-rouge">appSettings.json</code> file:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"PostHog"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"ProjectApiKey"</span><span class="p">:</span><span class="w"> </span><span class="s2">"phc_..."</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>Treat your personal API key as a secret by using a secrets manager to store it. For example, for local development, use the <code class="language-plaintext highlighter-rouge">dotnet user-secrets</code> command to store your personal API key:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>dotnet user-secrets init
dotnet user-secrets <span class="nb">set</span> <span class="s2">"PostHog:PersonalApiKey"</span> <span class="s2">"phx_..."</span>
</code></pre></div></div>

<p>In production, you might use Azure Key Vault or a similar service to provide the personal API key.</p>

<h2 id="register-the-client">Register the client</h2>

<p>Once you set up configuration, register the client with the dependency injection container.</p>

<p>In your <code class="language-plaintext highlighter-rouge">Program.cs</code> file, call the <code class="language-plaintext highlighter-rouge">AddPostHog</code> extension method on the <code class="language-plaintext highlighter-rouge">WebApplicationBuilder</code> instance. It’ll look something like this:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">using</span> <span class="nn">PostHog</span><span class="p">;</span>

<span class="kt">var</span> <span class="n">builder</span> <span class="p">=</span> <span class="n">WebApplication</span><span class="p">.</span><span class="nf">CreateBuilder</span><span class="p">(</span><span class="n">args</span><span class="p">);</span>

<span class="n">builder</span><span class="p">.</span><span class="nf">AddPostHog</span><span class="p">();</span>
</code></pre></div></div>

<p>Calling <code class="language-plaintext highlighter-rouge">builder.AddPostHog()</code> adds a singleton implementation of <code class="language-plaintext highlighter-rouge">IPostHogClient</code> to the dependency injection container. Inject it into your controllers or pages like so:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">public</span> <span class="k">class</span> <span class="nc">MyController</span><span class="p">(</span><span class="n">IPostHogClient</span> <span class="n">posthog</span><span class="p">)</span> <span class="p">:</span> <span class="n">Controller</span>
<span class="p">{</span>
<span class="p">}</span>

<span class="k">public</span> <span class="k">class</span> <span class="nc">MyPage</span><span class="p">(</span><span class="n">IPostHogClient</span> <span class="n">posthog</span><span class="p">)</span> <span class="p">:</span> <span class="n">PageModel</span>
<span class="p">{</span>
<span class="p">}</span>
</code></pre></div></div>

<h2 id="usage">Usage</h2>

<p>Use the <code class="language-plaintext highlighter-rouge">IPostHogClient</code> service to identify users, capture analytics, and evaluate feature flags.</p>

<p>Use the <code class="language-plaintext highlighter-rouge">IdentifyAsync</code> method to identify users:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// This stores information about the user in PostHog.</span>
<span class="k">await</span> <span class="n">posthog</span><span class="p">.</span><span class="nf">IdentifyAsync</span><span class="p">(</span>
    <span class="n">distinctId</span><span class="p">,</span>
    <span class="n">user</span><span class="p">.</span><span class="n">Email</span><span class="p">,</span>
    <span class="n">user</span><span class="p">.</span><span class="n">UserName</span><span class="p">,</span>
    <span class="c1">// Properties to set on the person. If they're already</span>
    <span class="c1">// set, they will be overwritten.</span>
    <span class="n">personPropertiesToSet</span><span class="p">:</span> <span class="k">new</span><span class="p">()</span>
    <span class="p">{</span>
        <span class="p">[</span><span class="s">"phone"</span><span class="p">]</span> <span class="p">=</span> <span class="n">user</span><span class="p">.</span><span class="n">PhoneNumber</span> <span class="p">??</span> <span class="s">"unknown"</span><span class="p">,</span>
        <span class="p">[</span><span class="s">"email_confirmed"</span><span class="p">]</span> <span class="p">=</span> <span class="n">user</span><span class="p">.</span><span class="n">EmailConfirmed</span><span class="p">,</span>
    <span class="p">},</span>
    <span class="c1">// Properties to set once. If they're already set</span>
    <span class="c1">// on the person, they won't be overwritten.</span>
    <span class="n">personPropertiesToSetOnce</span><span class="p">:</span> <span class="k">new</span><span class="p">()</span>
    <span class="p">{</span>
        <span class="p">[</span><span class="s">"joined"</span><span class="p">]</span> <span class="p">=</span> <span class="n">DateTime</span><span class="p">.</span><span class="n">UtcNow</span> 
    <span class="p">});</span>
</code></pre></div></div>
<p>Some things to note about the <code class="language-plaintext highlighter-rouge">IdentifyAsync</code> method:</p>

<ul>
  <li>The <code class="language-plaintext highlighter-rouge">distinctId</code> is the identifier for the user. This could be an email, a username, or some other identifier such as the database Id. The important thing is that it’s a consistent and unique identifier for the user. If you use PostHog on the client, use the same <code class="language-plaintext highlighter-rouge">distinctId</code> here as you do on the client.</li>
  <li>The <code class="language-plaintext highlighter-rouge">personPropertiesToSet</code> and <code class="language-plaintext highlighter-rouge">personPropertiesToSetOnce</code> are optional. You can use them to set properties about the user.</li>
  <li>If you choose a <code class="language-plaintext highlighter-rouge">distinctId</code> that can change (such as username or email), you can use the <code class="language-plaintext highlighter-rouge">AliasAsync</code> method to alias the old <code class="language-plaintext highlighter-rouge">distinctId</code> with the new one so that the user can be tracked across different <code class="language-plaintext highlighter-rouge">distinctIds</code>.</li>
</ul>

<p>To capture an event, call the <code class="language-plaintext highlighter-rouge">Capture</code> method:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">posthog</span><span class="p">.</span><span class="nf">Capture</span><span class="p">(</span><span class="s">"some-distinct-id"</span><span class="p">,</span> <span class="s">"my-event"</span><span class="p">);</span>
</code></pre></div></div>

<p>This will capture an event with the distinct id, the event name, and the current timestamp. You can also include properties:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">posthog</span><span class="p">.</span><span class="nf">Capture</span><span class="p">(</span>
    <span class="s">"some-distinct-id"</span><span class="p">,</span>
    <span class="s">"user signed up"</span><span class="p">,</span>
    <span class="k">new</span><span class="p">()</span> <span class="p">{</span> <span class="p">[</span><span class="s">"plan"</span><span class="p">]</span> <span class="p">=</span> <span class="s">"pro"</span> <span class="p">});</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">Capture</code> method is synchronous and returns immediately. The actual batching and sending of events is done in the background.</p>

<h2 id="feature-flags">Feature flags</h2>

<p>To evaluate a feature flag, call the <code class="language-plaintext highlighter-rouge">IsFeatureEnabledAsync</code> method:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">if</span> <span class="p">(</span><span class="k">await</span> <span class="n">posthog</span><span class="p">.</span><span class="nf">IsFeatureEnabledAsync</span><span class="p">(</span>
    <span class="s">"new_user_feature"</span><span class="p">,</span>
    <span class="s">"some-distinct-id"</span><span class="p">))</span> <span class="p">{</span>
    <span class="c1">// The feature flag is enabled.</span>
<span class="p">}</span>
</code></pre></div></div>

<p>This will evaluate the feature flag and return <code class="language-plaintext highlighter-rouge">true</code> if the feature flag is enabled. If the feature flag is not enabled or not found, it will return <code class="language-plaintext highlighter-rouge">false</code>.</p>

<p>Feature Flags can contain filter conditions that might depend on properties of the user. For example, you might have a feature flag that is enabled for users on the pro plan.</p>

<p>If you’ve previously identified the user and are NOT using local evaluation, the feature flag is evaluated on the server against the user properties set on the person via the <code class="language-plaintext highlighter-rouge">IdentifyAsync</code> method.</p>

<p>But if you’re using local evaluation, the feature flag is evaluated on the client, so you have to pass in the properties of the user:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">await</span> <span class="n">posthog</span><span class="p">.</span><span class="nf">IsFeatureEnabledAsync</span><span class="p">(</span>
    <span class="n">featureKey</span><span class="p">:</span> <span class="s">"person-flag"</span><span class="p">,</span>
    <span class="n">distinctId</span><span class="p">:</span> <span class="s">"some-distinct-id"</span><span class="p">,</span>
    <span class="n">personProperties</span><span class="p">:</span> <span class="k">new</span><span class="p">()</span> <span class="p">{</span> <span class="p">[</span><span class="s">"plan"</span><span class="p">]</span> <span class="p">=</span> <span class="s">"pro"</span> <span class="p">});</span>
</code></pre></div></div>

<p>This will evaluate the feature flag and return <code class="language-plaintext highlighter-rouge">true</code> if the feature flag is enabled and the user’s plan is “pro”.</p>

<h2 id="net-feature-management">.NET Feature Management</h2>

<p><a href="https://learn.microsoft.com/en-us/azure/azure-app-configuration/feature-management-dotnet-reference">.NET Feature Management</a> is an abstraction over feature flags that is supported by ASP.NET Core. With it enabled, you can use the <code class="language-plaintext highlighter-rouge">&lt;feature /&gt;</code> tag helper to conditionally render UI based on the state of a feature flag.</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">&lt;</span><span class="n">feature</span> <span class="n">name</span><span class="p">=</span><span class="s">"my-feature"</span><span class="p">&gt;</span>
    <span class="p">&lt;</span><span class="n">p</span><span class="p">&gt;</span><span class="n">This</span> <span class="k">is</span> <span class="n">a</span> <span class="n">feature</span> <span class="n">flag</span><span class="p">.&lt;/</span><span class="n">p</span><span class="p">&gt;</span>
<span class="p">&lt;/</span><span class="n">feature</span><span class="p">&gt;</span>
</code></pre></div></div>

<p>You can also use the <code class="language-plaintext highlighter-rouge">FeatureGateAttribute</code> in your controllers and pages to conditionally execute code based on the state of a feature flag.</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">[</span><span class="nf">FeatureGate</span><span class="p">(</span><span class="s">"my-feature"</span><span class="p">)]</span>
<span class="k">public</span> <span class="k">class</span> <span class="nc">MyController</span> <span class="p">:</span> <span class="n">Controller</span>
<span class="p">{</span>
<span class="p">}</span>
</code></pre></div></div>

<p>If your app already uses .NET Feature Management, you can switch to using PostHog with very little effort.</p>

<p>To use PostHog feature flags with the .NET Feature Management library, implement the <code class="language-plaintext highlighter-rouge">IPostHogFeatureFlagContextProvider</code> interface. The simplest way to do that is to inherit from the <code class="language-plaintext highlighter-rouge">PostHogFeatureFlagContextProvider</code> class and override the <code class="language-plaintext highlighter-rouge">GetDistinctId</code> and <code class="language-plaintext highlighter-rouge">GetFeatureFlagOptionsAsync</code> methods. This is required so that .NET Feature Management can evaluate feature flags locally with the correct <code class="language-plaintext highlighter-rouge">distinctId</code> and <code class="language-plaintext highlighter-rouge">personProperties</code>.</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">public</span> <span class="k">class</span> <span class="nc">MyFeatureFlagContextProvider</span><span class="p">(</span>
    <span class="n">IHttpContextAccessor</span> <span class="n">httpContextAccessor</span><span class="p">)</span>
    <span class="p">:</span> <span class="n">PostHogFeatureFlagContextProvider</span>
<span class="p">{</span>
    <span class="k">protected</span> <span class="k">override</span> <span class="kt">string</span><span class="p">?</span> <span class="nf">GetDistinctId</span><span class="p">()</span> <span class="p">=&gt;</span>
       <span class="n">httpContextAccessor</span><span class="p">.</span><span class="n">HttpContext</span><span class="p">?.</span><span class="n">User</span><span class="p">.</span><span class="n">Identity</span><span class="p">?.</span><span class="n">Name</span><span class="p">;</span>
    
    <span class="k">protected</span> <span class="k">override</span> <span class="n">ValueTask</span><span class="p">&lt;</span><span class="n">FeatureFlagOptions</span><span class="p">&gt;</span> <span class="nf">GetFeatureFlagOptionsAsync</span><span class="p">()</span>
    <span class="p">{</span>
        <span class="c1">// In a real app, you might get this information from a database or other source for the current user.</span>
        <span class="k">return</span> <span class="n">ValueTask</span><span class="p">.</span><span class="nf">FromResult</span><span class="p">(</span>
            <span class="k">new</span> <span class="n">FeatureFlagOptions</span>
            <span class="p">{</span>
                <span class="n">PersonProperties</span> <span class="p">=</span> <span class="k">new</span> <span class="n">Dictionary</span><span class="p">&lt;</span><span class="kt">string</span><span class="p">,</span> <span class="kt">object</span><span class="p">?&gt;</span>
                <span class="p">{</span>
                    <span class="p">[</span><span class="s">"email"</span><span class="p">]</span> <span class="p">=</span> <span class="s">"some-test@example.com"</span><span class="p">,</span>
                    <span class="p">[</span><span class="s">"plan"</span><span class="p">]</span> <span class="p">=</span> <span class="s">"pro"</span>
                <span class="p">},</span>
                <span class="n">OnlyEvaluateLocally</span> <span class="p">=</span> <span class="k">true</span>
            <span class="p">});</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Then, register your implementation in <code class="language-plaintext highlighter-rouge">Program.cs</code> (or <code class="language-plaintext highlighter-rouge">Startup.cs</code>):</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">using</span> <span class="nn">PostHog</span><span class="p">;</span>

<span class="kt">var</span> <span class="n">builder</span> <span class="p">=</span> <span class="n">WebApplication</span><span class="p">.</span><span class="nf">CreateBuilder</span><span class="p">(</span><span class="n">args</span><span class="p">);</span>

<span class="n">builder</span><span class="p">.</span><span class="nf">AddPostHog</span><span class="p">(</span><span class="n">options</span> <span class="p">=&gt;</span> <span class="p">{</span>
    <span class="n">options</span><span class="p">.</span><span class="n">UseFeatureManagement</span><span class="p">&lt;</span><span class="n">MyFeatureFlagContextProvider</span><span class="p">&gt;();</span>
<span class="p">});</span>
</code></pre></div></div>

<p>This registers a feature flag provider that uses your implementation of <code class="language-plaintext highlighter-rouge">IPostHogFeatureFlagContextProvider</code> to evaluate feature flags against PostHog.</p>

<h2 id="non-aspnet-core-usage">Non-ASP.NET Core usage</h2>

<p>The <code class="language-plaintext highlighter-rouge">PostHog.AspNetCore</code> package adds ASP.NET Core specific functionality on top of the core <code class="language-plaintext highlighter-rouge">PostHog</code> package. But if you’re not using ASP.NET Core, you can use the core <code class="language-plaintext highlighter-rouge">PostHog</code> package directly:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>dotnet add package PostHog.AspNetCore
</code></pre></div></div>

<p>And then register it with your dependency injection container:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">builder</span><span class="p">.</span><span class="n">Services</span><span class="p">.</span><span class="nf">AddPostHog</span><span class="p">();</span>
</code></pre></div></div>

<p>If you’re not using dependency injection, you can still use the registration method:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">using</span> <span class="nn">PostHog</span><span class="p">;</span>
<span class="kt">var</span> <span class="n">services</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">ServiceCollection</span><span class="p">();</span>
<span class="n">services</span><span class="p">.</span><span class="nf">AddPostHog</span><span class="p">();</span>
<span class="kt">var</span> <span class="n">serviceProvider</span> <span class="p">=</span> <span class="n">services</span><span class="p">.</span><span class="nf">BuildServiceProvider</span><span class="p">();</span>
<span class="kt">var</span> <span class="n">posthog</span> <span class="p">=</span> <span class="n">serviceProvider</span><span class="p">.</span><span class="n">GetRequiredService</span><span class="p">&lt;</span><span class="n">IPostHogClient</span><span class="p">&gt;();</span>
</code></pre></div></div>

<p>For a console app (or apps not using dependency injection), you can also use the <code class="language-plaintext highlighter-rouge">PostHogClient</code> directly, just make sure it’s a singleton:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">using</span> <span class="nn">System</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">PostHog</span><span class="p">;</span>

<span class="kt">var</span> <span class="n">posthog</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">PostHogClient</span><span class="p">(</span>
  <span class="n">Environment</span><span class="p">.</span><span class="nf">GetEnvironmentVariable</span><span class="p">(</span><span class="s">"PostHog__PersonalApiKey"</span><span class="p">));</span>
</code></pre></div></div>

<h2 id="examples">Examples</h2>

<p>To see all this in action, the <a href="https://github.com/posthog/posthog-dotnet"><code class="language-plaintext highlighter-rouge">posthog-dotnet</code> GitHub repository</a> has a <a href="https://github.com/PostHog/posthog-dotnet/tree/main/samples">samples directory</a> with a growing number of example projects. For example, the <a href="https://github.com/PostHog/posthog-dotnet/tree/main/samples/HogTied.Web">HogTied.Web</a> project is an ASP.NET Core web app that uses PostHog for analytics and feature flags and shows some advanced configuration.</p>

<h2 id="whats-next">What’s next?</h2>

<p>With this release done, I’ll be focusing my attention on the Feature Flags product. Even so, I’ll continue to maintain the SDK and fix any reported bugs.</p>

<p>If anyone reports bugs, I’ll be sure to fix them. But I won’t be adding any new features for the moment.</p>

<p>Down the road, I’m hoping to add a <code class="language-plaintext highlighter-rouge">PostHog.Unity</code> package. I just don’t have a lot of experience with Unity yet. My game development experience mostly consists of getting shot in the face by squeaky voiced kids playing Fortnite. I’m hoping someone will contribute a Unity sample project to the repo which I can use as a starting point.</p>

<p>If you have any feedback, questions, or issues with the PostHog .NET SDK, please reach file an issue at https://github.com/PostHog/posthog-dotnet.</p>]]></content><author><name>Phil Haack</name></author><category term="career" /><category term="work" /><summary type="html"><![CDATA[PostHog is a product analytics platform that helps you build better products faster. It contains a set of tools to help you capture analytics and leverage feature flags.]]></summary></entry><entry><title type="html">New Year, New Job</title><link href="https://haacked.com/archive/2025/01/07/new-year-new-job/" rel="alternate" type="text/html" title="New Year, New Job" /><published>2025-01-07T00:00:00+00:00</published><updated>2025-01-07T00:00:00+00:00</updated><id>https://haacked.com/archive/2025/01/07/new-year-new-job</id><content type="html" xml:base="https://haacked.com/archive/2025/01/07/new-year-new-job/"><![CDATA[<p>Last year I wrote a post, <a href="/archive/2024/12/10/chutes-and-ladder">career chutes and ladders</a>, where I proposed that a linear climb to the C-suite is not the only approach to a satisfying career. At the end of the post, I mentioned I was stepping off the ladder to take on an IC role.</p>

<p><img src="https://github.com/user-attachments/assets/0a825e78-c3e5-4aa4-ac1f-13ba597da4a5" alt="Hedge hog typing on a keyboard" title="Might be easier to code if my arms were longer" /></p>

<p>After over a year of being on a personally funded sabbatical, I started a new job at <a href="https://posthog.com">PostHog</a> as a Senior Product Engineer. This week is my orientation where I get to <a href="https://haacked.com/archive/2007/10/26/drinking-from-the-firehose.aspx/">drink from the firehose</a> once again.</p>

<h2 id="what-is-posthog">What is PostHog?</h2>

<p>Apart from being a company that seems to really love cute hedgehogs, PostHog is an open-source product analytics platform. They have a set of tools to help product engineers build better products. Each product can be used as a standalone tool, but they’re designed to level-up when you put them together.</p>

<p>In particular, I’ve started on the <a href="https://posthog.com/feature-flags">Feature Flags team</a>. Yesterday was my first day of onboarding and so far I really like my team.</p>

<p>Today is day two and I’ve already submitted <a href="https://github.com/PostHog/posthog/pull/27340">a small fix for my first pull request</a>!</p>

<h2 id="why-posthog">Why PostHog?</h2>

<p>When I was looking around at companies, an <a href="https://github.com/phanatic">old buddy from GitHub</a> who worked at PostHog reached out to me and suggested I take a look at this company. He said it reminded him of the good parts of working at GitHub.</p>

<p><a href="https://posthog.com/handbook">Their company handbook</a> really impressed me. What it communicates to me is that this is a remote-friendly company that values transparency, autonomy, and trust. It’s a company that treats its employees like adults and tries to minimize overhead.</p>

<p>Not only that, they’ve embraced a lot of employee-friendly practices. For example, a while back my friend Zach wrote about his <a href="https://zachholman.com/posts/fuck-your-90-day-exercise-window/">distaste for the 90 day exercise window</a>. PostHog <a href="https://posthog.com/handbook/people/compensation">provides a 10-year window</a>. Not only that, they offer employes double trigger acceleration!</p>

<div class="markdown-alert markdown-alert-note"><p class="markdown-alert-title"><svg class="octicon octicon-info" viewBox="0 0 16 16" version="1.1" width="16" height="16" aria-hidden="true"><path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.5 7.75A.75.75 0 0 1 7.25 7h1a.75.75 0 0 1 .75.75v2.75h.25a.75.75 0 0 1 0 1.5h-2a.75.75 0 0 1 0-1.5h.25v-2h-.25a.75.75 0 0 1-.75-.75ZM8 6a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z"></path></svg> Note</p><p>Double trigger acceleration means if you are let go or forced to leave due to the company being acquired, you receive all of your options at that time</p>
</div>

<p>This is a perk usually only offerered to executives.</p>

<p><em>I should <a href="https://posthog.com/careers">mention we’re hiring</a>! Please mention me if you apply. If we’ve worked together, let me know so I can provide feedback internally.</em></p>

<p>I’m excited to be part of a company that’s small, but growing. The company is at a stage similar to the stage GitHub was at when I joined. This is a team with a strong product engineering culture and I’m excited to contribute what I can and learn from them.</p>

<h2 id="the-challenge">The Challenge</h2>

<p>The other part that’s exciting for me is that I’ll be working in a stack that I don’t have a huge amount of experience with. The front-end is React with TypeScript and the back-end is Django with Python. I’ve done a bit of work in all these technologies except Django. However, I believe my experience with ASP.NET MVC will help me pick up Django quickly.</p>

<p>Not to mention, I’ve always taken the stance that I’m a software engineer, not just a .NET developer. Don’t get me wrong, I love working in .NET. But at the same time, I think it’s healthy for me to get production experience in other stacks. It’ll be an area of personal growth. Not to mention, they don’t quite have a .NET Client SDK yet so once I get settled in, that’s something I’m interested in getting started on.</p>

<h2 id="the-future">The Future</h2>

<p>I’ll share more about my experience here as I get settled in. In the meanwhile, wish me luck!</p>]]></content><author><name>Phil Haack</name></author><category term="career" /><category term="work" /><summary type="html"><![CDATA[Taking the plunge into a new job at PostHog.]]></summary></entry><entry><title type="html">Deserializing JSON to a string or a value</title><link href="https://haacked.com/archive/2024/12/17/string-or-value/" rel="alternate" type="text/html" title="Deserializing JSON to a string or a value" /><published>2024-12-17T00:00:00+00:00</published><updated>2024-12-17T00:00:00+00:00</updated><id>https://haacked.com/archive/2024/12/17/string-or-value</id><content type="html" xml:base="https://haacked.com/archive/2024/12/17/string-or-value/"><![CDATA[<p>I love using <a href="https://github.com/reactiveui/refit">Refit</a> to call web APIs in a nice type-safe manner. Sometimes though, APIs don’t want to cooperate with your strongly-typed hopes. For example, you might run into an API written by a hipster in a beanie, aka a dynamic-type enthusiast. I don’t say that pejoratively. Some of my closest friends write Python and Ruby.</p>

<p>For example, I came across an API that returned a value like this:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"important"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>No problem, I defined a class like this to deserialize it to:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">public</span> <span class="k">class</span> <span class="nc">ImportantResponse</span>
<span class="p">{</span>
    <span class="k">public</span> <span class="kt">bool</span> <span class="n">Important</span> <span class="p">{</span> <span class="k">get</span><span class="p">;</span> <span class="k">set</span><span class="p">;</span> <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>And life was good. Until that awful day that the API returned this:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"important"</span><span class="p">:</span><span class="w"> </span><span class="s2">"What is important is subjective to the viewer."</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>Damn! This philosophy lesson broke my client. One workaround is to do this:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">public</span> <span class="k">class</span> <span class="nc">ImportantResponse</span>
<span class="p">{</span>
    <span class="k">public</span> <span class="n">JsonElement</span> <span class="n">Important</span> <span class="p">{</span> <span class="k">get</span><span class="p">;</span> <span class="k">set</span><span class="p">;</span> <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>It works, but it’s not great. It doesn’t communicate to the consumer that this value can only be a <code class="language-plaintext highlighter-rouge">string</code> or a <code class="language-plaintext highlighter-rouge">bool</code>. That’s when I remembered an old blog post from my past.</p>

<p><img src="https://github.com/user-attachments/assets/15a13230-644f-4e5e-827e-c4c35051c77e" alt="A ball of string on the left, &quot;or&quot; in the middle, a present on the right" title="Is it one or is it the other?" /></p>

<h2 id="april-fools-joke-to-the-rescue">April Fool’s Joke to the Rescue</h2>

<p>When I was the Program Manager (PM) for ASP.NET MVC, my colleague and lead developer, Eilon, wrote a blog post entitled <a href="https://asp-blogs.azurewebsites.net/leftslipper/the-string-or-the-cat-a-new-net-framework-library">“The String or the Cat: A New .NET Framework Library</a> where he introduced the class <code class="language-plaintext highlighter-rouge">StringOr&lt;TOther&gt;</code>. This class could represent a dual-state value that’s either a string or another type.</p>

<blockquote>
  <p>The concepts presented here are based on a thought experiment proposed by scientist Erwin Schrödinger. While an understanding of quantum physics will help to understand the new types and APIs, it is not required.</p>
</blockquote>

<p>It turned out his blog post was an April Fool’s joke. But the idea stuck with me. And now, here’s a case where I need a real implementation of it. But I’m going to name mine, <code class="language-plaintext highlighter-rouge">StringOrValue&lt;T&gt;</code>.</p>

<h2 id="a-modern-stringorvaluet">A modern StringOrValue&lt;T&gt;</h2>

<p>One nice thing about implementing this today is we can leverage modern C# features. Here’s the starting implementation:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">[</span><span class="nf">JsonConverter</span><span class="p">(</span><span class="k">typeof</span><span class="p">(</span><span class="n">StringOrValueConverter</span><span class="p">))]</span>
<span class="k">public</span> <span class="k">readonly</span> <span class="k">struct</span> <span class="nc">StringOrValue</span><span class="p">&lt;</span><span class="n">T</span><span class="p">&gt;</span> <span class="p">:</span> <span class="n">IStringOrObject</span> <span class="p">{</span>
    <span class="k">public</span> <span class="nf">StringOrValue</span><span class="p">(</span><span class="kt">string</span> <span class="n">stringValue</span><span class="p">)</span> <span class="p">{</span>
        <span class="n">StringValue</span> <span class="p">=</span> <span class="n">stringValue</span><span class="p">;</span>
        <span class="n">IsString</span> <span class="p">=</span> <span class="k">true</span><span class="p">;</span>
    <span class="p">}</span>

    <span class="k">public</span> <span class="nf">StringOrValue</span><span class="p">(</span><span class="n">T</span> <span class="k">value</span><span class="p">)</span> <span class="p">{</span>
        <span class="n">Value</span> <span class="p">=</span> <span class="k">value</span><span class="p">;</span>
        <span class="n">IsValue</span> <span class="p">=</span> <span class="k">true</span><span class="p">;</span>
    <span class="p">}</span>

    <span class="k">public</span> <span class="n">T</span><span class="p">?</span> <span class="n">Value</span> <span class="p">{</span> <span class="k">get</span><span class="p">;</span> <span class="p">}</span>
    <span class="k">public</span> <span class="kt">string</span><span class="p">?</span> <span class="n">StringValue</span> <span class="p">{</span> <span class="k">get</span><span class="p">;</span> <span class="p">}</span>

    <span class="p">[</span><span class="nf">MemberNotNullWhen</span><span class="p">(</span><span class="k">true</span><span class="p">,</span> <span class="k">nameof</span><span class="p">(</span><span class="n">StringValue</span><span class="p">))]</span>
    <span class="k">public</span> <span class="kt">bool</span> <span class="n">IsString</span> <span class="p">{</span> <span class="k">get</span><span class="p">;</span> <span class="p">}</span>

    <span class="p">[</span><span class="nf">MemberNotNullWhen</span><span class="p">(</span><span class="k">true</span><span class="p">,</span> <span class="k">nameof</span><span class="p">(</span><span class="n">Value</span><span class="p">))]</span>
    <span class="k">public</span> <span class="kt">bool</span> <span class="n">IsValue</span> <span class="p">{</span> <span class="k">get</span><span class="p">;</span> <span class="p">}</span>
<span class="p">}</span>

<span class="c1">/// &lt;summary&gt;</span>
<span class="c1">/// Internal interface for &lt;see cref="StringOrValue{T}"/&gt;.</span>
<span class="c1">/// &lt;/summary&gt;</span>
<span class="c1">/// &lt;remarks&gt;</span>
<span class="c1">/// This is here to make serialization and deserialization easy.</span>
<span class="c1">/// &lt;/remarks&gt;</span>
<span class="p">[</span><span class="nf">JsonConverter</span><span class="p">(</span><span class="k">typeof</span><span class="p">(</span><span class="n">StringOrValueConverter</span><span class="p">))]</span>
<span class="k">internal</span> <span class="k">interface</span> <span class="nc">IStringOrObject</span>
<span class="p">{</span>
    <span class="kt">bool</span> <span class="n">IsString</span> <span class="p">{</span> <span class="k">get</span><span class="p">;</span> <span class="p">}</span>

    <span class="kt">bool</span> <span class="n">IsValue</span> <span class="p">{</span> <span class="k">get</span><span class="p">;</span> <span class="p">}</span>

    <span class="kt">string</span><span class="p">?</span> <span class="n">StringValue</span> <span class="p">{</span> <span class="k">get</span><span class="p">;</span> <span class="p">}</span>

    <span class="kt">object</span><span class="p">?</span> <span class="n">ObjectValue</span> <span class="p">{</span> <span class="k">get</span><span class="p">;</span> <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>We can use the <code class="language-plaintext highlighter-rouge">MemberNotNullWhen</code> attribute to tell the compiler that when <code class="language-plaintext highlighter-rouge">IsString</code> is true, <code class="language-plaintext highlighter-rouge">StringValue</code> is not null. And when <code class="language-plaintext highlighter-rouge">IsValue</code> is true, <code class="language-plaintext highlighter-rouge">Value</code> is not null. That way, code like this compiles just fine without raising null warnings:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">var</span> <span class="k">value</span> <span class="p">=</span> <span class="k">new</span> <span class="n">StringOrValue</span><span class="p">&lt;</span><span class="kt">string</span><span class="p">&gt;(</span><span class="s">"Hello"</span><span class="p">);</span>
<span class="k">if</span> <span class="p">(</span><span class="k">value</span><span class="p">.</span><span class="n">IsString</span><span class="p">)</span> <span class="p">{</span>
    <span class="n">Console</span><span class="p">.</span><span class="nf">WriteLine</span><span class="p">(</span><span class="k">value</span><span class="p">.</span><span class="n">StringValue</span><span class="p">.</span><span class="n">Length</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<p>and</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">var</span> <span class="k">value</span> <span class="p">=</span> <span class="k">new</span> <span class="n">StringOrValue</span><span class="p">&lt;</span><span class="n">SomeType</span><span class="p">&gt;(</span><span class="m">42</span><span class="p">);</span>
<span class="k">if</span> <span class="p">(</span><span class="k">value</span><span class="p">.</span><span class="n">IsValue</span><span class="p">)</span> <span class="p">{</span>
    <span class="n">Console</span><span class="p">.</span><span class="nf">WriteLine</span><span class="p">(</span><span class="k">value</span><span class="p">.</span><span class="nf">ToString</span><span class="p">());</span>
<span class="p">}</span>
</code></pre></div></div>

<p>It also is decorated with the <code class="language-plaintext highlighter-rouge">JsonConverter</code> attribute to tell the JSON serializer to use the <code class="language-plaintext highlighter-rouge">StringOrValueConverter</code> class to serialize and deserialize this type. I wanted this type to Just Work™. I didn’t want consumers of this class have to bother with registering a <code class="language-plaintext highlighter-rouge">JsonConverterFactory</code> for this type.</p>

<p>This also explains why I introduced the internal <code class="language-plaintext highlighter-rouge">IStringOrObject</code> interface. We can’t implement the <code class="language-plaintext highlighter-rouge">JsonConverter</code> attribute on a open generic type, so we need a non-generic interface to apply the attribute to. It also makes it easier to write the converter as you’ll see.</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">/// &lt;summary&gt;</span>
<span class="c1">/// Value converter for &lt;see cref="StringOrValue{T}"/&gt;.</span>
<span class="c1">/// &lt;/summary&gt;</span>
<span class="k">internal</span> <span class="k">class</span> <span class="nc">StringOrValueConverter</span> <span class="p">:</span> <span class="n">JsonConverter</span><span class="p">&lt;</span><span class="n">IStringOrObject</span><span class="p">&gt;</span>
<span class="p">{</span>
    <span class="k">public</span> <span class="k">override</span> <span class="kt">bool</span> <span class="nf">CanConvert</span><span class="p">(</span><span class="n">Type</span> <span class="n">typeToConvert</span><span class="p">)</span>
        <span class="p">=&gt;</span> <span class="n">typeToConvert</span><span class="p">.</span><span class="n">IsGenericType</span>
           <span class="p">&amp;&amp;</span> <span class="n">typeToConvert</span><span class="p">.</span><span class="nf">GetGenericTypeDefinition</span><span class="p">()</span> <span class="p">==</span> <span class="k">typeof</span><span class="p">(</span><span class="n">StringOrValue</span><span class="p">&lt;&gt;);</span>

    <span class="k">public</span> <span class="k">override</span> <span class="n">IStringOrObject</span> <span class="nf">Read</span><span class="p">(</span><span class="k">ref</span> <span class="n">Utf8JsonReader</span> <span class="n">reader</span><span class="p">,</span> <span class="n">Type</span> <span class="n">typeToConvert</span><span class="p">,</span> <span class="n">JsonSerializerOptions</span> <span class="n">options</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="kt">var</span> <span class="n">targetType</span> <span class="p">=</span> <span class="n">typeToConvert</span><span class="p">.</span><span class="nf">GetGenericArguments</span><span class="p">()[</span><span class="m">0</span><span class="p">];</span>

        <span class="k">if</span> <span class="p">(</span><span class="n">reader</span><span class="p">.</span><span class="n">TokenType</span> <span class="p">==</span> <span class="n">JsonTokenType</span><span class="p">.</span><span class="n">String</span><span class="p">)</span>
        <span class="p">{</span>
            <span class="kt">var</span> <span class="n">stringValue</span> <span class="p">=</span> <span class="n">reader</span><span class="p">.</span><span class="nf">GetString</span><span class="p">();</span>
            <span class="k">return</span> <span class="n">stringValue</span> <span class="k">is</span> <span class="k">null</span>
                <span class="p">?</span> <span class="nf">CreateEmptyInstance</span><span class="p">(</span><span class="n">targetType</span><span class="p">)</span>
                <span class="p">:</span> <span class="nf">CreateStringInstance</span><span class="p">(</span><span class="n">targetType</span><span class="p">,</span> <span class="n">stringValue</span><span class="p">);</span>
        <span class="p">}</span>

        <span class="kt">var</span> <span class="k">value</span> <span class="p">=</span> <span class="n">JsonSerializer</span><span class="p">.</span><span class="nf">Deserialize</span><span class="p">(</span><span class="k">ref</span> <span class="n">reader</span><span class="p">,</span> <span class="n">targetType</span><span class="p">,</span> <span class="n">options</span><span class="p">);</span>

        <span class="k">return</span> <span class="k">value</span> <span class="k">is</span> <span class="k">null</span>
            <span class="p">?</span> <span class="nf">CreateEmptyInstance</span><span class="p">(</span><span class="n">targetType</span><span class="p">)</span>
            <span class="p">:</span> <span class="nf">CreateValueInstance</span><span class="p">(</span><span class="n">targetType</span><span class="p">,</span> <span class="k">value</span><span class="p">);</span>
    <span class="p">}</span>

    <span class="k">static</span> <span class="n">ConstructorInfo</span> <span class="nf">GetEmptyConstructor</span><span class="p">(</span><span class="n">Type</span> <span class="n">targetType</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="k">return</span> <span class="k">typeof</span><span class="p">(</span><span class="n">StringOrValue</span><span class="p">&lt;&gt;)</span>
                   <span class="p">.</span><span class="nf">MakeGenericType</span><span class="p">(</span><span class="n">targetType</span><span class="p">).</span>
                   <span class="nf">GetConstructor</span><span class="p">([])</span>
               <span class="p">??</span> <span class="k">throw</span> <span class="k">new</span> <span class="nf">InvalidOperationException</span><span class="p">(</span><span class="s">$"No constructor found for StringOrValue&lt;</span><span class="p">{</span><span class="n">targetType</span><span class="p">.</span><span class="n">Name</span><span class="p">}</span><span class="s">&gt;."</span><span class="p">);</span>
    <span class="p">}</span>

    <span class="k">static</span> <span class="n">ConstructorInfo</span> <span class="nf">GetConstructor</span><span class="p">(</span><span class="n">Type</span> <span class="n">targetType</span><span class="p">,</span> <span class="n">Type</span> <span class="n">argumentType</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="k">return</span> <span class="k">typeof</span><span class="p">(</span><span class="n">StringOrValue</span><span class="p">&lt;&gt;)</span>
            <span class="p">.</span><span class="nf">MakeGenericType</span><span class="p">(</span><span class="n">targetType</span><span class="p">).</span>
            <span class="nf">GetConstructor</span><span class="p">([</span><span class="n">argumentType</span><span class="p">])</span>
            <span class="p">??</span> <span class="k">throw</span> <span class="k">new</span> <span class="nf">InvalidOperationException</span><span class="p">(</span><span class="s">$"No constructor found for StringOrValue&lt;</span><span class="p">{</span><span class="n">targetType</span><span class="p">.</span><span class="n">Name</span><span class="p">}</span><span class="s">&gt;."</span><span class="p">);</span>
    <span class="p">}</span>

    <span class="k">static</span> <span class="n">IStringOrObject</span> <span class="nf">CreateEmptyInstance</span><span class="p">(</span><span class="n">Type</span> <span class="n">targetType</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="kt">var</span> <span class="n">ctor</span> <span class="p">=</span> <span class="nf">GetEmptyConstructor</span><span class="p">(</span><span class="n">targetType</span><span class="p">);</span>
        <span class="k">return</span> <span class="p">(</span><span class="n">IStringOrObject</span><span class="p">)</span><span class="n">ctor</span><span class="p">.</span><span class="nf">Invoke</span><span class="p">([]);</span>
    <span class="p">}</span>

    <span class="k">static</span> <span class="n">IStringOrObject</span> <span class="nf">CreateStringInstance</span><span class="p">(</span><span class="n">Type</span> <span class="n">targetType</span><span class="p">,</span> <span class="kt">string</span> <span class="k">value</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="kt">var</span> <span class="n">ctor</span> <span class="p">=</span> <span class="nf">GetConstructor</span><span class="p">(</span><span class="n">targetType</span><span class="p">,</span> <span class="k">typeof</span><span class="p">(</span><span class="kt">string</span><span class="p">));</span>
        <span class="k">return</span> <span class="p">(</span><span class="n">IStringOrObject</span><span class="p">)</span><span class="n">ctor</span><span class="p">.</span><span class="nf">Invoke</span><span class="p">([</span><span class="k">value</span><span class="p">]);</span>
    <span class="p">}</span>

    <span class="k">static</span> <span class="n">IStringOrObject</span> <span class="nf">CreateValueInstance</span><span class="p">(</span><span class="n">Type</span> <span class="n">targetType</span><span class="p">,</span> <span class="kt">object</span> <span class="k">value</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="kt">var</span> <span class="n">ctor</span> <span class="p">=</span> <span class="nf">GetConstructor</span><span class="p">(</span><span class="n">targetType</span><span class="p">,</span> <span class="n">targetType</span><span class="p">);</span>
        <span class="k">return</span> <span class="p">(</span><span class="n">IStringOrObject</span><span class="p">)</span><span class="n">ctor</span><span class="p">.</span><span class="nf">Invoke</span><span class="p">([</span><span class="k">value</span><span class="p">]);</span>
    <span class="p">}</span>

    <span class="k">public</span> <span class="k">override</span> <span class="k">void</span> <span class="nf">Write</span><span class="p">(</span><span class="n">Utf8JsonWriter</span> <span class="n">writer</span><span class="p">,</span> <span class="n">IStringOrObject</span> <span class="k">value</span><span class="p">,</span> <span class="n">JsonSerializerOptions</span> <span class="n">options</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="k">if</span> <span class="p">(</span><span class="k">value</span><span class="p">.</span><span class="n">IsString</span><span class="p">)</span>
        <span class="p">{</span>
            <span class="n">writer</span><span class="p">.</span><span class="nf">WriteStringValue</span><span class="p">(</span><span class="k">value</span><span class="p">.</span><span class="n">StringValue</span><span class="p">);</span>
        <span class="p">}</span>
        <span class="k">else</span> <span class="k">if</span> <span class="p">(</span><span class="k">value</span><span class="p">.</span><span class="n">IsValue</span><span class="p">)</span>
        <span class="p">{</span>
            <span class="n">JsonSerializer</span><span class="p">.</span><span class="nf">Serialize</span><span class="p">(</span><span class="n">writer</span><span class="p">,</span> <span class="k">value</span><span class="p">.</span><span class="n">ObjectValue</span><span class="p">,</span> <span class="n">options</span><span class="p">);</span>
        <span class="p">}</span>
        <span class="k">else</span>
        <span class="p">{</span>
            <span class="n">writer</span><span class="p">.</span><span class="nf">WriteNullValue</span><span class="p">();</span>
        <span class="p">}</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>In the actual implementation of <code class="language-plaintext highlighter-rouge">StringOrValue&lt;T&gt;</code>, I implemented IEquatable&lt;T&gt;, IEquatable&lt;StringOrValue&lt;T&gt;&gt; and overrode the implicit operators:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">public</span> <span class="k">static</span> <span class="k">implicit</span> <span class="k">operator</span> <span class="n">StringOrValue</span><span class="p">&lt;</span><span class="n">T</span><span class="p">&gt;(</span><span class="kt">string</span> <span class="n">stringValue</span><span class="p">)</span> <span class="p">=&gt;</span> <span class="k">new</span><span class="p">(</span><span class="n">stringValue</span><span class="p">);</span>
<span class="k">public</span> <span class="k">static</span> <span class="k">implicit</span> <span class="k">operator</span> <span class="n">StringOrValue</span><span class="p">&lt;</span><span class="n">T</span><span class="p">&gt;(</span><span class="n">T</span> <span class="k">value</span><span class="p">)</span> <span class="p">=&gt;</span> <span class="k">new</span><span class="p">(</span><span class="k">value</span><span class="p">);</span>
</code></pre></div></div>

<p>This allows you to write code like this:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">StringOrValue</span><span class="p">&lt;</span><span class="kt">int</span><span class="p">&gt;</span> <span class="n">valueAsString</span> <span class="p">=</span> <span class="s">"Hello"</span><span class="p">;</span>
<span class="n">StringOrValue</span><span class="p">&lt;</span><span class="kt">int</span><span class="p">&gt;</span> <span class="n">valueAsNumber</span> <span class="p">=</span> <span class="m">42</span><span class="p">;</span>

<span class="n">Assert</span><span class="p">.</span><span class="nf">Equals</span><span class="p">(</span><span class="s">"Hello"</span><span class="p">,</span> <span class="n">valueAsString</span><span class="p">);</span>
<span class="n">Assert</span><span class="p">.</span><span class="nf">Equals</span><span class="p">(</span><span class="m">42</span><span class="p">,</span> <span class="n">valueAsNumber</span><span class="p">);</span>
</code></pre></div></div>

<p>So with this implementation in place, I can go back to the original example and write this:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">public</span> <span class="k">class</span> <span class="nc">ImportantResponse</span>
<span class="p">{</span>
    <span class="k">public</span> <span class="n">StringOrValue</span><span class="p">&lt;</span><span class="kt">bool</span><span class="p">&gt;</span> <span class="n">Important</span> <span class="p">{</span> <span class="k">get</span><span class="p">;</span> <span class="k">set</span><span class="p">;</span> <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>And now I can handle both cases:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">var</span> <span class="n">response</span> <span class="p">=</span> <span class="n">JsonSerializer</span><span class="p">.</span><span class="n">Deserialize</span><span class="p">&lt;</span><span class="n">ImportantResponse</span><span class="p">&gt;(</span><span class="n">json</span><span class="p">)</span>
    <span class="p">??</span> <span class="k">throw</span> <span class="k">new</span> <span class="nf">InvalidOperationException</span><span class="p">(</span><span class="s">"Deserialization failed."</span><span class="p">);</span>

<span class="k">if</span> <span class="p">(</span><span class="n">response</span><span class="p">.</span><span class="n">Important</span><span class="p">.</span><span class="n">IsValue</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">if</span> <span class="p">(</span><span class="n">response</span><span class="p">.</span><span class="n">Important</span><span class="p">.</span><span class="n">Value</span><span class="p">)</span> <span class="p">{</span>
        <span class="n">Console</span><span class="p">.</span><span class="nf">WriteLine</span><span class="p">(</span><span class="s">"It's important!"</span><span class="p">);</span>
    <span class="p">}</span>
    <span class="k">else</span> <span class="p">{</span>
        <span class="n">Console</span><span class="p">.</span><span class="nf">WriteLine</span><span class="p">(</span><span class="s">"It's not important."</span><span class="p">);</span>
    <span class="p">}</span>
<span class="p">}</span>
<span class="k">else</span> <span class="p">{</span>
    <span class="n">Console</span><span class="p">.</span><span class="nf">WriteLine</span><span class="p">(</span><span class="n">response</span><span class="p">.</span><span class="n">Important</span><span class="p">.</span><span class="n">StringValue</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<p>It’s time to go shopping for a beanie!</p>

<p>Here’s <a href="https://gist.github.com/haacked/2fd1f8f0818c27184f2d08704f6f06f6">the full implementation</a> for those interested in using this in your own projects!</p>

<script src="https://gist.github.com/haacked/2fd1f8f0818c27184f2d08704f6f06f6.js"></script>]]></content><author><name>Phil Haack</name></author><category term="csharp json" /><summary type="html"><![CDATA[You may want to deserialize JSON to strongly typed values, but sometimes you run into a situation where the API doesn't comply, until now.]]></summary></entry><entry><title type="html">Career Chutes and Ladder</title><link href="https://haacked.com/archive/2024/12/10/chutes-and-ladder/" rel="alternate" type="text/html" title="Career Chutes and Ladder" /><published>2024-12-10T00:00:00+00:00</published><updated>2024-12-10T00:00:00+00:00</updated><id>https://haacked.com/archive/2024/12/10/chutes-and-ladder</id><content type="html" xml:base="https://haacked.com/archive/2024/12/10/chutes-and-ladder/"><![CDATA[<p>The career ladder is a comforting fiction we’re sold as we embark on our careers: Start as Junior, climb to Senior, then Principal, Director, and VP. One day, you defeat the final boss and receive a key to the executive bathroom and and join the C-suite. You’ve made it!</p>

<p>If you’re lucky, your company supports a tall Individual Contributor (IC) ladder. But often, the next rung swaps your hoodie for a power shirt, suspenders, and management duties.</p>

<p><img src="https://github.com/user-attachments/assets/71945726-4bc8-494f-b076-22f0518f24d4" alt="Lumbergh from office space" title="Yeah, I'm going to need you to come in on Saturday" /></p>

<p>My first job followed this script: developer to team lead to Senior Manager. I was hustling up that ladder.</p>

<p>But life isn’t linear. Careers are more like Chutes and Ladders (which the Brits know as Snakes and Ladders, but that messes up my metaphor). Lucky breaks shoot you up; surprises send you sliding down. Those chutes? Not failures. Opportunities.</p>

<p><img src="https://github.com/user-attachments/assets/3b66f137-fd6b-4c3c-aedd-0c46701657fb" alt="The game of chutes and ladders" /></p>

<p>Take my journey. I took a chute from my first job back to an IC role at my second. Climbed back up into management. Took a chute to Microsoft as a Senior Program Manager. Then came another chute: GitHub.</p>

<p>I started as a developer, became a manager, then Director of Engineering. After that, I co-founded a startup, took the CTO title, but spent most of my time writing code.</p>

<p>It looked like the classic ladder climb, but in startups, titles are smoke and mirrors. So it only counted as a step up if we succeeded. <a href="https://haacked.com/archive/2023/11/13/failure/">Spoiler: we didn’t.</a></p>

<h2 id="ladders-are-overrated">Ladders Are Overrated</h2>

<p>Ladders are narrow and rigid. Titles—while shiny—hide what matters: the work. They’re bumper stickers for your career. Nice for signaling, but they don’t tell the whole story.</p>

<p>As Director, I mentored teams and focused on broad initiatives. But over time, I started to miss the tech. The longer I stayed out of writing production code, the more disconnected I felt. As a CTO, I got back to coding and loved it. When the startup failed, I promised myself at least a year off to reflect before jumping into something new.</p>

<h2 id="growth-on-the-slide">Growth on the Slide</h2>

<p>Over a year later, I’ve been thinking about my next move. It turns out nobody will pay me to be a man of leisure.</p>

<p>Conventional wisdom (and ego) says aim higher: VP of Software Development or CTO at an established company.</p>

<p>The irony of big titles is they mean more power at work, but less power over your time.</p>

<p>For me, my recent life circumstances make time autonomy among my top priorities. Sure, big titles pay well, but maximizing income isn’t my goal.</p>

<p>Here’s what I realized: By my own definition, I’ve succeeded. I’ve been part of great teams, built great products, and helped grow companies. I have nothing left to prove. I don’t need a lofty title or a giant paycheck (but I wouldn’t turn one down either).</p>

<p>So, once again, I’m setting aside my pride and stepping off the ladder. Next year on January 6th I start a new IC role. Details to come, but I’m thrilled to be back in the trenches, building and learning. It’s a place that treats its employees like adults and gives me the autonomy to structure my day as I see fit.</p>

<p>This isn’t about rejecting leadership. It’s about recalibrating. Leadership is broad. It’s guiding organizations or leading by example. I think it’s healthy—even advantageous—to bounce between IC and management roles over the course of a career. Life changes. I might return to management someday. Or not. The point is to stay open to what fits now.</p>

<h2 id="your-move">Your Move</h2>

<p>If you’re staring at a chute, wondering if stepping off the ladder will hurt you, consider this: maybe it’s not a setback. Maybe it’s a shortcut to what you really want.</p>

<p>Careers aren’t about perfect titles. They’re about collecting experiences, relationships, and skills that shape you. Sometimes, the most important moves don’t look like progress—until you’re somewhere unexpected, doing work that matters.</p>

<p>Spin the dial. Take the slide. Even in Chutes and Ladders, the winner isn’t who climbs highest. It’s who enjoys the game. At least, that’s what I told my friends when they beat me.</p>]]></content><author><name>Phil Haack</name></author><category term="career" /><summary type="html"><![CDATA[Sometimes stepping off the career ladder is the best move you can make.]]></summary></entry></feed>