Skip to content

[medium] perf(jj): detect repos without spawning and cache jj commands - #632

Open
elhoim wants to merge 2 commits into
sirmalloc:mainfrom
elhoim:perf/jj-cache
Open

elhoim wants to merge 2 commits into
sirmalloc:mainfrom
elhoim:perf/jj-cache

Conversation

@elhoim

@elhoim elhoim commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

BLUF

  • Priority: medium. This removes per-render subprocesses.
  • Problem: each jj widget spawned jj root and then its own uncached jj command, with no timeout. With all 8 jj widgets configured, one render spawned 8 processes in a non-jj directory and 16 inside a jj repo.
  • Fix:
    • A .jj directory walk decides "not a jj repo" without spawning anything.
    • jj commands now go through the same memory + persistent TTL cache as git and use the same 5 s timeout.
    • Read-only queries pass --ignore-working-copy.
  • Result:
    • Spawns per render: non-jj 8 → 0; jj repo 16 → 6 (empty cache) or 0 (warm cache); right after a file edit, 16 → 7, then 2, then 0.
    • Render CPU: −22% in a non-jj directory, −45% in a jj repo with a warm cache.
    • Output is byte-identical to main.

Details

  • Repo detection (isInsideJjRepo). jj finds its workspace by walking up to the nearest .jj directory, so the widget now does the same walk first. If there is no .jj, it returns false without spawning. If there is one, it still confirms with jj root. That keeps today's no jj output when .jj exists but jj is not installed.
  • Caching. runGitArgs is split into a shared runCachedCommand in git.ts. This is a pure move: the git exec options, memory keys, persistent file layout and git.test.ts are all unchanged.
    • runJjArgs uses the shared cache and stores its entries in ~/.cache/ccstatusline/jj-cache/. Using a separate directory means colocated .git + .jj repos cannot collide.
    • Entries are invalidated by the mtimes of .jj/repo/op_heads/heads and .jj/working_copy/checkout. Every jj operation, including a working-copy snapshot, changes both, as checked with jj 0.45.1. Secondary workspaces follow the .jj/repo pointer file.
    • Commands that may snapshot (jj diff) take an optional refreshMetadata hook, so they are keyed to the repo state after they ran. Without it, the snapshot recorded by jj diff would invalidate the entry it had just written. Read-only commands stay keyed to the state before they ran, so an operation that lands concurrently still causes a refresh. The git path does not use the hook.
    • The TTL comes from the existing gitCacheTtlSeconds setting, default 5 s.
    • Like git's unstaged diff --shortstat, a file edit shows up in jj diff --stat within one TTL. jj also inherits the setting's existing "0 = mtime only" meaning: with a TTL of 0, an edit shows up only after the next jj operation. This is the same trade-off git widgets already have for unstaged changes with a TTL of 0.
  • --ignore-working-copy. This is added to the change-id, description, bookmarks (heads(::@ & bookmarks())) and workspace-name queries. None of their output depends on file edits that have not been snapshotted: I checked each query with and without the flag after editing files, including with a bookmark on @, and the outputs matched. jj diff --stat still snapshots, because the snapshot is the data it reports. One side effect: in a stale workspace, these four queries may now show the repo view instead of failing to no jj. I did not reproduce a stale workspace to confirm this.
  • Tests.
    • New unit tests cover: the timeout, the no-.jj path with zero spawns, discovery from a subdirectory, memory and persistent cache hits, cached failures, TTL expiry, invalidation on op-head and checkout changes, the secondary-workspace pointer, separate cache entries for allowEmpty, and post-run versus pre-run keying.
    • The new tests fail on main (red before) and pass with the change (green after).
    • The widget tests now run against a temporary .jj workspace with an isolated HOME, through a shared jj-test-helpers.ts.

Measurements

The setup: 8 jj widgets, jj 0.45.1, node dist/ccstatusline.js, and an isolated HOME for each arm. CPU is user+sys including reaped children. I ran 25 interleaved round-robin passes. Load1 during the run was min 50.8, median 61.2, max 68.6 on 6 cores, so compare ratios rather than absolute times.

arm CPU median (ms) CPU p90 (ms) wall median (ms)
control node -e 0 109.1 134.6 900.5
main, non-jj cwd 1596.6 1805.7 12477.5
patched, non-jj cwd 1245.0 (−22%) 1405.8 9227.0
main, jj repo, warm cache 2303.5 2606.6 17602.6
patched, jj repo, warm cache 1261.7 (−45%) 1421.2 10259.4
main, jj repo, empty cache (working copy already snapshotted) 2280.1 2474.7 16423.1
patched, jj repo, empty cache 1768.8 (−22%) 1914.7 12999.8

Spawns per render. The first three rows were counted with strace -f execve. The post-edit rows were counted with a logging jj wrapper and a 60 s TTL, because strace under this load makes a single render take longer than the 5 s TTL.

scenario main patched
non-jj cwd 8 (jj root ×8) 0
jj repo, cold cache 16 (root ×9, diff ×3, log ×3, workspace ×1) 6 (root, diff, log ×3, workspace)
jj repo, warm cache 16 0
jj repo, first render after a file edit (cache past TTL) 16 7 (root ×2, diff, log ×3, workspace)
next render 16 2 (log, workspace)
render after that 16 0

The stdout of the patched build is byte-identical to main for the jj configs, in a jj repo and a non-jj directory, on cold and warm caches. It is also identical for the existing git3, default and heavy configs.

Checks

  • bun run lint (tsc + eslint --max-warnings=0): clean.
  • bun run build: OK.
  • bun test:
    • All jj and git suites pass: 153 tests across jj.test.ts, git.test.ts and the 9 Jj* widget files.
    • The full run had 2302 passes and 66 failures, all in suites that do not touch jj or git: 36 in fetchUsageData error handling (5 s timeouts), 15 in custom-command capture, and 15 in TUI menus and editors.
    • I ran those same files on unmodified main (35440e4) under the same load. They failed there too: 52 failures in the usage-fetch and TUI files, and 16 in custom-command capture.
    • The host was running at load1 50–68 on 6 cores, so these are load flakes.
  • Output is byte-identical to main. The patched build's re-keying change was re-checked after the benchmark run and still produced identical output.

This PR touches src/utils/git.ts (refactor only). As of opening, none of our other open PRs (including #624 usage-fetch.ts and #625 ansi.ts) touch git.ts or the jj files.

Overlap

🤖 Generated with Claude Code

Every jj widget called isInsideJjRepo, which spawned `jj root`, before
running its own uncached `jj` command, with no timeout. With all eight jj
widgets configured, a render spawned 8 `jj root` processes in a non-jj
directory and 16 jj processes inside a jj repo (9x root, 3x diff --stat,
...), each read query also snapshotting the working copy.

- isInsideJjRepo first walks up for a `.jj` directory (jj's own workspace
  discovery); without one it returns false without spawning. With one it
  still confirms via `jj root`, so a missing jj binary still renders "no jj".
- runJjArgs goes through the same in-process + persistent TTL cache as git
  (runGitArgs is refactored into a shared runCachedCommand, git behaviour
  unchanged), stored under ~/.cache/ccstatusline/jj-cache and invalidated by
  the mtimes of .jj/repo/op_heads/heads (every operation, including a
  snapshot) and .jj/working_copy/checkout. Secondary workspaces follow the
  .jj/repo pointer file. Uses the gitCacheTtlSeconds setting. Commands
  that may snapshot (`jj diff`) are keyed to the repo state after they ran,
  so their own snapshot does not invalidate the entry they just wrote.
- jj commands get the same 5 s timeout as git.
- The change-id, description, bookmarks and workspace-name queries pass
  --ignore-working-copy: their output does not depend on unsnapshotted file
  edits. `jj diff --stat` keeps snapshotting.

Spawns per render (8 jj widgets): non-jj cwd 8 -> 0; jj repo 16 -> 6
on an empty cache, 0 on a warm one; right after a file edit (cache past
its TTL) 16 -> 7, then 2 on the next render, then 0. CPU (user+sys incl. children,
median of 25 interleaved passes, load1 ~61 on 6 cores): non-jj
1597 -> 1245 ms (-22%), jj repo warm 2304 -> 1262 ms (-45%), cold
2280 -> 1769 ms (-22%). Output is byte-identical to main for the jj
configs and for the existing git/default/heavy configs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
whycantfindaname pushed a commit to whycantfindaname/ccstatusline that referenced this pull request Oct 1, 2026
Merged from the jason/beta4-local-fixes Trellis build (compat-repair base):
bounded stdin reads in shared hooks (sirmalloc#590), research dispatch may write the
task research dir (sirmalloc#634), scoped archive commits (sirmalloc#622, sirmalloc#630), list filter
traversal (sirmalloc#631), remove-subtask link check (sirmalloc#632), hooks.local.json ignore
(sirmalloc#633).
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant