PageSourceSearch

http://feeds.haacked.com/haacked

html haacked.com collected 2026-10-01 10:42:38 UTC 152,174 bytes, 1,433 lines download raw bytes

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&apos;ve been Haacked is a blog about Technology, Software, Management, and Open Source. It&apos;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/&lt;hash&gt;.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">&amp;&amp;</span> <span class="nt">-S</span> <span class="s2">"</span><span class="nv">$forwarded</span><span class="s2">"</span> <span class="o">]]</span><span class="p">;</span> <span class="k">then</span>
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">&amp;&amp;</span> <span class="o">[[</span> <span class="nt">-S</span> <span class="s2">"</span><span class="nv">$secretive</span><span class="s2">"</span> <span class="o">]]</span><span class="p">;</span> <span class="k">then
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">&amp;&amp;</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">&amp;&amp;</span> <span class="nv">_SSH_FWD_SOCK</span><span class="o">=</span><span class="s2">"</span><span class="nv">$SSH_AUTH_SOCK</span><span class="s2">"</span>
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">&gt;</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">&amp;&amp;</span> <span class="nb">export </span><span class="nv">SSH_AUTH_SOCK</span><span class="o">=</span><span class="s2">"</span><span class="nv">$HOME</span><span class="s2">/.ssh/agent.sock"</span>
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&gt;/dev/null | <span class="nb">head</span> <span class="nt">-1</span><span class="si">)</span> <span class="o">||</span> <span class="nb">true
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&gt;/dev/null<span class="si">)</span> <span class="o">||</span> <span class="nv">local_signing_key</span><span class="o">=</span><span class="s2">""</span>
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">&amp;&amp;</span> <span class="nt">-f</span> <span class="s2">"</span><span class="nv">$local_signing_key</span><span class="s2">"</span> <span class="o">]]</span><span class="p">;</span> <span class="k">then
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">&lt;key&gt;</span>ProgramArguments<span class="nt">&lt;/key&gt;</span>
172<span class="nt">&lt;array&gt;</span>
173    <span class="nt">&lt;string&gt;</span>/bin/sh<span class="nt">&lt;/string&gt;</span>
174    <span class="nt">&lt;string&gt;</span>-c<span class="nt">&lt;/string&gt;</span>
175    <span class="nt">&lt;string&gt;</span>"$HOME/.dotfiles/bin/ssh-agent-sync" &gt;/dev/null <span class="err">&amp;&amp;</span> /bin/launchctl setenv SSH_AUTH_SOCK "$HOME/.ssh/agent.sock"<span class="nt">&lt;/string&gt;</span>
176<span class="nt">&lt;/array&gt;</span>
177<span class="nt">&lt;key&gt;</span>
177RunAtLoad<span class="nt">&lt;/key&gt;</span>
178<span class="nt">&lt;true/&gt;</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>&lt;&lt;&lt;&lt;&lt;&lt;&lt; HEAD
246import { useState } from 'react';
247import { useQuery } from '@tanstack/react-query';
248=======
249import { useState } from 'react';
250import { useEffect } from 'react';
251&gt;&gt;&gt;&gt;&gt;&gt;&gt; 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>&lt;&lt;&lt;&lt;&lt;&lt;&lt; HEAD
305const timeout = 5000;
306=======
307const timeout = 10000;
308&gt;&gt;&gt;&gt;&gt;&gt;&gt; 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>&lt;&lt;&lt;&lt;&lt;&lt;&lt; HEAD
314const timeout = 5000;
315||||||| base
316const timeout = 3000;
317=======
318const timeout = 10000;
319&gt;&gt;&gt;&gt;&gt;&gt;&gt; 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>&lt;&lt;&lt;&lt;&lt;&lt;&lt; HEAD
373function calculateTotal(items) {
374  return items.reduce((sum, item) =&gt; sum + item.price, 0);
375}
376||||||| base
377=======
378function calculateTotal(items) {
379  return items.reduce((sum, item) =&gt; sum + item.price, 0);
380}
381&gt;&gt;&gt;&gt;&gt;&gt;&gt; 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>&gt; /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/&lt;repo-name&gt;/&lt;branch-name&gt;</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 &lt;branch&gt; <span class="o">[</span>base]        <span class="c"># Create new branch in worktree</span>
555tree-me checkout &lt;branch&gt;
555             <span class="c"># Checkout existing branch (alias: co)</span>
556tree-me <span class="nb">pr</span> &lt;number|url&gt;               <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 &lt;branch&gt;               <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> &lt;<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 &lt;TAB&gt;</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/&lt;repo-name&gt;/&lt;branch-name&gt;
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">&gt;</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">&gt;</span> git <span class="k">for</span><span class="nt">-each-ref</span> <span class="nt">--format</span><span class="o">=</span><span class="s1">'%(refname:short) %(upstream:track)'</span> refs/heads/
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">&gt;</span> git config <span class="nt">--global</span> alias.gone <span class="s2">"!git for-each-ref --format='%(refname:short) %(upstream:track)' refs/heads/ | awk '</span><span class="se">\$</span><span class="s2">2 == </span><span class="se">\"</span><span class="s2">[gone]</span><span class="se">\"</span><span class="s2"> { print </span><span class="se">\$</span><span class="s2">1 }'"</span>
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">&gt;</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">&gt;</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} &amp;&amp; git up &amp;&amp; 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} &amp;&amp; git up &amp;&amp; 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">&lt;feature /&gt;</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">&lt;</span><span class="n">feature</span> <span class="n">name</span><span class="p">=</span><span class="s">"my-feature"</span><span class="p">&gt;</span>
1017    <span class="p">&lt;</span><span class="n">p</span><span class="p">&gt;</span><span class="n">This</span> <span class="k">is</span> <span class="n">a</span> <span class="n">feature</span> <span class="n">flag</span><span class="p">.&lt;/</span><span class="n">p</span><span class="p">&gt;</span>
1018<span class="p">&lt;/</span><span class="n">feature</span><span class="p">&gt;</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">=&gt;</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">&lt;</span><span class="n">FeatureFlagOptions</span><span class="p">&gt;</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">&lt;</span><span class="kt">string</span><span class="p">,</span> <span class="kt">object</span><span class="p">?&gt;</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">=&gt;</span> <span class="p">{</span>
1064    <span class="n">options</span><span class="p">.</span><span class="n">UseFeatureManagement</span><span class="p">&lt;</span><span class="n">MyFeatureFlagContextProvider</span><span class="p">&gt;();</span>
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">&lt;</span><span class="n">IPostHogClient</span><span class="p">&gt;();
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, &quot;or&quot; 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&lt;TOther&gt;</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&lt;T&gt;</code>.</p>
1196
1197<h2 id="a-modern-stringorvaluet">A modern StringOrValue&lt;T&gt;</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">&lt;</span><span class="n">T</span><span class="p">&gt;</span> <span class="p">:</span> <span class="n">IStringOrObject</span> <span class="p">{</span>
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">/// &lt;summary&gt;</span>
1224<span class="c1">/// Internal interface for &lt;see cref="StringOrValue{T}"/&gt;.</span>
1225<span class="c1">/// &lt;/summary&gt;</span>
1226<span class="c1">/// &lt;remarks&gt;</span>
1227<span class="c1">/// This is here to make serialization and deserialization easy.</span>
1228<span class="c1">/// &lt;/remarks&gt;</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">&lt;</span><span class="kt">string</span><span class="p">&gt;(</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">&lt;</span><span class="n">SomeType</span><span class="p">&gt;(</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">/// &lt;summary&gt;</span>
1263<span class="c1">/// Value converter for &lt;see cref="StringOrValue{T}"/&gt;.</span>
1264<span class="c1">/// &lt;/summary&gt;</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">&lt;</span><span class="n">IStringOrObject</span><span class="p">&gt;</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">=&gt;</span> <span class="n">typeToConvert</span><span class="p">.</span><span class="n">IsGenericType</span>
1269           <span class="p">&amp;&amp;</span> <span class="n">typeToConvert</span><span class="p">.</span><span class="nf">GetGenericTypeDefinition</span><span class="p">()</span> <span class="p">==</span> <span class="k">typeof</span><span class="p">(</span><span class="n">StringOrValue</span><span class="p">&lt;&gt;);</span>
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">&lt;&gt;)</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&lt;</span><span class="p">{</span><span class="n">targetType</span><span class="p">.</span><span class="n">Name</span><span class="p">}</span><span class="s">&gt;."</span><span class="p">);</span>
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">&lt;&gt;)</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&lt;</span><span class="p">{</span><span class="n">targetType</span><span class="p">.</span><span class="n">Name</span><span class="p">}</span><span class="s">&gt;."</span><span class="p">);</span>
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&lt;T&gt;</code>, I implemented IEquatable&lt;T&gt;, IEquatable&lt;StringOrValue&lt;T&gt;&gt; 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">&lt;</span><span class="n">T</span><span class="p">&gt;(</span><span class="kt">string</span> <span class="n">stringValue</span><span class="p">)</span> <span class="p">=&gt;</span> <span class="k">new</span><span class="p">(</span><span class="n">stringValue</span><span class="p">);</span>
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">&lt;</span><span class="n">T</span><span class="p">&gt;(</span><span class="n">T</span> <span class="k">value</span><span class="p">)</span> <span class="p">=&gt;</span> <span class="k">new</span><span class="p">(</span><span class="k">value</span><span class="p">);</span>
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">&lt;</span><span class="kt">int</span><span class="p">&gt;</span> <span class="n">valueAsString</span> <span class="p">=</span> <span class="s">"Hello"</span><span class="p">;</span>
1351<span class="n">StringOrValue</span><span class="p">&lt;</span><span class="kt">int</span><span class="p">&gt;</span> <span class="n">valueAsNumber</span> <span class="p">=</span> <span class="m">42</span><span class="p">;</span>
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">&lt;</span><span class="kt">bool</span><span class="p">&gt;</span> <span class="n">Important</span> <span class="p">{</span> <span class="k">get</span><span class="p">;</span> <span class="k">set</span><span class="p">;</span> <span class="p">}</span>
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">&lt;</span><span class="n">ImportantResponse</span><span class="p">&gt;(</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.