62 processes to print a version number
pwt version used to take 306ms. Printing a version string is not hard
work, so the time had to be going somewhere else. It was: before reaching
the echo, the script spawned 62 jq processes.
Both numbers are from today, not from memory. I extracted the old commit
with git archive, rebuilt the old state directory from the .v1.bak
backups the migration left behind, and ran the two versions against the same
12 projects and 110 worktrees on the same machine (Apple Silicon, bash 5.3).
Counting the spawns took a shim earlier in PATH:
#!/bin/bash
echo x >> "$JQ_COUNT_FILE"
exec /opt/homebrew/bin/jq "$@"
pre-index code, `pwt version`: 306 ms, 62 jq spawns
pre-index code, `pwt list`: 322 ms, 63 jq spawns
This post is what removing those took, because the ending surprised me: the
speed came from one change, and deleting jq came from another one that
made a cold path measurably slower.
Where 62 spawns come from
pwt kept its state as JSON: one meta.json for every worktree of every
project, plus a config.json per project. Every project lookup read two or
three fields, and every field read was its own jq invocation. A command
that resolves the current project, its worktrees dir and its branch prefix
paid for all of them.
jq is not slow. Parsing a 40KB file is microseconds. What costs is
fork + exec + process teardown, about 5ms each on this machine, sixty
times over, for data that was already in a file the shell could read.
The prompt made it worse. pwt ps1 runs on every prompt redraw, and it was
paying the same tax.
Fix one: an index, and the field separator that ate my columns
The first version was a cache. One jq pass over all project configs
produces one line per project, written to $PWT_DIR/cache/project-index,
loaded afterwards with zero spawns. Staleness is checked by comparing mtimes
against the configs plus a count check, so adding, editing or removing a
project invalidates it.
with index, warm cache, `pwt version`: 54 ms, 0 jq spawns
with index, cold cache, `pwt version`: 73 ms, 1 jq spawn
306ms to 54ms, and the prompt stopped spawning anything at all.
One detail from that change is worth stealing. The index rows join their
fields with the ASCII unit separator, 0x1f, not with a tab:
while IFS=$'\037' read -r name path wt_dir prefix _rest; do
Tab is IFS whitespace in bash, and read collapses runs of IFS whitespace.
A project with an empty branch prefix would silently shift every later field
one column to the left. With 0x1f each empty field stays an empty field.
What the cache did not fix
Two things survived it.
jq was still a hard dependency of a tool that otherwise needs bash, git
and coreutils. 132 call sites across the source. A user without jq had a
broken install, and the failure showed up in whatever command touched state
first.
And the cache was now a second source of truth. Every write path had to remember to invalidate it. That is a bug class, not a bug: it stays quiet until the one path that forgot.
Fix two: no JSON to parse
So state v2 dropped JSON as the storage format. One flat key=value file
per record, one record per worktree:
# ~/.pwt/state/demo-app/TICKET-123.meta
path=/tmp/demo-app-worktrees/TICKET-123
branch=TICKET-123-fix-login-bug
base=HEAD
base_commit=a5725d8
port=5001
description=fix login bug
mode=worktree
created_at=2026-02-02T16:42:17Z
Reading a field is a shell builtin loop, no parser and no subprocess:
state_get() {
local file="$1" key="$2" line
[ -f "$file" ] || return 0
while IFS= read -r line || [ -n "$line" ]; do
case "$line" in
"$key="*) _state_unescape "${line#"$key"=}"; printf '\n'; return 0 ;;
esac
done <"$file"
}
Writing is tmp + mv on that one file, so a write is atomic per record
instead of rewriting a global database. One file per worktree also means a
consumer watching pwt state re-reads a few hundred bytes, not everything.
JSON did not disappear, it moved to the boundary. list --porcelain,
info --json and pwt state --json still emit it, generated by a small
pure-bash json_escape (escapes \ " \n \r \t, then rewrites any remaining
C0 control byte as \u00XX, and only pays that cost when one is actually
present, because a pasted description or an agent-written value does
occasionally carry one).
jq call sites went from 132 to 26, and all 26 live in the one-time v1
migration and in pwt doctor, which reports the version if it happens to be
installed.
The measurement that changed the story
version jq spawns
pre-index, one jq per field read 306 ms 62
index cache warm (jq build) 54 ms 0
index cache cold (jq build) 73 ms 1
state v2 warm (bash build) 55 ms 0
state v2 cold (bash build) 133 ms 0
The warm path, the one that runs on every prompt, is identical: 54ms against
55ms, which is noise. The cold path got slower. Building the index with
one jq pass cost about 19ms; building it by reading 12 config files in a
bash loop costs about 78ms. For bulk parsing, one jq process beats a shell
loop by four times, and it is not close.
So the honest ledger is: the 306ms to 54ms win came from removing process
spawns, not from removing jq. Deleting jq bought different things, and
they were worth a slower cold build: one less dependency, one less source of
truth, atomic per-record writes, and state a dashboard can watch without
invoking the CLI. It did not buy speed. I had been telling myself it did.
What broke on the way
The escaper stopped being symmetric. A value can contain a newline, so
_state_escape writes \n and _state_unescape reads it back. Later,
events.log became TSV, so _state_escape learned \t and \r. Its
counterpart did not. From then on a tab in any state value was written as
\t and read back as a literal backslash plus t: corruption on disk, not
recoverable by re-reading. The round-trip tests only covered \n and \\,
and the new events test pinned only the new half of the constraint. The two
functions now carry a comment saying they are edited together, and the tests
round-trip every character in the set.
The migration silently dropped 70% of the state. The first conversion filter walked project, then worktree, then fields:
jq -r 'to_entries[] | .key as $p | .value |
to_entries[] | .key as $w | .value |
to_entries[] | [$p,$w,.key,(.value|tostring)] | @tsv' meta.json
Real installs accumulate malformed legacy entries. Mine had a worktree
written one level too high, a string where the filter expected an object.
jq does not skip it, it aborts the whole stream:
jq: error (at meta.json:1129): string ("/Users/...") has no keys
Everything after that line is simply not emitted. Measured on my actual v1
file: the unguarded filter produced 273 rows across 3 of 10 projects. The
same file through the shipped filter, which is the same walk plus
select(type == "object") at each level, produces 880 rows across 9
projects and 110 worktrees. The tenth is the malformed one, reported by
name and left in the .v1.bak for hand recovery.
The version with 2>/dev/null on that call would have exited 0 and printed
a cheerful checkmark. Migrations now stage jq output to a temp file, check
its exit status, and refuse to touch anything if it failed.
A user’s Pwtfile was reading jobs/*.json directly. It broke the moment
the format changed, which is fair: it depended on an internal layout that was
never a contract. The fix was to publish one, pwt jobs list --porcelain, so
scripts have something supported to depend on. That bug and the one above
were both found by running the migration against real installs, not against
the test suite. The synthetic fixtures were too well formed to catch either.
What we kept afterwards
- Migrations are a contract, not a one-off script: each provides
describe,check,up,verify, driven bypwt state migratewith--checkfor a read-only dry run. Astate-versionfile says which schema is on disk, and every converted file leaves a.v1.bakbehind. - Property tests for both directions of any encoder. An escaper that is only tested forward is a corrupter waiting for the right input.
- Real data before release. Two of the bugs above existed only on disks that had accumulated years of edits.
Migrating a state format with an agent? Give it a copy of a real state directory, not a fixture. Both of the migration bugs here were found by pointing the new code at an actual install; the suite was green through all of it.
pwt is a Git worktree manager for parallel development, and its state now
reads with zero subprocesses: github.com/jonasporto/pwt,
brew install jonasporto/pwt/pwt.
You do not have to memorize any of this.
pwt skillprints the agent-facing guide to pwt; tell your agent the outcome you want and point it there, and it works out the mechanism and verifies it in a throwaway worktree.