1<?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-08-19T09:06:39+00:00</updated><id>https://haacked.com/atom.xml</id><title type="html">Youâve Been Haacked</title><subtitle>You've been Haacked is a blog about Technology, Software, Management, and Open Source. It's full of good stuff. 2</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> 3 4<p>There was one snag: signed commits.</p> 5 6<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> 7 8<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> 9 10<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> 11 12<h2 id="why-secretive-instead-of-a-key-file">Why Secretive Instead of a Key File?</h2> 13 14<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> 15 16<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> 17 18<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> 19 20<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> 21</div> 22 23<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> 24 25<h2 id="the-setup">The Setup</h2> 26 27<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> 28 29<div class="language-ini highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nn">[user]</span> 30 <span class="py">localSigningKey</span> <span class="p">=</span> <span class="s">~/Library/Containers/com.maxgoedjen.Secretive.SecretAgent/Data/PublicKeys/<hash>.pub</span> 31<span class="nn">[gpg]</span> 32 <span class="py">format</span> <span class="p">=</span> <span class="s">ssh</span> 33<span class="nn">[gpg "ssh"]</span> 34 <span class="py">program</span> <span class="p">=</span> <span class="s">/usr/bin/ssh-keygen</span> 35 <span class="py">allowedSignersFile</span> <span class="p">=</span> <span class="s">~/.dotfiles/git/allowed_signers</span> 36<span class="nn">[commit]</span> 37 <span class="py">gpgsign</span> <span class="p">=</span> <span class="s">true</span> 38</code></pre></div></div> 39 40<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> 41 42<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[email protected] ecdsa-sha2-nistp256 AAAA⦠[email protected] 43[email protected] ecdsa-sha2-nistp256 AAAA⦠[email protected] 44[email protected] ecdsa-sha2-nistp256 AAAA⦠[email protected] 45⦠46</code></pre></div></div> 47 48<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> 49 50<div class="markdown-alert markdown-alert-note"><p class="markdown-alert-title">
50<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> 51</div> 52 53<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> 54 55<h2 id="the-problem-with-being-remote">The Problem with Being Remote</h2> 56 57<p>When I remote into the main Mac, there are two ways in, and both break signing in their own way.</p> 58 59<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> 60 61<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> 62 63<p>But naive agent forwarding has three gaps:</p> 64 65<ol> 66 <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> 67 <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> 68 <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
68key baked into git config is wrong half the time.</li> 69</ol> 70 71<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> 72 73<h2 id="one-symlink-zero-cached-state">One Symlink, Zero Cached State</h2> 74 75<p>The whole system comes down to two decisions:</p> 76 77<ol> 78 <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> 79 <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> 80</ol> 81 82<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> 83 84<h3 id="the-symlink-healer">The Symlink Healer</h3> 85 86<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 it heals: back to the local Secretive socket when the current target is dead, or back out to a forwarded socket it remembers adopting, once that socket answers again:</p> 87 88<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> 89<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> 90<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> 91 92<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">&&</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> 93 <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> 94<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">&&</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 95 </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> 96<span class="k">fi 97 98if</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">&&</span> <span class="o">
98[[</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 99 </span><span class="nb">echo</span> <span class="s2">"local"</span> 100<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 101 </span><span class="nb">echo</span> <span class="s2">"forwarded"</span> 102<span class="k">else 103 </span><span class="nb">echo</span> <span class="s2">"none"</span> 104<span class="k">fi</span> 105</code></pre></div></div> 106 107<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> 108 109<p>It also remembers. Adopting a forwarded socket records a receipt (<code class="language-plaintext highlighter-rouge">~/.ssh/agent.fwd</code>), and when the symlink has healed back to local â say the client laptop slept and a probe timed out â a no-arg run re-adopts the receiptâs socket once it answers again. Only an interactive SSH login ever hands the script a socket, so the receipt is proof I chose that agent; nothing gets adopted by scanning or guessing. A receipt whose socket refuses the connection is deleted (each new session mints a fresh socket and pushes it), and <code class="language-plaintext highlighter-rouge">ssh-agent-sync --local</code> clears it when I want the machine to stay local.</p> 110 111<h3 id="the-shell-hook">The Shell Hook</h3> 112 113<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> 114 115<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> 116<span class="c"># sync overwrites SSH_AUTH_SOCK with the symlink path.</span> 117<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">&&</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> 118 119_ssh_agent_sync<span class="o">()</span> <span class="o">{</span> 120 <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">></span>/dev/null 121 <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">&&</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> 122<span class="o">}</span> 123_ssh_agent_sync 124autoload <span class="nt">-Uz</span> add-zsh-hook 125add-zsh-hook precmd _ssh_agent_sync 126</code></pre></div></div> 127 128<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> 129 130<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> 131 132<p>The hook runs before tmux auto-attaches, and the order matters: an SSH login shell blocks inside <code class="language-plaintext highlighter-rouge">tmux attach</code> until the session ends, so the push of a freshly minted forwarded socket has to happen first. Otherwise every reconnect lands in tmux with its new socket unadopted, and sshd mints a new socket name on every connection.</p> 133 134<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> 135 136<h3 id="resolving-the-key-at-sign-time">Resolving the Key at Sign Time</h3> 137 138<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> 139 140<div class="language-ini highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nn">[gpg "ssh"]</span> 141 <span class="py">defaultKeyCommand</span> <span class="p">=</span> <span class="s">~/.dotfiles/bin/git-signing-key</span> 142</code></pre></div></div> 143 144<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> 145 146<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> 147 148<span class="k">if</span> <span class="o">
148[[</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 149 </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>/dev/null | <span class="nb">head</span> <span class="nt">-1</span><span class="si">)</span> <span class="o">||</span> <span class="nb">true 150 </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 151 </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> 152 <span class="nb">exit </span>0 153 <span class="k">fi 154fi 155 156</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>/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> 157<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">&&</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 158 </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> 159 <span class="nb">exit </span>0 160<span class="k">fi</span> 161</code></pre></div></div> 162 163<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> 164 165<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> 166 167<h3 id="gui-apps">GUI Apps</h3> 168 169<p>One LaunchAgent seeds the environment for everything that never sources zshrc:</p> 170 171<div class="language-xml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt"><key></span>ProgramArguments<span class="nt"></key></span> 172<span class="nt"><array></span> 173 <span class="nt"><string></span>/bin/sh<span class="nt"></string></span> 174 <span class="nt"><string></span>-c<span class="nt"></string></span> 175 <span class="nt"><string></span>"$HOME/.dotfiles/bin/ssh-agent-sync" >/dev/null <span class="err">&&</span> /bin/launchctl setenv SSH_AUTH_SOCK "$HOME/.ssh/agent.sock"<span class="nt"></string></span> 176<span class="nt"></array></span> 177<span class="nt"><key></span>
177RunAtLoad<span class="nt"></key></span> 178<span class="nt"><true/></span> 179</code></pre></div></div> 180 181<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> 182 183<h3 id="the-ssh-client-side">The SSH Client Side</h3> 184 185<p>The last piece is the client config, which is refreshingly boring:</p> 186 187<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Host phils-macbook-pro macbook 188 HostName 100.x.y.z 189 ForwardAgent yes 190 191Host * 192 IdentityAgent "~/Library/Containers/com.maxgoedjen.Secretive.SecretAgent/Data/socket.ssh" 193</code></pre></div></div> 194 195<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> 196 197<h2 id="what-it-looks-like-in-practice">What It Looks Like in Practice</h2> 198 199<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> 200 201<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> 202 203<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> 204 205<h2 id="how-it-bit-me">How It Bit Me</h2> 206 207<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> 208 209<p>The culprit was me.</p> 210 211<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> 212 213<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> 214 215<h2 id="caveats">Caveats</h2> 216 217<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> 218 219<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> 220 221<div class="language-ini highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nn">[alias]</span> 222 <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> 223</code></pre></div></div> 224 225<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> 226 227<h2 id="steal-it">Steal It</h2> 228 229<p>At no point in any of this does a private key exist as a file. Eac
229h 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> 230 231<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> 232 233<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> 234 235<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> 236 237<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> 238 239<h2 id="the-problem-with-textual-merging">The Problem with Textual Merging</h2> 240 241<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> 242 243<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> 244 245<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code><<<<<<< HEAD 246import { useState } from 'react'; 247import { useQuery } from '@tanstack/react-query'; 248======= 249import { useState } from 'react'; 250import { useEffect } from 'react'; 251>>>>>>> feature/dashboard 252</code></pre></div></div> 253 254<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> 255 256<p>These false conflicts add up. On a large rebase, they can turn a five-minute task into a thirty-minute slog.</p> 257 258<h2 id="layer-1-mergiraf">Layer 1: Mergiraf</h2> 259 260<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> 261 262<p>That import example above? Mergiraf resolves it automatically. It understands that those are independent additions to an import list and combines them.</p> 263 264<p>
264Mergiraf 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> 265 266<h3 id="setup">Setup</h3> 267 268<p>Three steps.</p> 269 270<p><strong>1. Install mergiraf:</strong></p> 271 272<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>brew <span class="nb">install </span>mergiraf 273</code></pre></div></div> 274 275<p><strong>2. Register the merge driver in your <code class="language-plaintext highlighter-rouge">~/.gitconfig</code>:</strong></p> 276 277<div class="language-ini highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nn">[merge "mergiraf"]</span> 278 <span class="py">name</span> <span class="p">=</span> <span class="s">mergiraf</span> 279 <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> 280</code></pre></div></div> 281 282<p><strong>3. Apply it globally in <code class="language-plaintext highlighter-rouge">~/.config/git/attributes</code>:</strong></p> 283 284<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>* merge=mergiraf 285</code></pre></div></div> 286 287<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> 288 289<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> 290</div> 291 292<h3 id="companion-settings">Companion Settings</h3> 293 294<p>Two additional Git settings complement mergiraf nicely.</p> 295 296<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> 297 298<div class="language-ini highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nn">[merge]</span> 299 <span class="py">conflictStyle</span> <span class="p">=</span> <span class="s">diff3</span> 300</code></pre></div></div> 301 302<p>Hereâs the difference. Standard conflict markers:</p> 303 304<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code><<<<<<< HEAD 305const timeout = 5000; 306======= 307const timeout = 10000; 308>>>>>>> feature 309</code></pre></div></div> 310 311<p>With diff3:</p> 312 313<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code><<<<<<< HEAD 314const timeout = 5000; 315||||||| base 316const timeout = 3000; 317======= 318const timeout = 10000; 319>>>>>>> feature 320</code></pre></div></div> 321 322<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> 323 324<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> 325 326<div class="language-ini highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nn">[rerere]</span> 327 <span class="py">enabled</span> <span class="p">=</span> <span class="s">true</span> 328</code></pre></div></div> 329 330<h2 id="layer-2-automating-the-rest">Layer 2: Automating the Rest</h2> 331 332<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> 333 334<p>This is drudgery. Drudgery that follows clear rules. Perfect for automation.</p> 335 336<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> 337 338<h3 id="how-it-works">How It Works</h3> 339 340<p>The skill follows a three-step loop:</p> 341 342<ol> 343 <li> 344 <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> 345 </li> 346 <li> 347 <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> 348 </li> 349 <li> 350 <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> 351 </li> 352</ol> 353 354<h3 id="resolution-strategies">Resolution Strategies</h3> 355 356<p>Each category gets its own treatment:</p> 357 358<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> 359 360<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> 361 362<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> 363 364<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> 365 366<h3 id="stacked-pr-duplicate-detection">Stacked PR Duplicate Detection</h3> 367 368<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> 369 370<p>Hereâs what that looks like with diff3:</p> 371 372<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code><<<<<<< HEAD 373function calculateTotal(items) { 374 return items.reduce((sum, item) => sum + item.price, 0); 375} 376||||||| base 377======= 378function calculateTotal(items) { 379 return items.reduce((sum, item) => sum + item.price, 0); 380} 381>>>>>>> feature/add-checkout 382</code></pre></div></div> 383 384<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> 385 386<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> 387 388<h3 id="a-realistic-session">A Realistic Session</h3> 389 390<p>Hereâs what it looks like in practice. Youâre rebasing and hit conflicts:</p> 391 392<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>> /resolve-conflicts 393 394Conflicts (rebase step 3/12): 395- 1 lockfile: package-lock.json 396- 2 mergiraf: src/app.ts, src/utils.ts 397- 1 other: README.md 398 399Resolving lockfile: package-lock.json... accepted theirs (will regenerate) 400Resolving mergiraf: src/app.ts... resolved structurally 401Resolving mergiraf: src/utils.ts... 1 conflict remains, analyzing... 402 Auto-resolved src/utils.ts hunk at line 42: stacked PR duplicate 403 (HEAD and incoming 98% similar with empty base). Kept HEAD version. 404Resolving other: README.md... [presents conflict for user review] 405 406Regenerating package-lock.json... done 407Continuing rebase (step 4/12)... 408</code></pre></div></div> 409 410<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> 411 412<h3 id="setup-1">Setup</h3> 413 414<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> 415 416<div class="markdown-alert markdown-alert-note"><p class="markdown-alert-title">
416<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> 417</div> 418 419<h2 id="putting-it-together">Putting It Together</h2> 420 421<p>The two layers complement each other:</p> 422 423<ol> 424 <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> 425 <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> 426</ol> 427 428<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> 429 430<h2 id="try-it">Try It</h2> 431 432<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> 433 434<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> 435 436<p>Yet there it was. My smiling face. On <em>that</em> PR.</p> 437 438<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> 439 440<p><img src="https://i.haacked.com/blog/2026-01-06-one-year-at-posthog/hogzilla.png" alt="Hogzilla" title="Hogzilla" /></p> 441 442<p>Nah.</p> 443 444<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> 445 446<blockquote> 447 <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> 448</blockquote> 449 450<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> 451 452<h2 id="a-year-of-posthog">A Year of PostHog</h2> 453 454<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> 455 456<blockquote> 457 <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> 458</blockquote> 459 460<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> 461 462<p>What has stood out to me at PostHog is that the handbook reflects reality. The company thinks deeply about how
462it 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> 463 464<p>That kind of self-awareness is rare. Itâs one big reason why I love working here. Also, Tulum.</p> 465 466<h2 id="retirement-job">Retirement Job?</h2> 467 468<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> 469 470<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> 471 472<p>Of course I said I would work hard. What else could I say?</p> 473 474<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> 475 476<p>That was exhausting.</p> 477 478<p>Writing code is how I unwind.</p> 479 480<p>So in a way, yes, this <em>is</em> a retirement job. Because I love doing it.</p> 481 482<h2 id="polyglot">Polyglot</h2> 483 484<p>I also still have a lot to learn, and this has been a great place to do it.</p> 485 486<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> 487 488<p>Yes, PHP. I held out for thirty years. The streak is over.</p> 489 490<p>At one point, I shipped the same ETag caching feature to seven different SDKs in seven different languages in three days.</p> 491 492<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> 493 494<p>Working across so many languages and paradigms has stretched my thinking and deepened my understanding of how software systems are built.</p> 495 496<h2 id="team-lead">Team Lead</h2> 497 498<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> 499 500<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> 501 502<p>What excites me most about this split is the chance to focus deeply on the platform itself.</p> 503 504<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>
504https://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> 505 506<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> 507 508<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> 509 510<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> 511 512<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> 513 514<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> 515 516<p>I wanted something simpler. I wanted something that would work across any repository. No setup, no configuration files, just sensible defaults.</p> 517 518<h2 id="enter-tree-me">Enter tree-me</h2> 519 520<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> 521 522<p>Instead of this:</p> 523 524<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Create worktree manually</span> 525<span class="nb">mkdir</span> <span class="nt">-p</span> ~/worktrees/my-project 526git worktree add ~/worktrees/my-project/fix-bug <span class="nt">-b</span> haacked/fix-bug main 527<span class="nb">cd</span> ~/worktrees/my-project/fix-bug 528<span class="c"># Now repeat for every repo...</span> 529</code></pre></div></div> 530 531<p>You do this:</p> 532 533<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>tree-me create haacked/fix-bug 534<span class="c"># Creates: ~/dev/worktrees/my-project/haacked/fix-bug</span> 535<span class="c"># And automatically cds into it</span> 536</code></pre></div></div> 537 538<h2 id="how-it-works">How it works</h2> 539 540<p>tree-me uses git-like subcommands and follows conventions so you donât have to think:</p> 541 542<ul> 543 <li><strong>Auto-detects repository name</strong> from your git remote</li> 544 <li><strong>Auto-detects default branch</strong> (checks for origin/HEAD, falls back to main)</li> 545 <li><strong>Organizes by repo</strong>: <code class="language-plaintext highlighter-rouge">$WORKTREE_ROOT/<repo-name>/<branch-name></code></li> 546 <li><strong>Delegates to git</strong> for all validation, errors, and edge cases</li> 547 <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> 548 <li><strong>Auto-CD</strong>: Automatically changes to the worktree directory after creation</li> 549 <li><strong>Tab completion</strong>: Complete commands and branch names in bash/zsh</li> 550</ul> 551 552<p>Commands:</p> 553 554<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>tree-me create <branch> <span class="o">[</span>base] <span class="c"># Create new branch in worktree</span> 555tree-me checkout <branch>
555 <span class="c"># Checkout existing branch (alias: co)</span> 556tree-me <span class="nb">pr</span> <number|url> <span class="c"># Checkout GitHub PR (uses gh CLI)</span> 557tree-me list <span class="c"># List all worktrees (alias: ls)</span> 558tree-me remove <branch> <span class="c"># Remove a worktree (alias: rm)</span> 559tree-me prune <span class="c"># Prune stale worktree files</span> 560tree-me shellenv <span class="c"># Output shell function for auto-cd</span> 561</code></pre></div></div> 562 563<p>Examples:</p> 564 565<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> 566tree-me create haacked/fix-bug develop <span class="c"># Create from develop</span> 567tree-me co existing-feature <span class="c"># Checkout existing branch</span> 568tree-me <span class="nb">pr </span>123 <span class="c"># Checkout PR #123</span> 569tree-me <span class="nb">pr </span>https://github.com/org/repo/pull/456 570tree-me <span class="nb">ls</span> <span class="c"># Show all worktrees</span> 571tree-me <span class="nb">rm </span>haacked/fix-bug <span class="c"># Clean up (supports tab completion)</span> 572</code></pre></div></div> 573 574<h2 id="conventions">Conventions</h2> 575 576<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> 577 578<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> 579 580<h2 id="setup">Setup</h2> 581 582<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> 583 584<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">source</span> <<span class="o">(</span>tree-me shellenv<span class="o">)</span> 585</code></pre></div></div> 586 587<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 <TAB></code>).</p> 588 589<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> 590 591<h2 id="the-pr-workflow">The PR workflow</h2> 592 593<p>Hereâs where it shines. Someone asks you to review a PR while youâre deep in a feature:</p> 594 595<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> 596<span class="c"># You're now in: ~/dev/worktrees/dotfiles/pr-123</span> 597<span class="c"># Review the code, test it, leave comments</span> 598<span class="c"># When done, switch back</span> 599tree-me co haacked/my-feature <span class="c"># Checks out and cds back to your feature</span> 600<span class="c"># Back to your work, no stash needed</span> 601</code></pre></div></div> 602 603<p>When youâre done reviewing:</p> 604 605<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> 606</code></pre></div></div> 607 608<p>Gone. Clean. No accidentally committing review changes to your feature branch.</p> 609 610<div class="markdown-alert markdown-alert-note"><p class="markdown-alert-title">
610<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> 611</div> 612 613<h2 id="installation">Installation</h2> 614 615<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> 616 617<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Example: copy to ~/bin or ~/.local/bin</span> 618curl <span class="nt">-o</span> ~/bin/tree-me https://raw.githubusercontent.com/haacked/dotfiles/main/bin/tree-me 619<span class="nb">chmod</span> +x ~/bin/tree-me 620</code></pre></div></div> 621 622<p>Then enable auto-cd and tab completion (see Setup section above).</p> 623 624<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> 625 626<h2 id="why-i-built-this">Why I built this</h2> 627 628<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> 629 630<p>The philosophy: <strong>Donât recreate what git does well. Add only the minimal convention needed.</strong></p> 631 632<p>Git already handles worktrees perfectly. I just needed organized paths, sensible defaults, and a consistent interface across all my projects.</p> 633 634<h2 id="directory-structure">Directory structure</h2> 635 636<p>Everything is organized predictably based on the repository name and branch name:</p> 637 638<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>~/dev/worktrees/<repo-name>/<branch-name> 639</code></pre></div></div> 640 641<p>For example:</p> 642 643<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>~/dev/worktrees/ 644âââ dotfiles/ 645â âââ haacked/vim-improvements/ 646â âââ haacked/git-tools/ 647â âââ main/ 648âââ posthog/ 649â âââ haacked/feature-flags/ 650â âââ pr-789-contributor/ 651â âââ main/ 652âââ spelungit/ 653 âââ haacked/performance/ 654</code></pre></div></div> 655 656<p>One glance and you know where everything is.</p> 657 658<h2 id="what-it-doesnt-do">What it doesnât do</h2> 659 660<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> 661 662<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> 663 664<h2 id="try-it">Try it</h2> 665 666<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> 667 668<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> 669 670<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> 671 672<p><img src="https://i.haacked.com/blog/2025-09-29-announcing-spelungit/robot-spelunking-git.png" alt="Robot spelunking Git" /></p> 673 674<h2 id="enter-spelungit">Enter Spelungit</h2> 675 676<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> 677 678<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> 679 680<h2 id="how-it-works">How it works</h2> 681 682<p>Instead of this:</p> 683 684<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> 685git 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> 686<span class="c"># Now manually read through 50 commits...</span> 687</code></pre></div></div> 688 689<p>You do this in your AI development tool of choice:</p> 690 691<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> 692</code></pre></div></div> 693 694<p>Queries that work:</p> 695 696<ul> 697 <li>âWhen did we switch from REST to GraphQL?â</li> 698 <li>âCommits where we improved error handling in API endpointsâ</li> 699 <li>âShow me refactoring of the authentication flowâ</li> 700 <li>âSecurity improvements in file upload handlingâ</li> 701 <li>âWhere did we fix that memory leak in the worker process?â</li> 702</ul> 703 704<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> 705 706<h2 id="installation">Installation</h2> 707 708<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> 709 710<p>One line:</p> 711 712<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 713</code></pre></div></div> 714 715<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> 716 717<h2 id="using-it">Using it</h2> 718 719<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> 720 721<p>Then search naturally:</p> 722 723<ul> 724 <li>âShow me commits about error handling improvementsâ</li> 725 <li>âFind where we refactored the authenticationâ</li> 726 <li>âWhat changed in the API layer recently?â</li> 727</ul> 728 729<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> 730 731<h2 id="under-the-hood">Under the hood</h2> 732 733<p>Analyzes commit messages and code changes. For each commit:</p> 734 735<ul> 736 <li>Semantic content from messages and diffs</li> 737 <li>Code patterns like function names and classes</li> 738 <li>File types and directory structure</li> 739 <li>Feature vs. bug fix vs. refactoring</li> 740</ul> 741 742<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> 743 744<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> 745 746<h2 id="why-i-built-this">Why I built this</h2> 747 748<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> 749 750<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> 751 752<p>Also, Iâve been experimenting with MCP servers and wanted to build something useful instead of another todo app.</p> 753 754<h2 id="future-ideas-for-improvement">Future ideas for improvement</h2> 755 756<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. 757<strong>PostgreSQL support</strong>: For massive repos or shared team search. 758<strong>Smarter code understanding</strong>: Better refactoring vs. feature detection, architectural changes over time.</p> 759 760<h2 id="try-it">Try it</h2> 761 762<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> 763 764<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.]
764]></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> 765 766<ol> 767 <li>Checkout the default branch.</li> 768 <li>Run <code class="language-plaintext highlighter-rouge">git up</code> to make sure youâre up to date.</li> 769 <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> 770</ol> 771 772<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> 773 774<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> 775 776<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> 777 778<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> 779 780<p>But hereâs the thing about Git. Thereâs almost always a way.</p> 781 782<h2 id="solution">Solution</h2> 783 784<p>When you merge a PR on GitHub, it shows you a âDelete branchâ button:</p> 785 786<p><img src="https://i.haacked.com/blog/2025-04-17-git-gone/delete-repo.png" alt="image of a delete branch button" /></p> 787 788<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> 789 790<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> 791 792<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> 793 794<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">></span> git branch <span class="nt">-vv</span> 795 796 haacked/decide-v4 ba39408 <span class="o">[</span>origin/haacked/decide-v4: gone] Fix demo to handle variants 797 haacked/fix-sample-app ec15751 <span class="o">[</span>origin/haacked/fix-sample-app] Handle variants 798<span class="k">*</span> haacked/local-only e78d2f6 Do important stuff 799 main ab885d0 <span class="o">[</span>origin/main] chore: Bump to v1.0.2 800</code></pre></div></div> 801 802<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> 803 804<p>We can use gitâs porcelain to list branches and their tracking information in a more easily parseable format.</p> 805 806<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">></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/ 807 808haacked/decide-v4 <span class="o">[</span>gone] 809haacked/fix-sample-app 810haacked/local-only 811master 812</code></pre></div></div> 813 814<p>Letâs make this an alias to list these gone branches:</p> 815 816<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">></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> 817</code></pre></div></div> 818 819<p>
819Now we can use the alias to list gone branches:</p> 820 821<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">></span> git gone 822 823haacked/decide-v4 824</code></pre></div></div> 825 826<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> 827 828<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">></span> git config <span class="nt">--global</span> alias.bclean <span class="s2">"!git gone | xargs -r git branch -D"</span> 829</code></pre></div></div> 830 831<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> 832 833<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> 834 835<div class="language-ini highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nn">[alias]</span> 836 <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> 837 <span class="py">bclean</span> <span class="p">=</span> <span class="s">"!git gone | xargs -r git branch -D"</span> 838 <span class="c"># Switches to specified branch (or the default branch if no branch is specified), runs git up, then runs bclean. 839</span> <span class="py">bdone</span> <span class="p">=</span> <span class="s">"!f() { DEFAULT=$(git default); git checkout ${1-$DEFAULT} && git up && git bclean; }; f"</span> 840 <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> 841</code></pre></div></div> 842 843<p>Or, if you want to use the <code class="language-plaintext highlighter-rouge">git</code> command line, you can use the following:</p> 844 845<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> 846git 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> 847git config <span class="nt">--global</span> alias.bclean <span class="s2">"!git gone | xargs -r git branch -D"</span> 848git 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} && git up && git bclean; }; f"</span> 849</code></pre></div></div> 850 851<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> 852<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>fatal: ref refs/remotes/origin/HEAD is not a symbolic ref 853</code></pre></div> 854 855</div> 856 857 858<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> 859<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git remote set-head origin <span class="nt">--auto</span> 860</code></pre></div> 861 862</div> 863 864 865</div> 866 867<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> 868 869<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.
8690/" 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> 870 871<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> 872 873<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> 874 875<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> 876 877<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> 878 879<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>dotnet add package PostHog.AspNetCore 880</code></pre></div></div> 881 882<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> 883 884<h2 id="configuration">Configuration</h2> 885 886<p>To configure the client SDK, youâll need:</p> 887 888<ol> 889 <li>Project API Key - <em>from the <a href="https://us.posthog.com/settings/project">PostHog dashboard</a></em></li> 890 <li>Personal API Key - <em>for local evaluation (Optional, but recommended)</em></li> 891</ol> 892 893<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> 894</div> 895 896<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> 897 898<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w"> 899 </span><span class="nl">"PostHog"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> 900 </span><span class="nl">"ProjectApiKey"</span><span class="p">:</span><span class="w"> </span><span class="s2">"phc_..."</span><span class="w"> 901 </span><span class="p">}</span><span class="w"> 902</span><span class="p">}</span><span class="w"> 903</span></code></pre></div></div> 904 905<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> 906 907<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>dotnet user-secrets init 908dotnet user-secrets <span class="nb">set</span> <span class="s2">"PostHog:PersonalApiKey"</span> <span class="s2">"phx_..."</span> 909</code></pre></div></div> 910 911<p>In production, you might use Azure Key Vault or a similar service to provide the personal API key.</p> 912 913<h2 id="register-the-client">Register the client</h2> 914 915<p>Once you set up configuration, register the client with the dependency injection container.</p> 916 917<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> 918 919<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> 920 921<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> 922 923<span class="n">builder</span><span class="p">.</span><span class="nf">AddPostHog</span><span class="p">();</span> 924</code></pre></div></div> 925 926<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> 927 928<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> 929<span class="p">{</span> 930<span class="p">}</span> 931 932<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> 933<span class="p">{</span> 934<span class="p">}</span> 935</code></pre></div></div> 936 937<h2 id="usage">Usage</h2> 938 939<p>Use the <code class="language-plaintext highlighter-rouge">IPostHogClient</code> service to identify users, capture analytics, and evaluate feature flags.</p> 940 941<p>Use the <code class="language-plaintext highlighter-rouge">IdentifyAsync</code> method to identify users:</p> 942 943<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> 944<span class="k">await</span> <span class="n">posthog</span><span class="p">.</span><span class="nf">IdentifyAsync</span><span class="p">(</span> 945 <span class="n">distinctId</span><span class="p">,</span> 946 <span class="n">user</span><span class="p">.</span><span class="n">Email</span><span class="p">,</span> 947 <span class="n">user</span><span class="p">.</span><span class="n">UserName</span><span class="p">,</span> 948 <span class="c1">// Properties to set on the person. If they're already</span> 949 <span class="c1">// set, they will be overwritten.</span> 950 <span class="n">personPropertiesToSet</span><span class="p">:</span> <span class="k">new</span><span class="p">()</span> 951 <span class="p">{</span>
952 <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> 953 <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> 954 <span class="p">},</span> 955 <span class="c1">// Properties to set once. If they're already set</span> 956 <span class="c1">// on the person, they won't be overwritten.</span> 957 <span class="n">personPropertiesToSetOnce</span><span class="p">:</span> <span class="k">new</span><span class="p">()</span> 958 <span class="p">{</span> 959 <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> 960 <span class="p">});</span> 961</code></pre></div></div> 962<p>Some things to note about the <code class="language-plaintext highlighter-rouge">IdentifyAsync</code> method:</p> 963 964<ul> 965 <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> 966 <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> 967 <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> 968</ul> 969 970<p>To capture an event, call the <code class="language-plaintext highlighter-rouge">Capture</code> method:</p> 971 972<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> 973</code></pre></div></div> 974 975<p>This will capture an event with the distinct id, the event name, and the current timestamp. You can also include properties:</p> 976 977<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> 978 <span class="s">"some-distinct-id"</span><span class="p">,</span> 979 <span class="s">"user signed up"</span><span class="p">,</span> 980 <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> 981</code></pre></div></div> 982 983<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> 984 985<h2 id="feature-flags">Feature flags</h2> 986 987<p>To evaluate a feature flag, call the <code class="language-plaintext highlighter-rouge">IsFeatureEnabledAsync</code> method:</p> 988 989<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> 990 <span class="s">
990"new_user_feature"</span><span class="p">,</span> 991 <span class="s">"some-distinct-id"</span><span class="p">))</span> <span class="p">{</span> 992 <span class="c1">// The feature flag is enabled.</span> 993<span class="p">}</span> 994</code></pre></div></div> 995 996<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> 997 998<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> 999 1000<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> 1001 1002<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> 1003 1004<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> 1005 <span class="n">featureKey</span><span class="p">:</span> <span class="s">"person-flag"</span><span class="p">,</span> 1006 <span class="n">distinctId</span><span class="p">:</span> <span class="s">"some-distinct-id"</span><span class="p">,</span> 1007 <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> 1008</code></pre></div></div> 1009 1010<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> 1011 1012<h2 id="net-feature-management">.NET Feature Management</h2> 1013 1014<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"><feature /></code> tag helper to conditionally render UI based on the state of a feature flag.</p> 1015 1016<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p"><</span><span class="n">feature</span> <span class="n">name</span><span class="p">=</span><span class="s">"my-feature"</span><span class="p">></span> 1017 <span class="p"><</span><span class="n">p</span><span class="p">></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">.</</span><span class="n">p</span><span class="p">></span> 1018<span class="p"></</span><span class="n">feature</span><span class="p">></span> 1019</code></pre></div></div> 1020 1021<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> 1022 1023<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> 1024<span class="k">public</span> <span class="k">class</span> <span class="nc">MyController</span> <span class="p">:</span> <span class="n">Controller</span> 1025<span class="p">{</span> 1026<span class="p">}</span> 1027</code></pre></div></div> 1028 1029<p>If your app already uses .NET Feature Management, you can switch to using PostHog with very little effort.</p> 1030 1031<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> 1032 1033<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> 1034 <span class="n">IHttpContextAccessor</span> <span class="n">httpContextAccessor</span><span class="p">)</span> 1035 <span class="p">:</span> <span class="n">PostHogFeatureFlagContextProvider</span> 1036<span class="p">{</span> 1037 <span class="k">protected</span> <span class="k">
1037override</span> <span class="kt">string</span><span class="p">?</span> <span class="nf">GetDistinctId</span><span class="p">()</span> <span class="p">=></span> 1038 <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> 1039 1040 <span class="k">protected</span> <span class="k">override</span> <span class="n">ValueTask</span><span class="p"><</span><span class="n">FeatureFlagOptions</span><span class="p">></span> <span class="nf">GetFeatureFlagOptionsAsync</span><span class="p">()</span> 1041 <span class="p">{</span> 1042 <span class="c1">// In a real app, you might get this information from a database or other source for the current user.</span> 1043 <span class="k">return</span> <span class="n">ValueTask</span><span class="p">.</span><span class="nf">FromResult</span><span class="p">(</span> 1044 <span class="k">new</span> <span class="n">FeatureFlagOptions</span> 1045 <span class="p">{</span> 1046 <span class="n">PersonProperties</span> <span class="p">=</span> <span class="k">new</span> <span class="n">Dictionary</span><span class="p"><</span><span class="kt">string</span><span class="p">,</span> <span class="kt">object</span><span class="p">?></span> 1047 <span class="p">{</span> 1048 <span class="p">[</span><span class="s">"email"</span><span class="p">]</span> <span class="p">=</span> <span class="s">"[email protected]"</span><span class="p">,</span> 1049 <span class="p">[</span><span class="s">"plan"</span><span class="p">]</span> <span class="p">=</span> <span class="s">"pro"</span> 1050 <span class="p">},</span> 1051 <span class="n">OnlyEvaluateLocally</span> <span class="p">=</span> <span class="k">true</span> 1052 <span class="p">});</span> 1053 <span class="p">}</span> 1054<span class="p">}</span> 1055</code></pre></div></div> 1056 1057<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> 1058 1059<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> 1060 1061<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> 1062 1063<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">=></span> <span class="p">{</span> 1064 <span class="n">options</span><span class="p">.</span><span class="n">UseFeatureManagement</span><span class="p"><</span><span class="n">MyFeatureFlagContextProvider</span><span class="p">>();</span> 1065<span class="p">});</span> 1066</code></pre></div></div> 1067 1068<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> 1069 1070<h2 id="non-aspnet-core-usage">Non-ASP.NET Core usage</h2> 1071 1072<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> 1073 1074<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>dotnet add package PostHog.AspNetCore 1075</code></pre></div></div> 1076 1077<p>And then register it with your dependency injection container:</p> 1078 1079<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> 1080</code></pre></div></div> 1081 1082<p>If youâre not using dependency injection, you can still use the registration method:</p> 1083 1084<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> 1085<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> 1086<span class="n">services</span><span class="p">.</span><span class="nf">AddPostHog</span><span class="p">();</span> 1087<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> 1088<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"><</span><span class="n">IPostHogClient</span><span class="p">>();
1088</span> 1089</code></pre></div></div> 1090 1091<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> 1092 1093<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> 1094<span class="k">using</span> <span class="nn">PostHog</span><span class="p">;</span> 1095 1096<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> 1097 <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> 1098</code></pre></div></div> 1099 1100<h2 id="examples">Examples</h2> 1101 1102<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> 1103 1104<h2 id="whats-next">Whatâs next?</h2> 1105 1106<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> 1107 1108<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> 1109 1110<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> 1111 1112<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> 1113 1114<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> 1115 1116<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> 1117 1118<h2 id="what-is-posthog">What is PostHog?</h2> 1119 1120<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> 1121 1122<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> 1123 1124<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> 1125 1126<h2 id="why-posthog">Why PostHog?</h2> 1127 1128<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> 1129 1130<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> 1131 1132<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> 1133 1134<div class="markdown-alert markdown-alert-note"><p class="markdown-alert-title">
1134<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> 1135</div> 1136 1137<p>This is a perk usually only offerered to executives.</p> 1138 1139<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> 1140 1141<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> 1142 1143<h2 id="the-challenge">The Challenge</h2> 1144 1145<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> 1146 1147<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> 1148 1149<h2 id="the-future">The Future</h2> 1150 1151<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>
1151I 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> 1152 1153<p>For example, I came across an API that returned a value like this:</p> 1154 1155<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w"> 1156 </span><span class="nl">"important"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w"> 1157</span><span class="p">}</span><span class="w"> 1158</span></code></pre></div></div> 1159 1160<p>No problem, I defined a class like this to deserialize it to:</p> 1161 1162<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> 1163<span class="p">{</span> 1164 <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> 1165<span class="p">}</span> 1166</code></pre></div></div> 1167 1168<p>And life was good. Until that awful day that the API returned this:</p> 1169 1170<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w"> 1171 </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"> 1172</span><span class="p">}</span><span class="w"> 1173</span></code></pre></div></div> 1174 1175<p>Damn! This philosophy lesson broke my client. One workaround is to do this:</p> 1176 1177<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> 1178<span class="p">{</span> 1179 <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> 1180<span class="p">}</span> 1181</code></pre></div></div> 1182 1183<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> 1184 1185<p><img src="https://github.com/user-attachments/assets/15a13230-644f-4e5e-827e-c4c35051c77e" alt="A ball of string on the left, "or" in the middle, a present on the right" title="Is it one or is it the other?" /></p> 1186 1187<h2 id="april-fools-joke-to-the-rescue">April Foolâs Joke to the Rescue</h2> 1188 1189<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<TOther></code>. This class could represent a dual-state value thatâs either a string or another type.</p> 1190 1191<blockquote> 1192 <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> 1193</blockquote> 1194 1195<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<T></code>.</p> 1196 1197<h2 id="a-modern-stringorvaluet">A modern StringOrValue<T></h2> 1198 1199<p>One nice thing about implementing this today is we can leverage modern C# features. Hereâs the starting implementation:</p> 1200 1201<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> 1202<span class="k">public</span> <span class="k">readonly</span> <span class="k">struct</span> <span class="nc">StringOrValue</span><span class="p"><</span><span class="n">T</span><span class="p">></span> <span class="p">:</span> <span class="n">IStringOrObject</span> <span class="p">{</span> 1203 <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>
1204 <span class="n">StringValue</span> <span class="p">=</span> <span class="n">stringValue</span><span class="p">;</span> 1205 <span class="n">IsString</span> <span class="p">=</span> <span class="k">true</span><span class="p">;</span> 1206 <span class="p">}</span> 1207 1208 <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> 1209 <span class="n">Value</span> <span class="p">=</span> <span class="k">value</span><span class="p">;</span> 1210 <span class="n">IsValue</span> <span class="p">=</span> <span class="k">true</span><span class="p">;</span> 1211 <span class="p">}</span> 1212 1213 <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> 1214 <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> 1215 1216 <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> 1217 <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> 1218 1219 <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> 1220 <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> 1221<span class="p">}</span> 1222 1223<span class="c1">/// <summary></span> 1224<span class="c1">/// Internal interface for <see cref="StringOrValue{T}"/>.</span> 1225<span class="c1">/// </summary></span> 1226<span class="c1">/// <remarks></span> 1227<span class="c1">/// This is here to make serialization and deserialization easy.</span> 1228<span class="c1">/// </remarks></span> 1229<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> 1230<span class="k">internal</span> <span class="k">interface</span> <span class="nc">IStringOrObject</span> 1231<span class="p">{</span> 1232 <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> 1233 1234 <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> 1235 1236 <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> 1237 1238 <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> 1239<span class="p">}</span> 1240</code></pre></div></div> 1241 1242<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> 1243 1244<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"><</span><span class="kt">string</span><span class="p">>(</span><span class="s">"Hello"</span><span class="p">);</span> 1245<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> 1246 <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">
1246Length</span><span class="p">);</span> 1247<span class="p">}</span> 1248</code></pre></div></div> 1249 1250<p>and</p> 1251 1252<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"><</span><span class="n">SomeType</span><span class="p">>(</span><span class="m">42</span><span class="p">);</span> 1253<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> 1254 <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> 1255<span class="p">}</span> 1256</code></pre></div></div> 1257 1258<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> 1259 1260<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> 1261 1262<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">/// <summary></span> 1263<span class="c1">/// Value converter for <see cref="StringOrValue{T}"/>.</span> 1264<span class="c1">/// </summary></span> 1265<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"><</span><span class="n">IStringOrObject</span><span class="p">></span> 1266<span class="p">{</span> 1267 <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> 1268 <span class="p">=></span> <span class="n">typeToConvert</span><span class="p">.</span><span class="n">IsGenericType</span> 1269 <span class="p">&&</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"><>);</span> 1270 1271 <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> 1272 <span class="p">{</span> 1273 <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> 1274 1275 <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>
1276 <span class="p">{</span> 1277 <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> 1278 <span class="k">return</span> <span class="n">stringValue</span> <span class="k">is</span> <span class="k">null</span> 1279 <span class="p">?</span> <span class="nf">CreateEmptyInstance</span><span class="p">(</span><span class="n">targetType</span><span class="p">)</span> 1280 <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> 1281 <span class="p">}</span> 1282 1283 <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> 1284 1285 <span class="k">return</span> <span class="k">value</span> <span class="k">is</span> <span class="k">null</span> 1286 <span class="p">?</span> <span class="nf">CreateEmptyInstance</span><span class="p">(</span><span class="n">targetType</span><span class="p">)</span> 1287 <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> 1288 <span class="p">}</span> 1289 1290 <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> 1291 <span class="p">{</span> 1292 <span class="k">return</span> <span class="k">typeof</span><span class="p">(</span><span class="n">StringOrValue</span><span class="p"><>)</span> 1293 <span class="p">.</span><span class="nf">MakeGenericType</span><span class="p">(</span><span class="n">targetType</span><span class="p">).</span> 1294 <span class="nf">GetConstructor</span><span class="p">([])</span> 1295 <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<</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">>."</span><span class="p">);</span> 1296 <span class="p">}</span> 1297 1298 <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> 1299 <span class="p">{</span> 1300 <span class="k">return</span> <span class="k">typeof</span><span class="p">(</span><span class="n">StringOrValue</span><span class="p"><>)</span> 1301 <span class="p">.</span><span class="nf">MakeGenericType</span><span class="p">(</span><span class="n">targetType</span><span class="p">).</span> 1302 <span class="nf">GetConstructor</span><span class="p">([</span><span class="n">argumentType</span><span class="p">])</span> 1303 <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<</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">>."</span><span class="p">);</span> 1304 <span class="p">}</span> 1305 1306 <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> 1307 <span class="p">{</span>
1308 <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> 1309 <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> 1310 <span class="p">}</span> 1311 1312 <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> 1313 <span class="p">{</span> 1314 <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> 1315 <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> 1316 <span class="p">}</span> 1317 1318 <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> 1319 <span class="p">{</span> 1320 <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> 1321 <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> 1322 <span class="p">}</span> 1323 1324 <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> 1325 <span class="p">{</span> 1326 <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> 1327 <span class="p">{</span> 1328 <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> 1329 <span class="p">}</span> 1330 <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> 1331 <span class="p">{</span> 1332 <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> 1333 <span class="p">}</span> 1334 <span class="k">else</span> 1335 <span class="p">{</span> 1336 <span class="n">writer</span><span class="p">.</span><span class="nf">WriteNullValue</span><span class="p">();</span>
1337 <span class="p">}</span> 1338 <span class="p">}</span> 1339<span class="p">}</span> 1340</code></pre></div></div> 1341 1342<p>In the actual implementation of <code class="language-plaintext highlighter-rouge">StringOrValue<T></code>, I implemented IEquatable<T>, IEquatable<StringOrValue<T>> and overrode the implicit operators:</p> 1343 1344<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"><</span><span class="n">T</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="k">new</span><span class="p">(</span><span class="n">stringValue</span><span class="p">);</span> 1345<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"><</span><span class="n">T</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="k">new</span><span class="p">(</span><span class="k">value</span><span class="p">);</span> 1346</code></pre></div></div> 1347 1348<p>This allows you to write code like this:</p> 1349 1350<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">StringOrValue</span><span class="p"><</span><span class="kt">int</span><span class="p">></span> <span class="n">valueAsString</span> <span class="p">=</span> <span class="s">"Hello"</span><span class="p">;</span> 1351<span class="n">StringOrValue</span><span class="p"><</span><span class="kt">int</span><span class="p">></span> <span class="n">valueAsNumber</span> <span class="p">=</span> <span class="m">42</span><span class="p">;</span> 1352 1353<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> 1354<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> 1355</code></pre></div></div> 1356 1357<p>So with this implementation in place, I can go back to the original example and write this:</p> 1358 1359<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> 1360<span class="p">{</span> 1361 <span class="k">public</span> <span class="n">StringOrValue</span><span class="p"><</span><span class="kt">bool</span><span class="p">></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> 1362<span class="p">}</span> 1363</code></pre></div></div> 1364 1365<p>And now I can handle both cases:</p> 1366 1367<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"><</span><span class="n">ImportantResponse</span><span class="p">>(</span><span class="n">json</span><span class="p">)</span> 1368 <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> 1369 1370<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> 1371 <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>
1372 <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> 1373 <span class="p">}</span> 1374 <span class="k">else</span> <span class="p">{</span> 1375 <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> 1376 <span class="p">}</span> 1377<span class="p">}</span> 1378<span class="k">else</span> <span class="p">{</span> 1379 <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> 1380<span class="p">}</span> 1381</code></pre></div></div> 1382 1383<p>Itâs time to go shopping for a beanie!</p> 1384 1385<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> 1386
1387<script src="https://gist.github.com/haacked/2fd1f8f0818c27184f2d08704f6f06f6.js"></script>
1387]]></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> 1388 1389<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> 1390 1391<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> 1392 1393<p>My first job followed this script: developer to team lead to Senior Manager. I was hustling up that ladder.</p> 1394 1395<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> 1396 1397<p><img src="https://github.com/user-attachments/assets/3b66f137-fd6b-4c3c-aedd-0c46701657fb" alt="The game of chutes and ladders" /></p> 1398 1399<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> 1400 1401<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> 1402 1403<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> 1404 1405<h2 id="ladders-are-overrated">Ladders Are Overrated</h2> 1406 1407<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> 1408 1409<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> 1410 1411<h2 id="growth-on-the-slide">Growth on the Slide</h2> 1412 1413<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> 1414 1415<p>Conventional wisdom (and ego) says aim higher: VP of Software Development or CTO at an established company.</p> 1416 1417<p>The irony of big titles is they mean more power at work, but less power over your time.</p> 1418 1419<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> 1420 1421<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> 1422 1423<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
1423structure my day as I see fit.</p> 1424 1425<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> 1426 1427<h2 id="your-move">Your Move</h2> 1428 1429<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> 1430 1431<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> 1432 1433<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>
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.