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

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 skill prints 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.