<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en"><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://jonasporto.github.io/pwt/blog/feed.xml" rel="self" type="application/atom+xml" /><link href="https://jonasporto.github.io/pwt/" rel="alternate" type="text/html" hreflang="en" /><updated>2026-08-18T18:15:18+00:00</updated><id>https://jonasporto.github.io/pwt/blog/feed.xml</id><title type="html">pwt — Power Worktrees</title><subtitle>A powerful Git worktree workflow for today&apos;s multi-project development.</subtitle><author><name>Jonas Porto</name></author><entry><title type="html">I timed every command in my CLI on a 95-worktree project</title><link href="https://jonasporto.github.io/pwt/blog/i-timed-every-command-in-my-cli/" rel="alternate" type="text/html" title="I timed every command in my CLI on a 95-worktree project" /><published>2026-08-18T00:00:00+00:00</published><updated>2026-08-18T00:00:00+00:00</updated><id>https://jonasporto.github.io/pwt/blog/i-timed-every-command-in-my-cli</id><content type="html" xml:base="https://jonasporto.github.io/pwt/blog/i-timed-every-command-in-my-cli/"><![CDATA[<p>Here is every command I use daily, timed this morning against a project
with 95 worktrees, on a machine holding 16 projects and 111 worktree
records:</p>

<table>
  <thead>
    <tr>
      <th>Command</th>
      <th>Now</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">pwt version</code></td>
      <td>62 ms</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">pwt list</code></td>
      <td>44 ms</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">pwt tree</code></td>
      <td>53 ms</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">pwt list --porcelain</code></td>
      <td>245 ms</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">pwt ports</code></td>
      <td>256 ms</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">pwt state --json</code></td>
      <td>614 ms</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">pwt servers</code></td>
      <td><strong>3054 ms</strong></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">pwt list --refresh</code></td>
      <td><strong>13853 ms</strong></td>
    </tr>
  </tbody>
</table>

<p>Two months ago the first four were 13 to 17 seconds each. What changed is
not that the work got faster. Most of it did not.</p>

<h2 id="the-numbers-before">The numbers before</h2>

<p>Measured then on the same project, which had 66 worktrees at the time:</p>

<table>
  <thead>
    <tr>
      <th>Command</th>
      <th>Before</th>
      <th>After</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">pwt list --refresh</code></td>
      <td>16.5 s</td>
      <td>12.7 s</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">pwt list</code> (cached read)</td>
      <td>13 to 16 s</td>
      <td>0.03 s</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">pwt tree</code></td>
      <td>12.9 s</td>
      <td>0.05 s</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">pwt servers</code></td>
      <td>11.7 s</td>
      <td>1.9 s</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">pwt state --json</code></td>
      <td>2.4 s</td>
      <td>0.5 s</td>
    </tr>
  </tbody>
</table>

<p>Look at the first row against the second. The recompute got 23% cheaper,
which is a rounding error you would never feel. The read got 400 times
cheaper. Those two lines are the whole thesis.</p>

<h2 id="you-cannot-make-the-walk-fast-you-can-stop-putting-it-on-the-read">You cannot make the walk fast. You can stop putting it on the read.</h2>

<p>Listing 95 worktrees means asking git 95 times what changed, and <code class="language-plaintext highlighter-rouge">git
status --porcelain</code> costs 120 ms in that repository. That is 11 seconds
of unavoidable work, and no amount of shell tuning removes it.</p>

<p>So the read stopped doing it:</p>

<ul>
  <li>a cached list is served <strong>immediately, even when stale</strong>, with a note
on stderr saying how old it is;</li>
  <li>the recompute runs in a <strong>detached background process</strong> that rewrites
the cache for the next read;</li>
  <li><code class="language-plaintext highlighter-rouge">--refresh</code> stays synchronous, because when you ask for fresh you are
saying you will wait;</li>
  <li>the first run ever is synchronous too, since there is nothing to serve.</li>
</ul>

<p>That is the trade, stated plainly: <strong>you get milliseconds and you accept
that the answer may be a minute old.</strong> For “what is dirty across my
worktrees” that is obviously fine. For “did my last command take effect”
it is not, which is why writing metadata invalidates the cache instead of
waiting for it to expire.</p>

<h2 id="the-instrument-that-lied">The instrument that lied</h2>

<p><strong>A serial trace blamed the wrong thing.</strong> Running the list under <code class="language-plaintext highlighter-rouge">bash
-x</code> and counting external commands produced a satisfying culprit: 752
<code class="language-plaintext highlighter-rouge">tr</code> forks per list. It was real, and it was nearly free. Forks are cheap
and the trace flattened a parallel loop into a serial one, so the shape
of the cost was gone before I read it.</p>

<p>What found the real cost was comparing CPU to wall clock. <code class="language-plaintext highlighter-rouge">/usr/bin/time
-l</code> reported <strong>79 seconds of system time for 14 seconds of wall</strong>, which
says the work is spread across cores and dominated by syscalls, not by
process creation. That pointed at the filesystem, and from there at two
things: the status walk running <strong>twice</strong> per row (once to render, once
to decide merge status) and a <code class="language-plaintext highlighter-rouge">git fetch</code> costing 3 seconds on every list
that missed the cache.</p>

<p>The same lesson arrived a second time from a different direction, when a
commit message credited the startup win to the change that shipped
alongside the one that actually earned it. Re-measuring meant extracting
each commit with <code class="language-plaintext highlighter-rouge">git archive</code> and running all of them against identical
state, and the credit moved. That one has <a href="/pwt/blog/sixty-two-processes-to-print-a-version-number/">its own
post</a>,
including the part where the “faster” format was twice as slow on the
cold path.</p>

<h2 id="what-actually-made-the-recompute-cheaper">What actually made the recompute cheaper</h2>

<p>Not one big thing. Six small ones, each removing repeated work rather
than optimising it:</p>

<ul>
  <li>the status walk runs <strong>once per row</strong> instead of twice, with the merge
check reusing the porcelain output already captured;</li>
  <li>status symbols come from parsing <strong>one</strong> <code class="language-plaintext highlighter-rouge">git status --porcelain</code> in
bash, instead of three git commands piped through <code class="language-plaintext highlighter-rouge">wc</code> and <code class="language-plaintext highlighter-rouge">tr</code> (there
go the 752 forks, worth about nothing, removed anyway);</li>
  <li>divergence uses one <code class="language-plaintext highlighter-rouge">--left-right --count</code> instead of two calls;</li>
  <li>commit hash and age share a single <code class="language-plaintext highlighter-rouge">git log</code>;</li>
  <li>column width for ASCII answers without forking;</li>
  <li>the row pool became a <strong>rolling window</strong> sized to the core count
instead of a batch-of-4 barrier, so a slow row no longer stalls three
idle workers.</li>
</ul>

<p><strong>The dead end, since it was the obvious idea:</strong> widening the pool past 8
on a 12-core machine made it <em>slower</em>, 13.6 s against 12.7 s. <code class="language-plaintext highlighter-rouge">git
status</code> is already multi-threaded and the contention is in the
filesystem, so more workers just fight each other.</p>

<h2 id="the-bug-that-only-appeared-because-of-the-fix">The bug that only appeared because of the fix</h2>

<p>Serving stale caches needed tests, and the tests found something the
feature introduced: <code class="language-plaintext highlighter-rouge">list --refresh</code> <strong>deleted the cache before
regenerating it</strong>. For the seconds the recompute took, a concurrent
reader saw no cache file at all, and got the slow path or an empty
answer. Cache writes are now a temp file plus a rename, which is atomic,
and refresh overwrites rather than unlinking.</p>

<p>This is the ordinary tax on caching. The moment a read can be served from
a file, every writer has to think about who is reading it mid-write.</p>

<h2 id="still-slow-found-while-writing-this">Still slow, found while writing this</h2>

<p><code class="language-plaintext highlighter-rouge">pwt servers</code> is 3 seconds today, against the 1.9 s it measured at 66
worktrees: it grew with the project, which is the signature of work that
scales linearly with rows. I only looked at why because the table above
embarrassed me. Same method as before, CPU against wall:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>3.14 real   1.32 user   1.62 sys
</code></pre></div></div>

<p>CPU almost equals wall, which means one core is busy the whole time:
<strong>it is serial.</strong> Counting its externals: 65 git invocations, one <code class="language-plaintext highlighter-rouge">lsof</code>,
one <code class="language-plaintext highlighter-rouge">ps</code>. The snapshot work already landed there (one <code class="language-plaintext highlighter-rouge">lsof</code> for the
whole machine instead of one per worktree, which is what took it from
11.7 s to 1.9 s), but the git calls never joined the parallel pool that
<code class="language-plaintext highlighter-rouge">pwt list</code> uses.</p>

<p>So the fix is not fewer git calls, it is the same rolling pool applied to
a second command. Measured, not guessed, which is the only part of this I
would insist on.</p>

<h2 id="where-the-floor-is">Where the floor is</h2>

<p>The cached porcelain read is 245 ms, and 62 ms of that is process
startup. Tracing it shows <strong>five external commands in the whole run</strong>.
The remaining time is bash walking 95 records and escaping their fields,
about 2 ms per record with no forks at all.</p>

<p>That is worth knowing before you optimise a shell tool further: once you
have stopped forking, what is left is the interpreter, and the only
remaining moves are doing less work per record or not being bash. There
is no third option hiding in there.</p>

<h2 id="if-you-are-auditing-your-own-cli">If you are auditing your own CLI</h2>

<ol>
  <li><strong>Time every command, not the one that annoys you.</strong> The table is the
deliverable. Mine had four entries over ten seconds and I had
normalised all of them.</li>
  <li><strong>Compare CPU to wall before profiling anything.</strong> Wall much greater
than CPU means waiting (network, locks). CPU close to wall means
serial. CPU far above wall means parallel and syscall-heavy. Each
points somewhere different, and it costs one command.</li>
  <li><strong>Distrust fork counts from a serial trace.</strong> They are easy to collect
and they flatten exactly the structure you are trying to measure.</li>
  <li><strong>Re-measure attributions, especially your own commit messages.</strong>
Extract the commits, run them against identical state, and let the
numbers assign the credit.</li>
  <li><strong>Ask whether the work belongs on the read path at all.</strong> That
question beat every optimisation in this list by two orders of
magnitude.</li>
</ol>

<p><code class="language-plaintext highlighter-rouge">brew install jonasporto/pwt/pwt</code>. <code class="language-plaintext highlighter-rouge">pwt list</code> is served from cache and
tells you its age; <code class="language-plaintext highlighter-rouge">pwt list --refresh</code> is the one that waits
(<a href="/pwt/docs/commands/#list">reference</a>). If you script
against it, <code class="language-plaintext highlighter-rouge">--porcelain</code> carries <code class="language-plaintext highlighter-rouge">generated_at</code> so you can decide for
yourself whether the document is fresh enough.</p>]]></content><author><name>Jonas Porto</name></author><category term="performance" /><category term="cli" /><category term="bash" /><summary type="html"><![CDATA[Four commands took over ten seconds. The fix was not making the work faster, it was taking the work off the read path. Includes the two instruments that lied to me and the command that is still slow today.]]></summary></entry><entry><title type="html">How to make every coding agent create worktrees your way</title><link href="https://jonasporto.github.io/pwt/blog/how-to-make-every-coding-agent-use-pwt/" rel="alternate" type="text/html" title="How to make every coding agent create worktrees your way" /><published>2026-08-17T00:00:00+00:00</published><updated>2026-08-17T00:00:00+00:00</updated><id>https://jonasporto.github.io/pwt/blog/how-to-make-every-coding-agent-use-pwt</id><content type="html" xml:base="https://jonasporto.github.io/pwt/blog/how-to-make-every-coding-agent-use-pwt/"><![CDATA[<p>Worktrees became the standard isolation primitive for coding agents
during the first half of 2026, and the tools converged fast:</p>

<table>
  <thead>
    <tr>
      <th>Tool</th>
      <th>Creates worktrees</th>
      <th>Setup contract</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Claude Code</td>
      <td><code class="language-plaintext highlighter-rouge">--worktree</code>, subagents with <code class="language-plaintext highlighter-rouge">isolation: worktree</code>, background sessions</td>
      <td><code class="language-plaintext highlighter-rouge">.worktreeinclude</code> (copies gitignored files) and a <code class="language-plaintext highlighter-rouge">WorktreeCreate</code> hook</td>
    </tr>
    <tr>
      <td>Gemini CLI</td>
      <td>since v0.36 (April 2026), experimental, <code class="language-plaintext highlighter-rouge">--worktree</code>; a service owns lifecycle and cleanup</td>
      <td>none: the hooking system is still a feature request</td>
    </tr>
    <tr>
      <td>Grok Build</td>
      <td>up to 8 parallel subagents, each in its own worktree</td>
      <td><code class="language-plaintext highlighter-rouge">AGENTS.md</code>, plugins, hooks and MCP “work out of the box”, no worktree-specific setup event</td>
    </tr>
    <tr>
      <td>Codex</td>
      <td>worktree mode in the desktop app; the CLI has no worktree flag in stable</td>
      <td><code class="language-plaintext highlighter-rouge">AGENTS.md</code>; you create the worktree yourself</td>
    </tr>
  </tbody>
</table>

<p>Read the right-hand column again. Every one of them solved <strong>creation</strong>,
which is the easy half, and none of them defined what makes a checkout
<em>ready</em>. The same complaint follows each release: a fresh worktree has no
<code class="language-plaintext highlighter-rouge">.env</code>, no dependencies, and a dev server that wants a port another
worktree already took. Only one of these tools has an event you can hang
setup on, and using it means taking over creation.</p>

<p>So configuring setup per tool means maintaining the same knowledge in
four formats, three of which do not exist yet. The alternative is to
define “a ready worktree” once, in the repository, and make every tool
land on it. There are exactly three ways to do that, and they differ in
how much cooperation they need from the tool.</p>

<h2 id="layer-1-instruct-portable-advisory">Layer 1: instruct (portable, advisory)</h2>

<p>Every agent reads a project instruction file. Put the rule there:</p>

<div class="language-markdown highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Worktrees in this repository are created with <span class="sb">`pwt create &lt;name&gt;`</span>, never
with <span class="sb">`git worktree add`</span>: creation allocates a port, writes metadata and
runs the project's setup hook. A worktree that already exists is
registered with <span class="sb">`pwt adopt`</span>.
</code></pre></div></div>

<p>One wrinkle worth knowing: <strong>Claude Code does not read <code class="language-plaintext highlighter-rouge">AGENTS.md</code></strong>, the
file the other CLIs standardised on. If you keep one, import it rather
than duplicating it:</p>

<div class="language-markdown highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">&lt;!-- CLAUDE.md --&gt;</span>
@AGENTS.md
</code></pre></div></div>

<p>This layer is portable and free, and it is advice. An agent under
pressure will still reach for <code class="language-plaintext highlighter-rouge">git worktree add</code>, because that is what
its training says worktrees are.</p>

<h2 id="layer-2-enforce-mechanical-per-tool">Layer 2: enforce (mechanical, per tool)</h2>

<p>Claude Code can refuse the command outright, in
<code class="language-plaintext highlighter-rouge">.claude/settings.json</code> (checked in, applies to the team) or
<code class="language-plaintext highlighter-rouge">.claude/settings.local.json</code> (your machine only):</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w"> </span><span class="nl">"permissions"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"deny"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"Bash(git worktree add *)"</span><span class="p">]</span><span class="w"> </span><span class="p">}</span><span class="w"> </span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>Two properties make this actually hold. <strong>Deny is evaluated before
allow</strong>, so no broader permission reopens the path. And compound commands
are parsed per subcommand, so <code class="language-plaintext highlighter-rouge">git status &amp;&amp; git worktree add wt</code> is
caught too, rather than sneaking through as one string. <code class="language-plaintext highlighter-rouge">/permissions</code>
shows every active rule and where it came from.</p>

<p>This is the layer that turns a convention into a guarantee, and it exists
only for the tools that implement it.</p>

<h2 id="layer-3-the-creation-hook-claude-code">Layer 3: the creation hook (Claude Code)</h2>

<p><code class="language-plaintext highlighter-rouge">WorktreeCreate</code> fires when Claude Code makes a worktree: <code class="language-plaintext highlighter-rouge">--worktree</code>,
subagents with <code class="language-plaintext highlighter-rouge">isolation: worktree</code>, background sessions. The contract is
narrower than it first looks, and the thread that requested the feature
flagged why: <strong>your script owns the creation</strong>. Exit 0 and git is never
called; exit non-zero and the whole thing rolls back.</p>

<p>The input arrives as JSON on <strong>stdin</strong>, not as arguments:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w"> </span><span class="nl">"base_path"</span><span class="p">:</span><span class="w"> </span><span class="s2">"/repo"</span><span class="p">,</span><span class="w"> </span><span class="nl">"worktree_path"</span><span class="p">:</span><span class="w"> </span><span class="s2">"/repo/.claude/worktrees/session-xyz"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"worktree_name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"session-xyz"</span><span class="p">,</span><span class="w"> </span><span class="nl">"session_id"</span><span class="p">:</span><span class="w"> </span><span class="s2">"abc123"</span><span class="w"> </span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>So the script creates the checkout where the agent asked for it, and
hands the rest over:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">#!/usr/bin/env bash</span>
<span class="c"># .claude/hooks/worktree-create.sh</span>
<span class="nv">input</span><span class="o">=</span><span class="si">$(</span><span class="nb">cat</span><span class="si">)</span>
<span class="nv">base</span><span class="o">=</span><span class="si">$(</span>jq <span class="nt">-r</span> .base_path     <span class="o">&lt;&lt;&lt;</span><span class="s2">"</span><span class="nv">$input</span><span class="s2">"</span><span class="si">)</span>
<span class="nv">wt</span><span class="o">=</span><span class="si">$(</span>jq   <span class="nt">-r</span> .worktree_path <span class="o">&lt;&lt;&lt;</span><span class="s2">"</span><span class="nv">$input</span><span class="s2">"</span><span class="si">)</span>

git <span class="nt">-C</span> <span class="s2">"</span><span class="nv">$base</span><span class="s2">"</span> worktree add <span class="s2">"</span><span class="nv">$wt</span><span class="s2">"</span> <span class="nt">-b</span> <span class="s2">"</span><span class="si">$(</span><span class="nb">basename</span> <span class="s2">"</span><span class="nv">$wt</span><span class="s2">"</span><span class="si">)</span><span class="s2">"</span> <span class="o">&gt;</span>&amp;2 <span class="o">||</span> <span class="nb">exit </span>1
pwt <span class="nt">--no-input</span> adopt <span class="s2">"</span><span class="nv">$wt</span><span class="s2">"</span> <span class="o">&gt;</span>&amp;2 <span class="o">||</span> <span class="nb">exit </span>1    <span class="c"># port, metadata, setup()</span>
<span class="nb">exit </span>0
</code></pre></div></div>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w"> </span><span class="nl">"hooks"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"WorktreeCreate"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"matcher"</span><span class="p">:</span><span class="w"> </span><span class="s2">"*"</span><span class="p">,</span><span class="w"> </span><span class="nl">"hooks"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
  </span><span class="p">{</span><span class="w"> </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"command"</span><span class="p">,</span><span class="w"> </span><span class="nl">"command"</span><span class="p">:</span><span class="w"> </span><span class="s2">".claude/hooks/worktree-create.sh"</span><span class="w"> </span><span class="p">}</span><span class="w"> </span><span class="p">]</span><span class="w"> </span><span class="p">}</span><span class="w"> </span><span class="p">]</span><span class="w"> </span><span class="p">}</span><span class="w"> </span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>Two details that bite. Everything on <strong>stdout is parsed as structured
output</strong>, so send your progress to stderr. And <code class="language-plaintext highlighter-rouge">--no-input</code> closes stdin
and sets <code class="language-plaintext highlighter-rouge">PWT_AGENT=1</code>, so a setup step that would have asked a question
fails loudly instead of hanging a session nobody is watching.</p>

<p>Note which command does the work: <code class="language-plaintext highlighter-rouge">adopt</code>, not <code class="language-plaintext highlighter-rouge">create</code>. The agent picked
the path, usually inside <code class="language-plaintext highlighter-rouge">.claude/worktrees/</code>, and adopting records that
real path instead of insisting on the project’s own directory. Verified
on a checkout outside <code class="language-plaintext highlighter-rouge">worktrees_dir</code>: port allocated, <code class="language-plaintext highlighter-rouge">setup()</code> run,
<code class="language-plaintext highlighter-rouge">.env</code> written with the allocated port.</p>

<h2 id="layer-4-adopt-whatever-arrives">Layer 4: adopt whatever arrives</h2>

<p>The other three layers cover the tools that cooperate. This one covers
everything else, and it needs no cooperation at all:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>pwt adopt          <span class="c"># inside a worktree someone else created</span>
pwt adopt <span class="nt">--all</span>    <span class="c"># every unregistered worktree in the directory</span>
</code></pre></div></div>

<p>That is the fallback for Gemini CLI (which creates worktrees and has no
hook to run anything in them), for Grok Build’s eight parallel subagents,
for Codex where you did the <code class="language-plaintext highlighter-rouge">git worktree add</code> yourself, for a teammate,
and for the worktree you made by hand last Tuesday and forgot. It is also
the only layer that keeps working when a tool changes its hook format,
which they will: three of the four rows above shipped their worktree
support this year.</p>

<h2 id="what-to-actually-wire">What to actually wire</h2>

<p>Layers 1 and 4 give most of the value and depend on nothing: state the
rule, and adopt the strays. Add layer 2 for the tool you use most, since
it is one line and it converts “please” into “cannot”. Add layer 3 only
if you are already using that tool’s worktree flag daily, because you are
taking ownership of creation in exchange.</p>

<p>And the honest limit: none of this is worth wiring if your worktrees do
not need setup. A repository with no <code class="language-plaintext highlighter-rouge">.env</code>, no installable dependencies
and no dev server is fine with whatever the agent does on its own. The
whole argument for routing creation through one tool is that <strong>the values
that must differ per worktree have to be derived</strong>, and something has to
own that derivation. If nothing must differ, nothing needs owning.</p>

<p><code class="language-plaintext highlighter-rouge">brew install jonasporto/pwt/pwt</code>, and the agent-facing guide the rule
above refers to is <a href="/pwt/docs/agents/"><code class="language-plaintext highlighter-rouge">pwt skill</code></a>,
printable into any tool’s skills directory with <code class="language-plaintext highlighter-rouge">pwt skill --install</code>.</p>]]></content><author><name>Jonas Porto</name></author><category term="agents" /><category term="worktrees" /><category term="cli" /><summary type="html"><![CDATA[Instructions are advisory, a deny rule is mechanical, and adopting after the fact needs no cooperation at all. Three layers, verified syntax, and the honest note on when none of it is worth wiring.]]></summary></entry><entry><title type="html">Using pwt ports as a central registry for every app on your machine</title><link href="https://jonasporto.github.io/pwt/blog/how-to-manage-ports-across-many-local-projects/" rel="alternate" type="text/html" title="Using pwt ports as a central registry for every app on your machine" /><published>2026-08-17T00:00:00+00:00</published><updated>2026-08-17T00:00:00+00:00</updated><id>https://jonasporto.github.io/pwt/blog/how-to-manage-ports-across-many-local-projects</id><content type="html" xml:base="https://jonasporto.github.io/pwt/blog/how-to-manage-ports-across-many-local-projects/"><![CDATA[<p>Dozens of small projects accumulate on a laptop, generated faster than
ever, and every framework template ships with the same default: 3000,
8000, 8888. Nothing coordinates them, so the question “which port does
this project use” has no answer except trying one.</p>

<p>The ask that follows is always some version of “a central registry and
routing for all this”. That sounds like one feature. It is two, at
different layers, and every tool solves one and leaves the other, which
is why nothing ever feels finished:</p>

<ul>
  <li><strong>Allocation</strong> decides which number a project gets. It is bookkeeping.</li>
  <li><strong>Naming</strong> decides what you type in the browser. It is a proxy.</li>
</ul>

<h2 id="layer-1-is-bookkeeping-and-scanning-is-not-it">Layer 1 is bookkeeping, and scanning is not it</h2>

<p>The registry question is “who owns 8001”. The universal answer, and the
one every framework and helper script implements, is to scan: try a port,
see if it is bound, take the next one if it is. <code class="language-plaintext highlighter-rouge">devenv</code>’s
<a href="https://devenv.sh/processes/"><code class="language-plaintext highlighter-rouge">ports.&lt;n&gt;.allocate</code></a> does a careful
version of this, holding ports during evaluation to avoid races.</p>

<p>Scanning has one blind spot, and it is fatal for this scenario: <strong>a
stopped server still owns its port.</strong> Thirty projects on a laptop are
not running at once; most are asleep. Every scan looks at a machine where
almost nothing is bound and concludes almost everything is free, so two
projects get the same number and the collision is deferred to whichever
morning you start both.</p>

<p>I had this exact hole in my own tool, which allocated per project and
consulted the live machine. Two projects, both configured to start at
8000, nothing running:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>$ # project chatapp, then project dashboard
   chatapp    feature-a    8001
   dashboard  feature-b    8001
</code></pre></div></div>

<p>Both correct by their own logic, both wrong. A port is a machine
resource, so the record has to be machine-wide: allocation must consult
what every project has already been given, whether or not anything is
listening today. After the fix, on the same machine:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>$ pwt ports
PORT    PROJECT     WORKTREE      STATUS
8000    chatapp     @             conflict
8000    dashboard   @             conflict
8001    chatapp     feature-a     conflict
8001    dashboard   feature-b     conflict
8002    chatapp     feature-c     -
8003    dashboard   feature-d     -
8004    chatapp     feature-e     -

Two records share a port. Fix with: pwt fix-port &lt;worktree&gt;
</code></pre></div></div>

<p>New allocations step around every other project’s numbers, and the
pre-existing overlaps are <em>reported</em> rather than quietly inherited. That
listing is the “central registry for this shit”: one command, every
project, plus who is actually listening right now.</p>

<p>The important part is not the tool, it is the shape: <strong>a registry is a
file you write to when you hand out a port, not a scan you run when you
need one.</strong> Anything that only scans will keep handing out duplicates to
a machine full of sleeping apps.</p>

<p>Repairing an old overlap has one wrinkle, and it decides whether the fix
is free. Two patterns exist in the wild:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Read at runtime: the port is whatever the tool says, today</span>
server<span class="o">()</span> <span class="o">{</span> <span class="nb">exec env </span><span class="nv">PORT</span><span class="o">=</span><span class="s2">"</span><span class="nv">$PWT_PORT</span><span class="s2">"</span> mix phx.server<span class="p">;</span> <span class="o">}</span>

<span class="c"># Baked at setup: the number is written into generated files</span>
<span class="c"># .env       DEFAULT_URL=localhost:5001</span>
<span class="c">#            WORKTREE_DB_SUFFIX=_wt5001</span>
<span class="c"># Procfile   rails s -p 5001</span>
</code></pre></div></div>

<p>Moving a project of the first kind is a metadata edit and nothing else.
Moving one of the second kind leaves generated files pointing at the old
number until the setup hook runs again, and when a <strong>database name</strong> is
derived from the port, as in that second block, reallocating quietly
points the app at a different database. Check which kind you have before
repairing a port that something already generated files from.</p>

<p>Repairing an old overlap has one wrinkle worth knowing, and it decides
whether the fix is free or not. Two patterns exist in the wild:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Read at runtime: the port is whatever the tool says, today</span>
server<span class="o">()</span> <span class="o">{</span> <span class="nb">exec env </span><span class="nv">PORT</span><span class="o">=</span><span class="s2">"</span><span class="nv">$PWT_PORT</span><span class="s2">"</span> mix phx.server<span class="p">;</span> <span class="o">}</span>

<span class="c"># Baked at setup: the number is written into generated files</span>
<span class="c"># .env         DEFAULT_URL=localhost:5001</span>
<span class="c">#              WORKTREE_DB_SUFFIX=_wt5001</span>
<span class="c"># Procfile     rails s -p 5001</span>
</code></pre></div></div>

<p>Moving a project of the first kind is a metadata edit and nothing else.
Moving one of the second kind means the generated files still point at
the old number until the setup hook runs again, and if a <strong>database
name</strong> is derived from the port, as in that second block, reallocating
quietly points the app at a different database. Check which kind you
have before you fix a port that something already generated files from.</p>

<h2 id="layer-2-is-naming-and-it-cannot-be-solved-by-allocation">Layer 2 is naming, and it cannot be solved by allocation</h2>

<p>Even with a perfect registry you still have the other half of the
complaint: you have to remember that the dashboard is 8003. The fixes for that live
one layer up, and they are genuinely different tools:</p>

<table>
  <thead>
    <tr>
      <th>Approach</th>
      <th>Gives you</th>
      <th>Costs</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Path routing (<code class="language-plaintext highlighter-rouge">/dashboard</code>)</td>
      <td>one host, one port</td>
      <td>apps must tolerate a path prefix; many break on absolute URLs</td>
    </tr>
    <tr>
      <td>Per-app subdomain (<code class="language-plaintext highlighter-rouge">app.localhost</code>)</td>
      <td>clean names, separate cookie jars</td>
      <td>a proxy; <code class="language-plaintext highlighter-rouge">*.localhost</code> works in Chrome and Firefox without <code class="language-plaintext highlighter-rouge">/etc/hosts</code></td>
    </tr>
    <tr>
      <td>CNAME / real DNS</td>
      <td>works off-machine</td>
      <td>DNS records and certificates for a laptop</td>
    </tr>
    <tr>
      <td>One stable port, repointed</td>
      <td>one address that never changes</td>
      <td>only one app is live at a time</td>
    </tr>
  </tbody>
</table>

<p>The last row is the one people skip, and it is the right shape when the
thing on the other end is not you: an OAuth callback, a webhook, a device
on your network. Those store <strong>one</strong> address and have no opinion about
your ports, which is
<a href="/pwt/blog/your-oauth-callback-accepts-one-url-you-have-six-worktrees/">its own post</a>.</p>

<p>Notice that none of these rows <em>allocate</em> anything. A proxy routes a name
to a port that something else decided. That is why the two layers keep
getting conflated and nothing feels solved: tools that name (portless,
Port Zero, Caddy setups) do not stop two apps from claiming 8000, and
tools that allocate (devenv, per-project config) do not give you a name
worth typing.</p>

<h2 id="the-honest-scope">The honest scope</h2>

<p>For a laptop with dozens of unrelated projects, the registry half is the
half that pays: you need the machine to remember
what it handed out, and you need to see the map. That is <code class="language-plaintext highlighter-rouge">pwt ports</code>, and
the allocation behind it, in
<a href="https://github.com/jonasporto/pwt">pwt</a>.</p>

<p>The honest caveat: pwt is a git worktree manager. The port registry is
real and now machine-wide, but you get it by registering a project with
<code class="language-plaintext highlighter-rouge">pwt init</code>, which earns its keep when you also want per-worktree setup,
servers and jobs. If all you want is a port broker for 35 loose folders,
this is a bigger tool than the problem. What I would keep from it,
whatever you use, is the rule: <strong>write down the allocation, do not scan
for it.</strong></p>

<p><code class="language-plaintext highlighter-rouge">brew install jonasporto/pwt/pwt</code>, then <code class="language-plaintext highlighter-rouge">pwt ports</code>
(<a href="/pwt/docs/commands/#ports">reference</a>).</p>]]></content><author><name>Jonas Porto</name></author><category term="ports" /><category term="cli" /><category term="agents" /><summary type="html"><![CDATA[Scanning finds a port that is free right now, which is why two projects end up with the same number: a sleeping app still owns one. Allocation is bookkeeping, naming is a proxy, and they are different problems.]]></summary></entry><entry><title type="html">What is using port 5000 on your Mac (and why your tools call it a running server)</title><link href="https://jonasporto.github.io/pwt/blog/what-is-using-port-5000-on-your-mac/" rel="alternate" type="text/html" title="What is using port 5000 on your Mac (and why your tools call it a running server)" /><published>2026-08-17T00:00:00+00:00</published><updated>2026-08-17T00:00:00+00:00</updated><id>https://jonasporto.github.io/pwt/blog/what-is-using-port-5000-on-your-mac</id><content type="html" xml:base="https://jonasporto.github.io/pwt/blog/what-is-using-port-5000-on-your-mac/"><![CDATA[<p>Here is what owns port 5000 on my Mac:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>$ lsof -nP -iTCP:5000 -sTCP:LISTEN
COMMAND         PID   USER  TYPE  NAME
ControlCenter   649  jonas  IPv4  *:5000 (LISTEN)
</code></pre></div></div>

<p>That is macOS itself: <strong>AirPlay Receiver</strong>, added in Monterey and enabled
silently for anyone who upgraded, with Control Center holding the socket.
It takes 7000 as well. Of the 156 listening sockets on this machine,
those two are the only ones below 10000 owned by a system daemon, and
both belong to that one process.</p>

<p>The Flask documentation states it outright, in the “Address already in
use” section: <em>“macOS Monterey and later automatically starts a service
that uses port 5000.”</em></p>

<h2 id="it-does-not-just-occupy-the-port-it-answers">It does not just occupy the port. It answers.</h2>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>$ curl -i http://127.0.0.1:5000/
HTTP/1.1 403 Forbidden
Content-Length: 0
Server: AirTunes/860.7.1
</code></pre></div></div>

<p>Both ports answer, both with 403, both identifying as AirTunes. That is
the part every write-up about this skips, and the part that causes the
expensive failures. A TCP connect succeeds. An HTTP request gets a
response. Anything built on “can I reach this port” concludes your server
is up.</p>

<h2 id="whether-it-collides-with-you-depends-on-one-socket-option">Whether it collides with you depends on one socket option</h2>

<p>This surprised me, so I measured it instead of assuming. Same machine,
same daemon on <code class="language-plaintext highlighter-rouge">*:5000</code>, four attempts to bind:</p>

<table>
  <thead>
    <tr>
      <th>Bind</th>
      <th>Result</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">127.0.0.1:5000</code>, no <code class="language-plaintext highlighter-rouge">SO_REUSEADDR</code></td>
      <td><code class="language-plaintext highlighter-rouge">OSError: [Errno 48] Address already in use</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">127.0.0.1:5000</code>, with <code class="language-plaintext highlighter-rouge">SO_REUSEADDR</code></td>
      <td><strong>binds fine</strong></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">0.0.0.0:5000</code>, with <code class="language-plaintext highlighter-rouge">SO_REUSEADDR</code></td>
      <td><code class="language-plaintext highlighter-rouge">Errno 48</code></td>
    </tr>
    <tr>
      <td>Ruby <code class="language-plaintext highlighter-rouge">TCPServer.new('127.0.0.1', 5000)</code></td>
      <td><strong>binds fine</strong></td>
    </tr>
  </tbody>
</table>

<p>Control Center holds the <strong>wildcard</strong> address. On BSD, and therefore on
macOS, a socket with <code class="language-plaintext highlighter-rouge">SO_REUSEADDR</code> may bind a <em>specific</em> address while
another socket holds the wildcard on the same port. Linux does not allow
that. Ruby’s <code class="language-plaintext highlighter-rouge">TCPServer</code> sets <code class="language-plaintext highlighter-rouge">SO_REUSEADDR</code> for you; a raw Python socket
does not.</p>

<p>So “does AirPlay break my dev server” has no single answer. It depends on
your language’s socket defaults and on whether you bind localhost or all
interfaces. It also means <strong>a real server and the daemon can be listening
on the same port at the same time</strong>, which turns out to matter later.</p>

<p>The errors, when you do collide, measured here rather than copied:</p>

<table>
  <thead>
    <tr>
      <th>Stack</th>
      <th>What it prints</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Python</td>
      <td><code class="language-plaintext highlighter-rouge">OSError: [Errno 48] Address already in use</code> (Linux says 98)</td>
    </tr>
    <tr>
      <td>Ruby</td>
      <td><code class="language-plaintext highlighter-rouge">Errno::EADDRINUSE: Address already in use - bind(2) for "0.0.0.0" port 5000</code></td>
    </tr>
    <tr>
      <td>Node</td>
      <td><code class="language-plaintext highlighter-rouge">Error: listen EADDRINUSE: address already in use :::5000</code></td>
    </tr>
    <tr>
      <td>Go</td>
      <td><code class="language-plaintext highlighter-rouge">listen tcp 0.0.0.0:5000: bind: address already in use</code></td>
    </tr>
  </tbody>
</table>

<p>Those are the honest failures. The interesting ones print nothing at all.</p>

<h2 id="four-ways-my-own-tool-got-this-wrong">Four ways my own tool got this wrong</h2>

<p>pwt allocates a port per worktree and reports what is running. Pointed at
a machine with AirPlay on, every reporting path was wrong:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>$ pwt ports
PORT    PROJECT   WORKTREE   STATUS
5000    app       feat       listening        # nothing is running

$ pwt server wait feat --timeout 600
Ready: feat (port 5000)                       # returned immediately

$ pwt info feat
  Server:  running (PID 649)                  # that is Control Center
</code></pre></div></div>

<p>And the one I am least proud of:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>$ pwt remove feat
Error: Processes detected on port 5000:
  PID 649 (/System/Library/CoreServices/ControlCenter.app/.../ControlCenter)

Options:
  pwt remove feat --kill-port    # Kill port processes
</code></pre></div></div>

<p>The removal was blocked by a process nobody should be killing, and the
suggested way out was <code class="language-plaintext highlighter-rouge">kill -9</code> on Control Center. I found that by
writing the test before the fix: run it against the old code and the
output reads <code class="language-plaintext highlighter-rouge">✓ Port 5000 freed</code>, right after the signal goes out.</p>

<p><code class="language-plaintext highlighter-rouge">pwt server wait</code> is the one that actually costs you. An agent starts a
server, waits for readiness, gets “Ready” instantly, and calls an
endpoint that belongs to AirTunes. The 403 comes back and the failure
surfaces three steps later, somewhere unrelated to the port.</p>

<h2 id="why-one-command-got-it-right-and-the-rest-did-not">Why one command got it right and the rest did not</h2>

<p>pwt already knew how to tell a system daemon from a dev server. The check
existed, correct and complete, in <strong>exactly one function</strong>, reached by
exactly one command: <code class="language-plaintext highlighter-rouge">pwt list -v</code> printed <code class="language-plaintext highlighter-rouge">[port 5000: system]</code> while
everything else printed <code class="language-plaintext highlighter-rouge">listening</code>.</p>

<p>That is the general shape of this bug and it deserves a name: <strong>a filter
that lives in one code path is not a filter, it is a coincidence.</strong> Each
later occupancy check was written by someone looking at the socket, not
at the function three files away that had already solved it. No amount of
care prevents that. Only a single callable place does.</p>

<h2 id="what-a-system-process-means-mechanically">What “a system process” means, mechanically</h2>

<p>You cannot ask the kernel whether a listener is “yours”. You can ask who
owns the pid:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>port_pid_is_system<span class="o">()</span> <span class="o">{</span>
    <span class="nv">cmd</span><span class="o">=</span><span class="si">$(</span>ps <span class="nt">-p</span> <span class="s2">"</span><span class="nv">$1</span><span class="s2">"</span> <span class="nt">-o</span> <span class="nb">command</span><span class="o">=</span> 2&gt;/dev/null<span class="si">)</span> <span class="o">||</span> <span class="k">return </span>1
    <span class="nv">exe</span><span class="o">=</span><span class="s2">"</span><span class="k">${</span><span class="nv">cmd</span><span class="p">%% *</span><span class="k">}</span><span class="s2">"</span>
    <span class="k">case</span> <span class="s2">"</span><span class="nv">$exe</span><span class="s2">"</span> <span class="k">in</span> /System/<span class="k">*</span> <span class="p">|</span> /usr/libexec/<span class="k">*</span><span class="p">)</span> <span class="k">return </span>0 <span class="p">;;</span> <span class="k">esac</span>
    <span class="k">case</span> <span class="s2">"</span><span class="k">${</span><span class="nv">exe</span><span class="p">##*/</span><span class="k">}</span><span class="s2">"</span> <span class="k">in
        </span>ControlCenter <span class="p">|</span> rapportd <span class="p">|</span> AirPlayXPCHelper <span class="p">|</span> sharingd<span class="p">)</span> <span class="k">return </span>0 <span class="p">;;</span>
    <span class="k">esac</span>
    <span class="k">return </span>1
<span class="o">}</span>
</code></pre></div></div>

<p>Two decisions in there carry weight.</p>

<p><strong>A port counts as system only when every listener on it is one.</strong> This
is where the <code class="language-plaintext highlighter-rouge">SO_REUSEADDR</code> measurement stops being trivia: a Ruby server
really can hold <code class="language-plaintext highlighter-rouge">127.0.0.1:5000</code> while Control Center holds <code class="language-plaintext highlighter-rouge">*:5000</code>.
Verified with both running at once, the port reports <code class="language-plaintext highlighter-rouge">listening</code>, which
is correct, because one of the two listeners is a server you started.</p>

<p><strong>Allocation is unchanged.</strong> A system-held port stays unavailable for new
worktrees, because you cannot reliably bind what Control Center holds.
The whole fix is about what gets <em>reported</em>, never about what gets handed
out. Making 5000 allocatable again would have been the obvious next step
and the wrong one: it hands a worktree a port whose server may fail to
start depending on how that server opens its socket.</p>

<p>Cost: one <code class="language-plaintext highlighter-rouge">ps</code> over every listener, once per command, 40ms with 151 of
them. Per-port classification instead would be a fork per worktree, which
is exactly what a snapshot exists to avoid.</p>

<h2 id="the-options-and-what-each-costs">The options, and what each costs</h2>

<p><strong>Turn AirPlay Receiver off.</strong> System Settings, General, AirDrop &amp;
Handoff. It frees both ports, and you lose the ability to AirPlay to that
Mac. If you never use it, do this and stop reading.</p>

<p><strong>Move your port.</strong> Correct, and more expensive than it looks. On one
project here the port is not just a flag: it is written into <code class="language-plaintext highlighter-rouge">.env</code>, into
the Procfile, and the database name derives from it, so moving the port
meant recreating a database. That is the argument for a tool that
allocates ports and <em>derives</em> the dependent values, instead of copying a
file that already contains a number.</p>

<p><strong>Teach the tool the difference.</strong> What I shipped in 0.2.9:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>$ pwt ports
PORT    PROJECT   WORKTREE   STATUS
5000    app       feat       system

system: held by a macOS daemon, not by a server of yours.
  AirPlay Receiver takes 5000 and 7000: turn it off in System
  Settings &gt; General &gt; AirDrop &amp; Handoff, or move the port with
  pwt fix-port &lt;worktree&gt;.
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">pwt server wait</code> now refuses immediately, with the reason, instead of
waiting out a ten-minute timeout on a port that can never become yours.</p>

<h2 id="one-myth-since-it-is-everywhere">One myth, since it is everywhere</h2>

<p>Several popular write-ups claim Flask changed its default port to 5001 on
macOS because of this. It did not. The current documentation (3.1.x)
still starts the server on <code class="language-plaintext highlighter-rouge">http://localhost:5000/</code> and tells you to
disable AirPlay Receiver or pass <code class="language-plaintext highlighter-rouge">--port 5001</code> yourself. Check the docs
of whatever you are actually running before trusting a fix you read
somewhere, this article included.</p>

<h2 id="the-rule-underneath">The rule underneath</h2>

<p>“Something is bound to this port” and “my server is running” are
different questions, and most tools answer the first while printing the
second. It stays invisible until a daemon you did not start occupies
exactly the port your framework defaults to, which on macOS is the
default case.</p>

<p><code class="language-plaintext highlighter-rouge">brew install jonasporto/pwt/pwt</code>, then <code class="language-plaintext highlighter-rouge">pwt ports</code>
(<a href="/pwt/docs/commands/#ports">reference</a>) to see every
allocation on the machine and who actually holds each one. Ports across
many projects are <a href="/pwt/blog/how-to-manage-ports-across-many-local-projects/">their own
post</a>,
and per-worktree allocation is
<a href="/pwt/blog/how-to-deal-with-port-allocation-in-git-worktrees/">here</a>.</p>]]></content><author><name>Jonas Porto</name></author><category term="ports" /><category term="macos" /><category term="cli" /><summary type="html"><![CDATA[AirPlay Receiver holds 5000 and 7000, answers HTTP 403 as AirTunes, and satisfies every readiness probe pointed at it. Whether it collides with your server depends on one socket option. Four false positives I found in my own tool, and the classifier that fixes all of them.]]></summary></entry><entry><title type="html">Your agent created the worktree. Now it does not run.</title><link href="https://jonasporto.github.io/pwt/blog/your-agent-created-the-worktree-now-it-does-not-run/" rel="alternate" type="text/html" title="Your agent created the worktree. Now it does not run." /><published>2026-08-17T00:00:00+00:00</published><updated>2026-08-17T00:00:00+00:00</updated><id>https://jonasporto.github.io/pwt/blog/your-agent-created-the-worktree-now-it-does-not-run</id><content type="html" xml:base="https://jonasporto.github.io/pwt/blog/your-agent-created-the-worktree-now-it-does-not-run/"><![CDATA[<p>Every parallel-agent workflow ends at the same wall. The agent creates a
worktree, starts working, and the first command fails: no <code class="language-plaintext highlighter-rouge">.env</code>, no
<code class="language-plaintext highlighter-rouge">node_modules</code>, and a dev server that wants the port another worktree is
already using.</p>

<p>This is not a bug in any of those tools. <code class="language-plaintext highlighter-rouge">git worktree add</code> checks out
<strong>tracked</strong> files, and everything that makes a checkout runnable is
untracked on purpose. The difference from the usual version of this
problem is who created the checkout: <strong>you did not</strong>. Your agent did, so
the advice that starts with “when you create the worktree” does not
apply.</p>

<h2 id="what-the-tools-give-you-and-where-each-stops">What the tools give you, and where each stops</h2>

<p><strong><code class="language-plaintext highlighter-rouge">.worktreeinclude</code></strong> (Claude Code) copies gitignored files into the new
worktree. It is the smallest possible fix and it works for secrets that
are identical everywhere. It cannot <em>derive</em> anything: every worktree
gets the same file, so the same <code class="language-plaintext highlighter-rouge">PORT=3000</code> and the same
<code class="language-plaintext highlighter-rouge">DATABASE_URL</code>. Copy a port into three worktrees and two of them will not
start.</p>

<p><strong>The <code class="language-plaintext highlighter-rouge">WorktreeCreate</code> hook</strong> (Claude Code, shipped since the feature
request that collected 29 reactions) does run your script when the agent
makes a worktree. It is the real answer, with one consequence the
feature request thread flagged immediately: your hook is expected to
<strong>create the worktree itself</strong> and print the path. You are not adding
setup to Claude’s worktree handling; you are replacing it.</p>

<p>That is fine if you have something that already knows how to create a
worktree properly. It is a lot to write from scratch.</p>

<p><strong>Nothing at all</strong> is what you get from every other path: a teammate’s
<code class="language-plaintext highlighter-rouge">git worktree add</code>, another agent tool, <code class="language-plaintext highlighter-rouge">claude-squad</code> (whose own issue
for this is still open), or the worktree you made by hand last Tuesday.</p>

<h2 id="measured-what-an-agent-created-worktree-is-missing">Measured: what an agent-created worktree is missing</h2>

<p>Three worktrees, created the way an agent creates them:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>$ git worktree add ../wts/fix-login    -b fix-login
$ git worktree add ../wts/add-search   -b add-search
$ git worktree add ../wts/refactor-api -b refactor-api

fix-login:     .env MISSING
add-search:    .env MISSING
refactor-api:  .env MISSING
</code></pre></div></div>

<p>Copying <code class="language-plaintext highlighter-rouge">.env</code> into each would fix the first failure and create the next
one, because all three would then hold <code class="language-plaintext highlighter-rouge">PORT=3000</code>.</p>

<h2 id="adopting-what-already-exists">Adopting what already exists</h2>

<p><code class="language-plaintext highlighter-rouge">pwt adopt</code> registers a checkout that someone else created: it allocates
a port, writes the metadata, and runs the project’s <code class="language-plaintext highlighter-rouge">setup()</code> hook with
<code class="language-plaintext highlighter-rouge">$PWT_PORT</code> bound. From inside the worktree:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>$ pwt adopt
Adopting worktree: agent-feature
  Branch: agent-feature
  Port:   4001

  ✓ Metadata saved
Running Pwtfile (setup)...
  ✓ Copied: .env
  ✓ Pwtfile (setup) completed

$ cat .env
PORT=4001
API_KEY=abc
</code></pre></div></div>

<p>The copy came from the hook; the <strong>4001 did not</strong>. The setup rewrote it
from the port pwt had just allocated, which is the difference between
copying config and deriving it.</p>

<p>For the pile that accumulated while you were not looking, <code class="language-plaintext highlighter-rouge">--all</code> takes
the whole directory:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>$ pwt adopt --all
Adopting: add-search
Adopting: fix-login
Adopting: refactor-api
Adopted: 3  Skipped (already registered): 0  Failed: 0

fix-login      -&gt; PORT=3002
add-search     -&gt; PORT=3001
refactor-api   -&gt; PORT=3003
</code></pre></div></div>

<p>Three worktrees, three ports, one command, and the ones already
registered are skipped rather than redone.</p>

<h2 id="if-you-do-want-the-hook">If you do want the hook</h2>

<p>The hook receives a JSON object on <strong>stdin</strong> (<code class="language-plaintext highlighter-rouge">base_path</code>,
<code class="language-plaintext highlighter-rouge">worktree_path</code>, <code class="language-plaintext highlighter-rouge">worktree_name</code>) and owns the creation: exit 0 and git
is never called, exit non-zero and the whole thing rolls back. So the
script creates the checkout where the agent asked for it, and hands the
rest to <code class="language-plaintext highlighter-rouge">adopt</code>:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">#!/usr/bin/env bash</span>
<span class="c"># .claude/hooks/worktree-create.sh</span>
<span class="nv">input</span><span class="o">=</span><span class="si">$(</span><span class="nb">cat</span><span class="si">)</span>
<span class="nv">base</span><span class="o">=</span><span class="si">$(</span>jq <span class="nt">-r</span> .base_path     <span class="o">&lt;&lt;&lt;</span><span class="s2">"</span><span class="nv">$input</span><span class="s2">"</span><span class="si">)</span>
<span class="nv">wt</span><span class="o">=</span><span class="si">$(</span>jq   <span class="nt">-r</span> .worktree_path <span class="o">&lt;&lt;&lt;</span><span class="s2">"</span><span class="nv">$input</span><span class="s2">"</span><span class="si">)</span>

git <span class="nt">-C</span> <span class="s2">"</span><span class="nv">$base</span><span class="s2">"</span> worktree add <span class="s2">"</span><span class="nv">$wt</span><span class="s2">"</span> <span class="nt">-b</span> <span class="s2">"</span><span class="si">$(</span><span class="nb">basename</span> <span class="s2">"</span><span class="nv">$wt</span><span class="s2">"</span><span class="si">)</span><span class="s2">"</span> <span class="o">&gt;</span>&amp;2 <span class="o">||</span> <span class="nb">exit </span>1
pwt <span class="nt">--no-input</span> adopt <span class="s2">"</span><span class="nv">$wt</span><span class="s2">"</span> <span class="o">&gt;</span>&amp;2 <span class="o">||</span> <span class="nb">exit </span>1     <span class="c"># port, metadata, setup()</span>
<span class="nb">exit </span>0
</code></pre></div></div>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w"> </span><span class="nl">"hooks"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"WorktreeCreate"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"matcher"</span><span class="p">:</span><span class="w"> </span><span class="s2">"*"</span><span class="p">,</span><span class="w"> </span><span class="nl">"hooks"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
  </span><span class="p">{</span><span class="w"> </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"command"</span><span class="p">,</span><span class="w"> </span><span class="nl">"command"</span><span class="p">:</span><span class="w"> </span><span class="s2">".claude/hooks/worktree-create.sh"</span><span class="w"> </span><span class="p">}</span><span class="w"> </span><span class="p">]</span><span class="w"> </span><span class="p">}</span><span class="w"> </span><span class="p">]</span><span class="w"> </span><span class="p">}</span><span class="w"> </span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>Two details that are easy to get wrong. Everything the script prints on
stdout is parsed as structured output, so send progress to stderr.
And <code class="language-plaintext highlighter-rouge">--no-input</code> closes stdin and sets <code class="language-plaintext highlighter-rouge">PWT_AGENT=1</code>, so a setup step
that would have asked a question fails instead of hanging a session
nobody is watching.</p>

<p>Note where the worktree lives: the agent picks the path, often inside
<code class="language-plaintext highlighter-rouge">.claude/worktrees/</code>, and <code class="language-plaintext highlighter-rouge">adopt</code> records that real path rather than
insisting on the project’s own directory. Verified: adopting a checkout
outside <code class="language-plaintext highlighter-rouge">worktrees_dir</code> allocates the port and runs <code class="language-plaintext highlighter-rouge">setup()</code> normally.</p>

<p>Worth being honest about the split: the hook covers the worktrees <em>that
agent</em> creates. <code class="language-plaintext highlighter-rouge">adopt</code> covers all the others, and there are always
others. They are not alternatives; the hook is the front door and
<code class="language-plaintext highlighter-rouge">adopt</code> is the one you use for everything that came in through a window.</p>

<h2 id="the-rule-underneath">The rule underneath</h2>

<p>A worktree is not ready because it exists. It is ready when the values
that must differ per worktree have been derived rather than copied:
the port, the database name, whatever your stack keys on. Which tool
created the directory is an implementation detail, and any workflow that
only works when <em>you</em> created it will break the first time an agent gets
there first.</p>

<p><code class="language-plaintext highlighter-rouge">brew install jonasporto/pwt/pwt</code>, then <code class="language-plaintext highlighter-rouge">pwt adopt</code> inside the worktree
your agent already made
(<a href="/pwt/docs/commands/#adopt">reference</a>). What
<code class="language-plaintext highlighter-rouge">setup()</code> should do with the port, the database and the dependency store
is <a href="/pwt/blog/how-to-copy-env-into-a-git-worktree-and-what-it-breaks/">its own post</a>.</p>]]></content><author><name>Jonas Porto</name></author><category term="worktrees" /><category term="agents" /><category term="env-config" /><category term="ports" /><summary type="html"><![CDATA[Coding agents create worktrees themselves, and a fresh checkout has no .env, no dependencies and no port of its own. The hook that fixes it makes you take over worktree creation, which is the part worth reading before you wire anything.]]></summary></entry><entry><title type="html">Cleaning up merged git worktrees without losing work</title><link href="https://jonasporto.github.io/pwt/blog/cleaning-up-merged-git-worktrees-without-losing-work/" rel="alternate" type="text/html" title="Cleaning up merged git worktrees without losing work" /><published>2026-08-16T00:00:00+00:00</published><updated>2026-08-16T00:00:00+00:00</updated><id>https://jonasporto.github.io/pwt/blog/cleaning-up-merged-git-worktrees-without-losing-work</id><content type="html" xml:base="https://jonasporto.github.io/pwt/blog/cleaning-up-merged-git-worktrees-without-losing-work/"><![CDATA[<p>Worktrees accumulate. Twenty directories in, you want the finished ones
gone, and the two commands everyone reaches for are
<code class="language-plaintext highlighter-rouge">git worktree remove</code> and <code class="language-plaintext highlighter-rouge">git branch --merged</code>. On one scratch repo with
four worktrees, both got it wrong: one destroyed uncommitted work, the
other could not see a merge that had already happened.</p>

<p>The four worktrees: <code class="language-plaintext highlighter-rouge">01-merged</code> (merged normally), <code class="language-plaintext highlighter-rouge">02-squashed</code> (merged
with squash, the way most PRs land), <code class="language-plaintext highlighter-rouge">03-open</code> (genuinely unfinished),
<code class="language-plaintext highlighter-rouge">04-dirty</code> (merged, but with an uncommitted file).</p>

<h2 id="git-branch---merged-cannot-see-a-squash-merge"><code class="language-plaintext highlighter-rouge">git branch --merged</code> cannot see a squash merge</h2>

<p>The canonical way to find finished branches:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>$ git branch --merged main
  TICKET-01-merged
  TICKET-04-dirty
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">TICKET-02-squashed</code> is missing, and its work is already in <code class="language-plaintext highlighter-rouge">main</code>.
Squash merge replays the changes as one <strong>new commit</strong>, so the branch’s
own commits are not ancestors of <code class="language-plaintext highlighter-rouge">main</code> and every ancestry-based check
calls it unmerged. Since most teams merge PRs with squash, the list you
would clean up is exactly the list of branches your workflow cannot
detect. Nothing errors; you just keep the directories forever.</p>

<p>This one has no free lunch, and I will not pretend otherwise: pwt’s
check is ancestry-based too, so it also reports <code class="language-plaintext highlighter-rouge">02-squashed</code> as pending.
The difference is what happens next. An ancestry check that says
“pending” and therefore <strong>keeps</strong> the directory is a false negative you
clean up by hand; a tool that guessed and deleted would be a false
positive you cannot undo. If you want squash-merged branches detected,
the reliable signal is the forge (<code class="language-plaintext highlighter-rouge">gh pr list --state merged</code>), not git
ancestry.</p>

<h2 id="git-worktree-remove-has-exactly-two-settings"><code class="language-plaintext highlighter-rouge">git worktree remove</code> has exactly two settings</h2>

<p>Point it at <code class="language-plaintext highlighter-rouge">04-dirty</code>, the merged worktree with an uncommitted file:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>$ git worktree remove .../TICKET-04-dirty
fatal: '.../TICKET-04-dirty' contains modified or untracked files,
use --force to delete it
</code></pre></div></div>

<p>Correct refusal, useless suggestion. Git tells you the one flag that
makes it work, so that is the flag everyone types:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>$ git worktree remove --force .../TICKET-04-dirty
$ cat .../TICKET-04-dirty/notes.txt
(gone)
</code></pre></div></div>

<p>The uncommitted file is not in any commit, not in any stash, not in the
reflog. <code class="language-plaintext highlighter-rouge">--force</code> is the only way past the refusal and it is unrecoverable.
“Refuse, or destroy” are the two options git offers, and in a directory
you already decided is finished, the refusal is the thing you are trying
to get past.</p>

<h2 id="the-sweep-with-the-classification-made-explicit">The sweep, with the classification made explicit</h2>

<p>The same repo, through <code class="language-plaintext highlighter-rouge">pwt auto-remove</code> (its alias is <code class="language-plaintext highlighter-rouge">pwt cleanup</code>),
which previews by default:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>$ pwt auto-remove main --dry-run
Checking worktrees merged into: main

  ✅ MERGED: TICKET-01-merged
  ⏳ PENDING: TICKET-02-squashed
  ⏳ PENDING: TICKET-03-open
  ⚠️  DIRTY: TICKET-04-dirty - merged but has uncommitted changes

[DRY-RUN] Would remove 1 worktree(s):
  - TICKET-01-merged

Would keep: 3
</code></pre></div></div>

<p>Three states instead of two. Merged and clean is removable. Pending is
kept. <strong>Merged but dirty is its own state</strong>, and it is the one that
matters: the branch is finished, so a merge-based sweep would delete it,
and the uncommitted file would go with it. It is reported and kept.</p>

<p>Running it for real removes exactly what the preview promised:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>$ pwt auto-remove main --execute
Removing: TICKET-01-merged
Done!
  Removed: 1
  Kept:    3
</code></pre></div></div>

<p>Two guards worth knowing. Non-interactively (an agent, a script, CI) the
command refuses to do anything without an explicit <code class="language-plaintext highlighter-rouge">--execute</code> or
<code class="language-plaintext highlighter-rouge">--dry-run</code>: a destructive sweep should never be what happens when
nobody was watching. And the branch is left alone; removing a checkout
and deleting history are different decisions, and <code class="language-plaintext highlighter-rouge">pwt remove
--with-branch</code> is where you say you meant the second one.</p>

<h2 id="when-you-do-delete-dirty-work-it-is-recoverable">When you do delete dirty work, it is recoverable</h2>

<p>Sometimes the answer really is “yes, remove it, I know it is dirty”.
That path keeps a copy:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>$ pwt remove TICKET-03-open -y
?? notes.txt
Proceeding due to -y flag (changes will be LOST)
  ✓ Metadata saved to ~/.pwt/trash/TICKET-03-open_20260816_165157.trash
  ✓ Untracked files backed up to ~/.pwt/trash/..._untracked/

$ pwt restore list
Available backups:
  TICKET-03-open  2026-08-16 16:51:57  [untracked]
     Branch: TICKET-03-open
</code></pre></div></div>

<p>The file I “lost” is sitting in the trash directory, and
<code class="language-plaintext highlighter-rouge">pwt restore TICKET-03-open</code> puts it back. This is the difference
between <code class="language-plaintext highlighter-rouge">--force</code> as an escape hatch and <code class="language-plaintext highlighter-rouge">--force</code> as a shredder: git
has no copy of an untracked file, so a tool that removes worktrees has
to make one before it deletes.</p>

<h2 id="the-shape-of-the-rule">The shape of the rule</h2>

<p>Cleanup is a classification problem wearing a deletion problem’s
clothes. For every finished-looking worktree there are three questions,
and the usual tools answer only the first:</p>

<table>
  <thead>
    <tr>
      <th>Question</th>
      <th><code class="language-plaintext highlighter-rouge">git worktree remove</code></th>
      <th><code class="language-plaintext highlighter-rouge">git branch --merged</code></th>
      <th>what you actually need</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Is the branch merged?</td>
      <td>does not ask</td>
      <td>ancestry only (misses squash)</td>
      <td>ancestry, plus the forge for squash</td>
    </tr>
    <tr>
      <td>Is there uncommitted work?</td>
      <td>refuses, or destroys</td>
      <td>does not ask</td>
      <td>keep it, or back it up first</td>
    </tr>
    <tr>
      <td>Was this what you meant?</td>
      <td>no preview</td>
      <td>no preview</td>
      <td>dry run by default</td>
    </tr>
  </tbody>
</table>

<p><code class="language-plaintext highlighter-rouge">pwt auto-remove</code> (alias <code class="language-plaintext highlighter-rouge">pwt cleanup</code>) is the sweep with those three
answered:
<a href="/pwt/docs/commands/#auto-remove">reference</a>,
<code class="language-plaintext highlighter-rouge">brew install jonasporto/pwt/pwt</code>. The <code class="language-plaintext highlighter-rouge">git worktree remove</code> refusal is
still correct behaviour; it just needed a tool that treats “merged but
dirty” as a real answer instead of an obstacle.</p>]]></content><author><name>Jonas Porto</name></author><category term="worktrees" /><category term="cli" /><category term="agents" /><summary type="html"><![CDATA[git worktree remove refuses dirty worktrees, --force destroys them, and git branch --merged cannot see a squash merge. I measured all three on one scratch repo, then wired the safe version.]]></summary></entry><entry><title type="html">How to run a command in every git worktree</title><link href="https://jonasporto.github.io/pwt/blog/how-to-run-a-command-in-every-git-worktree/" rel="alternate" type="text/html" title="How to run a command in every git worktree" /><published>2026-08-16T00:00:00+00:00</published><updated>2026-08-16T00:00:00+00:00</updated><id>https://jonasporto.github.io/pwt/blog/how-to-run-a-command-in-every-git-worktree</id><content type="html" xml:base="https://jonasporto.github.io/pwt/blog/how-to-run-a-command-in-every-git-worktree/"><![CDATA[<p>You have a handful of worktrees and one command to run in all of them:
the test suite, a <code class="language-plaintext highlighter-rouge">git status</code>, a dependency install. Git ships
<code class="language-plaintext highlighter-rouge">git submodule foreach</code> but never shipped <code class="language-plaintext highlighter-rouge">git worktree foreach</code>, so the
answer you will find is a hand-written loop. Here is the version that
actually behaves:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">status</span><span class="o">=</span>0
<span class="k">while </span><span class="nb">read</span> <span class="nt">-r</span> _ path<span class="p">;</span> <span class="k">do</span>
    <span class="o">(</span><span class="nb">cd</span> <span class="s2">"</span><span class="nv">$path</span><span class="s2">"</span> <span class="o">&amp;&amp;</span> ./scripts/test<span class="o">)</span> <span class="o">||</span> <span class="o">{</span> <span class="nb">echo</span> <span class="s2">"FAILED: </span><span class="nv">$path</span><span class="s2">"</span> <span class="o">&gt;</span>&amp;2<span class="p">;</span> <span class="nv">status</span><span class="o">=</span>1<span class="p">;</span> <span class="o">}</span>
<span class="k">done</span> &lt; &lt;<span class="o">(</span>git worktree list <span class="nt">--porcelain</span> | <span class="nb">grep</span> <span class="s2">"^worktree "</span><span class="o">)</span>
<span class="nb">exit</span> <span class="nv">$status</span>
</code></pre></div></div>

<p>Every part of that is load-bearing, and the rest of this post is the
evidence: I built a scratch project with three worktrees, broke the test
in one of them, and ran the four loop variants the internet suggests.
Three of the four got it wrong, each differently.</p>

<h2 id="variant-1-the-pipe-that-reports-and-then-lies">Variant 1: the pipe that reports and then lies</h2>

<p>The most common answer pipes into <code class="language-plaintext highlighter-rouge">while read</code> and appends an <code class="language-plaintext highlighter-rouge">|| echo</code>:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>$ git worktree list --porcelain | grep "^worktree " | while read -r _ path; do
    (cd "$path" &amp;&amp; ./scripts/test) || echo "failed: $path"
  done
failed: .../wts/TICKET-1102
$ echo $?
0
</code></pre></div></div>

<p>It <em>tells</em> you TICKET-1102 failed and then <strong>exits 0</strong>. The pipe runs the
loop in a subshell, so nothing you record inside it survives, and the
pipeline’s status is the loop’s, which succeeded. In CI this is a green
build with a broken worktree. The fix is the process substitution in the
version up top: the loop runs in your shell, so <code class="language-plaintext highlighter-rouge">status=1</code> sticks.</p>

<h2 id="variant-2-no-error-handling-with-and-without-set--e">Variant 2: no error handling, with and without set -e</h2>

<p>Drop the <code class="language-plaintext highlighter-rouge">||</code> entirely, which is how most one-off loops get typed:</p>

<ul>
  <li><strong>Without <code class="language-plaintext highlighter-rouge">set -e</code></strong>: the failure vanishes. Loop exits 0, nothing
printed, nothing recorded.</li>
  <li><strong>With <code class="language-plaintext highlighter-rouge">set -e</code></strong> (a script): the first failing worktree <strong>aborts the
whole sweep</strong>. In my run, TICKET-1102 failed and TICKET-1103 was never
visited. You do not learn whether the rest passes, which was the whole
question.</li>
</ul>

<p>Same loop, two shells, two different wrong answers.</p>

<h2 id="variant-3-awk-until-a-path-has-a-space">Variant 3: awk, until a path has a space</h2>

<p>Plenty of answers skip <code class="language-plaintext highlighter-rouge">--porcelain</code> and parse the human output:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>$ for path in $(git worktree list | awk '{print $1}'); do
    (cd "$path" &amp;&amp; ./scripts/test) || echo "failed: $path"
  done
cd: .../wts/hotfix: No such file or directory
failed: .../wts/hotfix
</code></pre></div></div>

<p>The worktree is called <code class="language-plaintext highlighter-rouge">hotfix urgente</code>. Word splitting cut it at the
space and the loop visited a directory that does not exist. One honest
footnote: the <code class="language-plaintext highlighter-rouge">--porcelain</code> + <code class="language-plaintext highlighter-rouge">read -r _ path</code> form survives spaces,
because <code class="language-plaintext highlighter-rouge">read</code> puts the rest of the line in its last variable; it is the
unquoted <code class="language-plaintext highlighter-rouge">$( )</code> and awk forms that break. Paths with newlines need
<code class="language-plaintext highlighter-rouge">--porcelain -z</code>, at which point the loop stops fitting in a comment box.</p>

<h2 id="the-entry-nobody-filters">The entry nobody filters</h2>

<p><code class="language-plaintext highlighter-rouge">git worktree list</code> includes the <strong>main checkout</strong> as its first entry.
Every loop above ran the command there too. Sometimes that is what you
want; often it silently is not (the command mutates state you meant to
keep clean in main). Either way, the loop does not ask.</p>

<h2 id="what-the-loop-looks-like-as-a-command">What the loop looks like as a command</h2>

<p><a href="https://github.com/jonasporto/pwt">pwt</a> ships the loop as
<code class="language-plaintext highlighter-rouge">pwt for-each</code>, with the failure handling the DIY versions kept getting
wrong. Same scratch project, same broken worktree:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>$ pwt for-each ./scripts/test
=== @ (main) ===
=== TICKET-1101 ===
=== TICKET-1102 ===
=== TICKET-1103 ===
✗ Command failed in 1 of 4 worktrees: TICKET-1102
$ echo $?
1
</code></pre></div></div>

<p>Every worktree gets a labeled header, the main checkout runs first and
is explicitly marked <code class="language-plaintext highlighter-rouge">@</code>, the sweep continues past failures, and the
exit is non-zero with the failing worktrees named. Each worktree also
gets its <code class="language-plaintext highlighter-rouge">PWT_*</code> environment, so the command can use the things pwt
already knows:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>pwt <span class="k">for</span><span class="nt">-each</span> <span class="s1">'curl -s localhost:$PWT_PORT/health'</span>   <span class="c"># each worktree's own port</span>
pwt <span class="k">for</span><span class="nt">-each</span> migrate                                <span class="c"># a Pwtfile function, per worktree</span>
</code></pre></div></div>

<p>The second form is the quiet superpower: if <code class="language-plaintext highlighter-rouge">migrate</code> is a function in
your <a href="/pwt/blog/how-to-copy-env-into-a-git-worktree-and-what-it-breaks/">Pwtfile</a>,
<code class="language-plaintext highlighter-rouge">for-each</code> runs it with each worktree’s port, branch and path filled in,
which is how “run the migration everywhere” stops needing any loop at
all.</p>

<p>Full disclosure, because this blog measures its own tool by the same
rules: until this week <code class="language-plaintext highlighter-rouge">pwt for-each</code> had variant 2’s bug. It printed a
checkmark and exited 0 no matter what failed. Writing this post is what
exposed it; the fix (aggregate per-worktree exit codes, name the
failures, exit non-zero) shipped with tests that were first run against
the old binary to prove they caught it.</p>

<p><code class="language-plaintext highlighter-rouge">brew install jonasporto/pwt/pwt</code>, and <code class="language-plaintext highlighter-rouge">pwt for-each</code> is there after a
<code class="language-plaintext highlighter-rouge">pwt init</code> (<a href="/pwt/docs/commands/#for-each">reference</a>).
The loop at the top of this post remains correct if you would rather own
it yourself.</p>]]></content><author><name>Jonas Porto</name></author><category term="worktrees" /><category term="cli" /><category term="bash" /><summary type="html"><![CDATA[Git has submodule foreach but no worktree foreach, so everyone writes the loop by hand. I tested the four versions the internet suggests: two hide failures, one aborts halfway, one breaks on a space.]]></summary></entry><entry><title type="html">How to add an existing repo to pwt</title><link href="https://jonasporto.github.io/pwt/blog/how-to-add-an-existing-repo-to-pwt/" rel="alternate" type="text/html" title="How to add an existing repo to pwt" /><published>2026-08-15T00:00:00+00:00</published><updated>2026-08-15T00:00:00+00:00</updated><id>https://jonasporto.github.io/pwt/blog/how-to-add-an-existing-repo-to-pwt</id><content type="html" xml:base="https://jonasporto.github.io/pwt/blog/how-to-add-an-existing-repo-to-pwt/"><![CDATA[<p>You have a repo already cloned and you want it managed by
<a href="https://github.com/jonasporto/pwt">pwt</a>: allocated ports, worktrees with
setup hooks, background jobs, and two-keystroke navigation. The whole
onboarding is one command, run inside the repo:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>$ cd ~/Projects/pwt-ui &amp;&amp; pwt init
✓ Configured: pwt-ui

path=/Users/jonasporto/Projects/pwt-ui
worktrees_dir=/Users/jonasporto/Projects/pwt-ui-worktrees
remote=git@github.com:jonasporto/pwt-ui.git
</code></pre></div></div>

<p>That output is not a summary of what happened. It is the <em>entirety</em> of
what happened: those three lines are the complete contents of the one
file pwt created, and this post is short because there is honestly not
much more to it.</p>

<h2 id="where-the-state-lives-and-where-it-does-not">Where the state lives, and where it does not</h2>

<p>Everything landed outside your repo, in <code class="language-plaintext highlighter-rouge">~/.pwt/projects/&lt;name&gt;/config</code>,
as plain <code class="language-plaintext highlighter-rouge">key=value</code> text. Inside the repo: nothing. No dotfile, no hook,
no config edit; <code class="language-plaintext highlighter-rouge">git status</code> before and after are identical. The
<code class="language-plaintext highlighter-rouge">worktrees_dir</code> was not even created yet, because it is not needed until
the first worktree is.</p>

<p>That asymmetry is deliberate, and it makes un-registering trivial:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">rm</span> <span class="nt">-rf</span> ~/.pwt/projects/pwt-ui   <span class="c"># pwt forgets the project; the repo never knew</span>
</code></pre></div></div>

<p>There is nothing to uninstall from the repo because nothing was installed
into it. If you try pwt for one project and hate it, the exit is one
<code class="language-plaintext highlighter-rouge">rm</code> of pwt’s own state.</p>

<p>The variant for a repo you have not cloned yet does both steps at once:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>pwt init git@github.com:you/app.git   <span class="c"># clone + register</span>
</code></pre></div></div>

<h2 id="the-part-you-will-use-fifty-times-a-day">The part you will use fifty times a day</h2>

<p>Registration buys you the navigation. With the shell integration active
(<code class="language-plaintext highlighter-rouge">eval "$(pwt shell-init)"</code> in your shell rc), the project name is now a
<code class="language-plaintext highlighter-rouge">cd</code> from anywhere:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>$ pwt pwt-ui        # from any directory: cd to the repo
$ pwt pwt-ui list   # or run commands against it without cd'ing
</code></pre></div></div>

<p>The name is still four syllables, so give it an alias:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>$ pwt alias ui
✓ pwt-ui → ui
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">pwt ui</code> from anywhere in the filesystem now lands in the repo, and
<code class="language-plaintext highlighter-rouge">pwt ui &lt;worktree&gt;</code> lands in a specific worktree of it. For a project you
enter dozens of times a day, this is the feature that pays the
registration back within the hour.</p>

<p>And the worktree name does not have to be the worktree name. Any unique
fragment matches, which in practice means the ticket number is the whole
address:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>$ pwt 1234                 # worktree is TICKET-1234-fix-login
~/Projects/app-worktrees/TICKET-1234-fix-login

$ pwt 1240                 # fragment matches two worktrees
Multiple matches for '1240':
  TICKET-1240-api
  TICKET-1240-api-retry
</code></pre></div></div>

<p>Unique fragment: you are there. Ambiguous fragment: pwt lists the
candidates and refuses to guess, which is the correct behavior at 4pm
with six worktrees named almost the same thing.</p>

<h2 id="what-the-registration-unlocks-later">What the registration unlocks later</h2>

<p>Nothing above touched worktrees, and that is the point: registration is
cheap and separate. When the project needs them, the machinery from the
earlier posts is already wired to the name:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">pwt create feature-x</code> gives the worktree
<a href="/pwt/blog/how-to-deal-with-port-allocation-in-git-worktrees/">a port that survives everything</a></li>
  <li>a <code class="language-plaintext highlighter-rouge">Pwtfile</code> in the repo root turns “set up a worktree” into
<a href="/pwt/blog/how-to-copy-env-into-a-git-worktree-and-what-it-breaks/">code that runs on every create</a>,
including <a href="/pwt/blog/how-to-gitignore-per-worktree/">worktree-only git excludes</a></li>
  <li><code class="language-plaintext highlighter-rouge">pwt server --bg</code> and <code class="language-plaintext highlighter-rouge">pwt jobs wait</code> give agents
<a href="/pwt/blog/your-agent-finished-how-would-you-know/">something better than polling</a></li>
</ul>

<p>The repo in the transcript is real: pwt-ui, the terminal dashboard for
pwt I am building, is now managed by the tool it fronts. Registering it
took under a minute, and dogfooding it immediately caught a real bug
(<code class="language-plaintext highlighter-rouge">pwt init</code> through the shell wrapper registered a ghost project named
after an internal command; fixed, with a regression test, before this
post went up). Adding your repo to the tool that manages your repos is
apparently also a test suite.</p>

<p><code class="language-plaintext highlighter-rouge">brew install jonasporto/pwt/pwt</code>, then <code class="language-plaintext highlighter-rouge">pwt init</code> inside the repo you
use most. The alias goes in the same minute.</p>]]></content><author><name>Jonas Porto</name></author><category term="cli" /><category term="worktrees" /><summary type="html"><![CDATA[One command inside the repo, three lines of state outside it, nothing written into your project. What pwt init actually does, how to navigate with a two-letter alias, and how to undo all of it.]]></summary></entry><entry><title type="html">How to gitignore per worktree</title><link href="https://jonasporto.github.io/pwt/blog/how-to-gitignore-per-worktree/" rel="alternate" type="text/html" title="How to gitignore per worktree" /><published>2026-08-15T00:00:00+00:00</published><updated>2026-08-15T00:00:00+00:00</updated><id>https://jonasporto.github.io/pwt/blog/how-to-gitignore-per-worktree</id><content type="html" xml:base="https://jonasporto.github.io/pwt/blog/how-to-gitignore-per-worktree/"><![CDATA[<p>You want a file ignored in one worktree only: not committed to
<code class="language-plaintext highlighter-rouge">.gitignore</code>, not ignored everywhere on your machine, just invisible in
this one checkout. Here is the version that actually works:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git config extensions.worktreeConfig <span class="nb">true</span>         <span class="c"># once per repo</span>
git <span class="nt">-C</span> &lt;worktree&gt; config <span class="nt">--worktree</span> <span class="se">\</span>
    core.excludesFile /abs/path/to/exclude-file   <span class="c"># once per worktree</span>
<span class="nb">echo</span> <span class="s2">"debug.log"</span> <span class="o">&gt;&gt;</span> /abs/path/to/exclude-file
</code></pre></div></div>

<p>The rest of this post is why the popular answers fail, measured, and the
two gotchas inside the working one. Everything below was run today on git
2.39.5.</p>

<h2 id="the-file-everyone-cites-is-never-read">The file everyone cites is never read</h2>

<p>Search for this and the top answers, including a dedicated guide site and
well-starred gists, tell you a linked worktree has its own exclude file at
<code class="language-plaintext highlighter-rouge">.git/worktrees/&lt;name&gt;/info/exclude</code>. It sounds right: that directory is
the worktree’s private git dir, and <code class="language-plaintext highlighter-rouge">info/exclude</code> is where repo-local
ignores live. Measured:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>$ echo "debug.log" &gt; .git/worktrees/wt-a/info/exclude
$ touch wt-a/debug.log
$ git -C wt-a status --porcelain
?? debug.log
</code></pre></div></div>

<p><strong>Git does not read that path.</strong> Ignore patterns come from
<code class="language-plaintext highlighter-rouge">$GIT_COMMON_DIR/info/exclude</code>, the directory all worktrees share, and git
never consults a per-worktree <code class="language-plaintext highlighter-rouge">info/</code> for excludes. The failure mode is
the worst kind: nothing errors, the file just sits there, and you believe
the pattern is active until an agent runs <code class="language-plaintext highlighter-rouge">git add -A</code> and commits the
thing you “ignored”.</p>

<p>Two more non-answers, briefly. A different <code class="language-plaintext highlighter-rouge">.gitignore</code> per worktree does
not exist as a concept: <code class="language-plaintext highlighter-rouge">.gitignore</code> is tracked content, so it belongs to
the branch, follows every checkout of that branch, and any edit is one
<code class="language-plaintext highlighter-rouge">git add</code> away from being committed for the whole team. And the global
<code class="language-plaintext highlighter-rouge">core.excludesFile</code> is the opposite scope, every repo on the machine,
<a href="/pwt/blog/i-found-two-global-gitignores-on-my-machine/">which is its own trap</a>.</p>

<h2 id="the-almost-right-answer-one-file-for-all-worktrees">The almost-right answer: one file for all worktrees</h2>

<p><code class="language-plaintext highlighter-rouge">.git/info/exclude</code> in the common directory works and is never committed.
Its scope surprises people in the other direction:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>$ echo "debug.log" &gt;&gt; .git/info/exclude
$ git status --porcelain; git -C wt-a status --porcelain; git -C wt-b status --porcelain
(empty)  (empty)  (empty)
</code></pre></div></div>

<p>One line, ignored in the main checkout and in every worktree, current and
future. Before reaching for true per-worktree isolation, check whether
this is actually what you need: an ignore pattern for a file that does not
exist costs nothing, so the union of every worktree’s patterns in one
shared file behaves identically to per-worktree files in almost every
real case. You need real per-worktree scope only when the same filename
must be ignored in one worktree and <em>visible</em> in another.</p>

<h2 id="true-per-worktree-worktreeconfig">True per-worktree: worktreeConfig</h2>

<p>When you do need it, git has it, behind an extension:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>$ git config extensions.worktreeConfig true
$ git -C wt-a config --worktree core.excludesFile \
      "$PWD/.git/worktrees/wt-a/info/exclude"
$ git -C wt-a status --porcelain    # debug.log ignored here
(empty)
$ git -C wt-b status --porcelain    # and only here
?? debug.log
</code></pre></div></div>

<p>A nice touch: pointing <code class="language-plaintext highlighter-rouge">core.excludesFile</code> at
<code class="language-plaintext highlighter-rouge">.git/worktrees/&lt;name&gt;/info/exclude</code> makes the internet’s mythical file
real. Git still does not read it on its own; the <code class="language-plaintext highlighter-rouge">--worktree</code> config is
what wires it in, and the path is simply a sensible place to keep the
patterns, since it dies with the worktree.</p>

<p>Two gotchas cost me a retake each:</p>

<p><strong>The path must be absolute.</strong> My first attempt used a relative path and
nothing was ignored, silently: a relative <code class="language-plaintext highlighter-rouge">core.excludesFile</code> resolves
against wherever the command runs, not against the git dir.</p>

<p><strong>Per-worktree <code class="language-plaintext highlighter-rouge">core.excludesFile</code> replaces the outer one.</strong> The key is
single-valued, so the most specific scope wins alone; it does not stack:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>-- repo-level excludesFile ignores *.tmp
-- wt-a sets a worktree-level excludesFile for debug.log
$ git -C wt-b status --porcelain | grep tmp    # still ignored
(empty)
$ git -C wt-a status --porcelain | grep tmp    # resurfaced
?? x.tmp
</code></pre></div></div>

<p>The worktree that customized its excludes lost every pattern from the
level above. If you use this, copy the outer patterns into the
per-worktree file, and remember the config is machine-local state: a new
clone, a teammate, a recreated worktree all start from zero.</p>

<h2 id="declaring-it-once-in-the-repo">Declaring it once, in the repo</h2>

<p>For the common case, the shared <code class="language-plaintext highlighter-rouge">info/exclude</code>, pwt can own the writing.
The project’s <code class="language-plaintext highlighter-rouge">Pwtfile</code> (which <em>is</em> committed) declares the patterns, and
every <code class="language-plaintext highlighter-rouge">pwt create</code> applies them idempotently:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Pwtfile</span>
setup<span class="o">()</span> <span class="o">{</span>
    pwtfile_git_exclude <span class="s2">"pnpm-lock.yaml"</span> <span class="s2">"pnpm-workspace.yaml"</span> <span class="s2">".claude/"</span>
<span class="o">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">pwtfile_git_exclude</code> (pwt ≥ 0.2.2) appends each pattern to the common
<code class="language-plaintext highlighter-rouge">.git/info/exclude</code> only if it is not already there, so the declaration is
safe to run on every worktree creation, and a fresh machine converges the
first time it creates a worktree. The knowledge travels in the commit; the
ignore state stays out of it.</p>

<h2 id="what-this-looks-like-with-an-agent">What this looks like with an agent</h2>

<p>The honest summary of this post is “git has four ignore layers, one famous
path is fake, one config key silently replaces another, and paths must be
absolute”. Nobody should keep that in their head. Stated as an outcome
instead:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>you&gt; ignore the generated pnpm files in every worktree,
     without committing anything
agent&gt; runs pwt skill, reads the guide, adds
       pwtfile_git_exclude "pnpm-lock.yaml" "pnpm-workspace.yaml"
       to setup(), creates a throwaway worktree, checks git status
</code></pre></div></div>

<p><a href="https://github.com/jonasporto/pwt">pwt</a> is a git worktree manager for
parallel development: <code class="language-plaintext highlighter-rouge">brew install jonasporto/pwt/pwt</code>. The exclude
helper landed in v0.2.2, alongside the
<a href="/pwt/blog/how-to-deal-with-port-allocation-in-git-worktrees/">port allocation</a>
and <a href="/pwt/blog/how-to-copy-env-into-a-git-worktree-and-what-it-breaks/">setup hooks</a>
from earlier posts.</p>]]></content><author><name>Jonas Porto</name></author><category term="worktrees" /><category term="env-config" /><category term="cli" /><summary type="html"><![CDATA[The .git/worktrees//info/exclude file every answer cites is never read by git. Here is the measurement, the method that actually works, and the two gotchas inside it.]]></summary></entry><entry><title type="html">I found two global gitignores on my machine. Both were the wrong layer.</title><link href="https://jonasporto.github.io/pwt/blog/i-found-two-global-gitignores-on-my-machine/" rel="alternate" type="text/html" title="I found two global gitignores on my machine. Both were the wrong layer." /><published>2026-08-15T00:00:00+00:00</published><updated>2026-08-15T00:00:00+00:00</updated><id>https://jonasporto.github.io/pwt/blog/i-found-two-global-gitignores-on-my-machine</id><content type="html" xml:base="https://jonasporto.github.io/pwt/blog/i-found-two-global-gitignores-on-my-machine/"><![CDATA[<p>Modern worktrees fill up with files that must exist and must never be
committed: agent configuration (<code class="language-plaintext highlighter-rouge">.claude/</code>, <code class="language-plaintext highlighter-rouge">AGENTS.md</code>, <code class="language-plaintext highlighter-rouge">.mcp.json</code>), a
<a href="/pwt/blog/the-node-modules-store-i-deleted-is-now-a-pnpm-feature/">derived <code class="language-plaintext highlighter-rouge">pnpm-lock.yaml</code> in a yarn project</a>,
generated Procfiles, review-tool state. The standard answer is a global
gitignore in <code class="language-plaintext highlighter-rouge">core.excludesFile</code>, and that is what I had. This week I
audited it before retiring it, and the audit is the argument.</p>

<p>What I found on one machine: <strong>two</strong> global ignore files with identical
content, because a conditional include (<code class="language-plaintext highlighter-rouge">includeIf "gitdir:..."</code>) pointed
work repos at a second copy. <code class="language-plaintext highlighter-rouge">core.excludesFile</code> is single-valued, so the
second file did not add to the first; it silently replaced it. The two
files matched today only because one started as a copy of the other, and
nothing would have kept them matching. Together they were hiding <strong>36
files across 10 repos</strong>, and in one repo they were hiding a directory the
repo deliberately <strong>tracks</strong> ten files inside.</p>

<p>That last one is the real cost. Ignore rules never affect tracked files,
so nothing broke. But the next intentional file of that name, in the one
repo where it belongs in a commit, is born invisible: <code class="language-plaintext highlighter-rouge">git status</code> will
not mention it, and you will not commit it, and nothing will tell you.</p>

<h2 id="the-four-layers-and-what-each-is-for">The four layers, and what each is for</h2>

<table>
  <thead>
    <tr>
      <th>Layer</th>
      <th>Committed?</th>
      <th>Scope</th>
      <th>Travels with</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">.gitignore</code></td>
      <td>yes</td>
      <td>this repo, everyone</td>
      <td>the repo</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">.git/info/exclude</code></td>
      <td>never</td>
      <td>this repo, this machine</td>
      <td>nothing (but see below)</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">core.excludesFile</code> (global)</td>
      <td>never</td>
      <td>every repo on the machine</td>
      <td>nothing</td>
    </tr>
    <tr>
      <td>per-worktree, via <code class="language-plaintext highlighter-rouge">extensions.worktreeConfig</code></td>
      <td>never</td>
      <td>one worktree</td>
      <td>nothing</td>
    </tr>
  </tbody>
</table>

<p>Two facts decide everything:</p>

<p><strong>Layers are additive, but <code class="language-plaintext highlighter-rouge">core.excludesFile</code> is one value.</strong> All four
sources combine when git decides what to ignore. But the global layer is a
single config key: set it again in an included config and the old file
stops applying entirely. That is how machines end up with two registries
and nobody noticing.</p>

<p><strong><code class="language-plaintext highlighter-rouge">.git/info/exclude</code> lives in the repo’s common directory.</strong> A worktree’s
<code class="language-plaintext highlighter-rouge">.git</code> is a pointer file; the real directory, including <code class="language-plaintext highlighter-rouge">info/exclude</code>, is
shared by every worktree of the repo. One line in that file ignores the
pattern in the main checkout and in all worktrees, current and future, and
can never reach a commit because git does not track its own metadata.</p>

<p>That second fact makes <code class="language-plaintext highlighter-rouge">info/exclude</code> the right home for exactly the
files this post is about: repo-specific, machine-local, worktree-borne.
The global file is the right home for almost nothing: the classic
<code class="language-plaintext highlighter-rouge">.DS_Store</code> case, and even that is arguable.</p>

<p>The fourth layer exists but read the fine print before wanting it:
per-worktree config requires <code class="language-plaintext highlighter-rouge">extensions.worktreeConfig</code>, and a
per-worktree <code class="language-plaintext highlighter-rouge">core.excludesFile</code> <em>replaces</em> the global one for that
worktree instead of adding to it. Since ignore patterns for files that do
not exist are free, I have yet to find a case where the union of patterns
in <code class="language-plaintext highlighter-rouge">info/exclude</code> is not simpler.</p>

<h2 id="migrating-off-the-global-mechanically">Migrating off the global, mechanically</h2>

<p>The dangerous move is deleting the global first: the moment it stops
applying, every file it was hiding shows up as <code class="language-plaintext highlighter-rouge">??</code> in every repo at once,
and an agent running <code class="language-plaintext highlighter-rouge">git add -A</code> will happily commit your agent config
into a work repo. So the order is: write the per-repo entries first, prove
they cover everything, delete the global last.</p>

<p>No guessing is needed at any step. Git will tell you exactly what the
global is hiding, per repo:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># what status looks like today vs. with no global ignore</span>
diff &lt;<span class="o">(</span>git status <span class="nt">--porcelain</span><span class="o">)</span> <span class="se">\</span>
     &lt;<span class="o">(</span>git <span class="nt">-c</span> core.excludesFile<span class="o">=</span>/dev/null status <span class="nt">--porcelain</span><span class="o">)</span>

<span class="c"># for each path that appeared: which pattern (and which file) hid it</span>
git check-ignore <span class="nt">-v</span> <span class="nt">--</span> &lt;path&gt;
</code></pre></div></div>

<p>Every line the diff produces is a fact: this repo depends on that global
pattern. <code class="language-plaintext highlighter-rouge">check-ignore -v</code> names the pattern and the file it came from,
which is also how I discovered registry number two. The migration is then:
append exactly those patterns to that repo’s <code class="language-plaintext highlighter-rouge">.git/info/exclude</code>, re-run
the diff everywhere until it is empty everywhere, and only then empty the
global file. I ran this across 90+ checkouts; the whole thing is an hour,
most of it waiting on <code class="language-plaintext highlighter-rouge">git status</code>.</p>

<p>The repos that needed nothing got nothing, which is the point. A global
pattern is a claim about every repo you will ever clone. A line in
<code class="language-plaintext highlighter-rouge">info/exclude</code> is a claim about one repo, and it can be wrong without
poisoning the others.</p>

<h2 id="making-it-reproducible">Making it reproducible</h2>

<p><code class="language-plaintext highlighter-rouge">info/exclude</code> has one genuine weakness: it travels with nothing. A new
machine, a teammate, a re-clone all start from an empty file, and the
knowledge of what to exclude lives nowhere.</p>

<p>So give it a source of truth that is committed, without committing the
excludes themselves. As of this week, a
<a href="https://github.com/jonasporto/pwt">pwt</a> Pwtfile can declare them:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Pwtfile - setup() runs on every `pwt create`</span>
setup<span class="o">()</span> <span class="o">{</span>
    pwtfile_git_exclude <span class="s2">"pnpm-lock.yaml"</span> <span class="s2">"pnpm-workspace.yaml"</span> <span class="s2">".claude/"</span>
<span class="o">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">pwtfile_git_exclude</code> appends each pattern to the repo’s common
<code class="language-plaintext highlighter-rouge">.git/info/exclude</code>, idempotently, so declaring it on every create costs
nothing and a fresh machine converges on the first worktree it creates.
The Pwtfile is committed; the exclude file never is. The declaration
travels, the state stays local.</p>

<blockquote>
  <p>Running agents in your worktrees? This failure is agent-shaped on both
ends: agents generate exactly the files that need excluding, and agents
run <code class="language-plaintext highlighter-rouge">git add -A</code> without reading the status first. A pattern declared in
the Pwtfile closes the loop before either happens.</p>
</blockquote>

<p><code class="language-plaintext highlighter-rouge">pwtfile_git_exclude</code> ships in pwt as of this week, alongside the port
allocation and setup hooks from the
<a href="/pwt/blog/how-to-deal-with-port-allocation-in-git-worktrees/">earlier</a>
<a href="/pwt/blog/how-to-copy-env-into-a-git-worktree-and-what-it-breaks/">posts</a>:
<code class="language-plaintext highlighter-rouge">brew install jonasporto/pwt/pwt</code>.</p>]]></content><author><name>Jonas Porto</name></author><category term="worktrees" /><category term="env-config" /><category term="agents" /><summary type="html"><![CDATA[Agent configs, derived lockfiles, generated files: every worktree needs them, no commit should contain them. The usual answer is a global gitignore, and mine was hiding 36 files across 10 repos, one of which tracks the files it hid.]]></summary></entry></feed>