perf(alias): parallelize prewarm git/config/user-config reads#2573
Merged
perf(alias): parallelize prewarm git/config/user-config reads#2573
Conversation
The two big git invocations on the alias-dispatch critical path — `git rev-parse ...` (prewarm) and `git config --list -z` (all_config) — ran in series, with prewarm finishing before LoadedConfigs::load even started. Each pays ~3-4ms of process startup, and standalone measurement showed the config-merge work itself is only ~0.5ms — i.e. ~4ms of the ~10ms `all_config` cost is purely a second git fork. Run them concurrently from `Repository::prewarm_at` via `std::thread::scope`, stash the parsed config map in a new process-wide `GLOBAL_CONFIG_PRELOAD` keyed by discovery path, and have `Repository::at` consume the preload into `cache.all_config` so the first `all_config()` call is a memory hit. `LoadedConfigs::load` no longer warms `project_identifier` — it's a memory-only OnceCell init now that `all_config` is preloaded — so the parallel scope simplifies to user-config + project-config loads. Bench (alias dispatch): - dispatch/warm/1: 13.84ms → 11.50ms (-16.6%) - dispatch/warm/100: 17.4ms → 12.7ms (-27%) - dispatch/cold/1: 14.5ms → 11.6ms (-20%) - dispatch/cold/100: 14.85ms → 11.7ms (-21%) Behavior nuance: `prewarm_all_config` runs `git config --list -z` from `discovery_path` instead of `git_common_dir` (the rev-parse thread is racing in parallel and `git_common_dir` isn't known yet). For the default config (`extensions.worktreeConfig` off) this is byte-identical — linked worktrees and the common dir share one config file. With `extensions.worktreeConfig` enabled, the new code reads per-worktree values that the old code masked; that's arguably the correct behaviour when a user opts into worktree-scoped config, and worktrunk doesn't itself enable the extension. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three related changes that compound: 1. Rename for git-vs-worktrunk-config disambiguation. Within `src/git/repository/mod.rs`, the `GIT_*` prefix denotes git-side data; worktrunk-side data deserves a parallel `WORKTRUNK_*` prefix: - `GLOBAL_CONFIG_PRELOAD` -> `GIT_CONFIG_PRELOAD` - `Repository::prewarm_all_config` -> `Repository::prewarm_git_config` 2. Hoist user-config loading into `Repository::prewarm`. A new sibling thread (`prewarm_user_config`) runs `UserConfig::load_with_warnings` on `main` alongside the two existing git forks, stashing the result in `WORKTRUNK_USER_CONFIG_PRELOAD`. `Repository::at` clones it into `cache.user_config` so the first `repo.user_config()` call is a memory hit. Pure file I/O on XDG paths -- no git involvement -- so it overlaps cleanly with the rev-parse and git-config forks. Removes the ~4 ms TOML parse from the alias-dispatch critical path. 3. Collapse `LoadedConfigs::load`'s parallel scope. With `user_config` preloaded, `all_config` preloaded, and `project_identifier()` already memory-only since the prior commit, the only remaining work is the ~30 us `.config/wt.toml` read. The thread::scope is gone; `load` becomes two sequential cache reads. Module doc rewritten to current shape (no "no longer warmed" prose). Behaviour change: user-config warnings (parse failures, env-var rejections, deprecation notices) now emit from the prewarm thread that runs before alias dispatch, rather than from whatever later caller first invoked `repo.user_config()`. The result is uniform across commands -- every `wt` invocation now surfaces config issues on stderr, not just commands that happen to load user config. This exposed a pre-existing duplication in `wt config update`: `format_update_preview` emits warnings via `format_deprecation_warnings` directly (bypassing `WARNED_DEPRECATED_PATHS` dedup), which now races prewarm and prints the same warnings twice. Drop the redundant emission from `format_update_preview` and the `--print` path -- prewarm is the single canonical source. Snapshot updates fall into two groups: - New stderr lines on `wt config show` and friends: warnings now appear on stderr where they previously only appeared in stdout's rendered USER CONFIG section. Consistent with all other commands. - Cleaner `wt config update` output: warnings appear once (from prewarm), not twice. Verified: `cargo run -- hook pre-merge --yes` passes (3464 tests); `wt-perf timeline` shows three sibling spans (prewarm_rev_parse, prewarm_git_config, prewarm_user_config) starting near ts=0 on different tids inside the prewarm scope. Co-Authored-By: Claude <noreply@anthropic.com>
The previous commit moved `UserConfig::load_with_warnings` into
`Repository::prewarm`'s parallel scope. Loading the config eagerly is
the perf win, but it also broke the existing `suppress_warnings()`
contract: handlers for `wt switch` (picker mode), `wt select`, and
`wt list statusline` latch the suppression flag before triggering
config load, because rendering deprecation warnings on stderr above
a TUI picker pushes the picker down (and statusline runs on every
prompt redraw — a recurring stderr line each prompt is unusable).
After the prewarm change, those handler-side latches fire too late:
prewarm has already emitted the warnings before dispatch reaches the
handler. Move the latch up to `main()` based on the parsed `Cli`,
before `Repository::prewarm` runs. The handler-side calls remain as
local documentation of intent and idempotent backups (`OnceLock`).
Cases that suppress:
- `Commands::Select { .. }` — deprecated, always opens the picker
- `Commands::Switch(args)` with `args.branch.is_none()` — picker mode
- `Commands::List(args)` with `Statusline` subcommand
Verified manually: `wt switch main` shows the warning, `wt switch`
(picker) doesn't; `wt list` shows it, `wt list statusline` doesn't.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
worktrunk-bot
approved these changes
May 4, 2026
LoadedConfigs::load became a no-op wrapper after prewarm_user_config landed: both fields were thin cache reads, the `Result` only there because project_config()? is fallible. Inline at each call site and move the surviving invariants (warning ordering, why user/project stay distinct) into Repository::project_config rustdoc. Net -62 lines. Co-Authored-By: Claude <noreply@anthropic.com>
worktrunk-bot
approved these changes
May 4, 2026
max-sixty
added a commit
that referenced
this pull request
May 4, 2026
The rustdoc on `project_config` added in #2573 documented a convention that this PR's parent commit removed. Without any callers warming from the entry point, the paragraph pointed readers at a pattern the codebase no longer uses. Co-Authored-By: Claude <noreply@anthropic.com>
max-sixty
added a commit
that referenced
this pull request
May 4, 2026
…entry (#2582) Follow-up to #2573. The `LoadedConfigs::load` inlining left two call sites with a bare `repo.project_config()?;` whose only effect was to warm the cache and emit any deprecation warnings before downstream hook output started. Reading the resulting code, the discarded `?` looks load-bearing without being so — the next reader has to trace the call chain to confirm nothing reads the value. Both call sites — `CommandEnv::for_action_branchless` and the picker post-switch path — have downstream consumers (`run_pre_switch_hooks`, `plan_switch`, the rest of the hook pipeline) that load project config themselves on first use. Project-config deprecation warnings still surface; they just emerge from the first real read instead of from a bare statement at the entry point. User-config warnings continue to come from prewarm, so the user-visible ordering of warnings vs. command output is unchanged in the common path. Drop both. The surrounding comments now name where project config is loaded, rather than implying the entry point pre-warms it. --------- Co-authored-by: Claude <noreply@anthropic.com>
max-sixty
added a commit
that referenced
this pull request
May 5, 2026
## Summary Release v0.48.0. See [CHANGELOG.md](https://github.com/max-sixty/worktrunk/blob/release/CHANGELOG.md) for the full notes. Highlights: - `--format=json` extends to seven step + hook commands (#2560) - `wt step commit` / `wt step squash` gain `--dry-run` (#2557) - New `dirname` / `basename` template filters (#2592, #2605) - New `[remove] delete-branch` config option (#2589) - `wt-perf timeline` subcommand (#2558) - Faster `wt list` on dirty worktrees (#2602) and faster alias dispatch (#2556, #2573) - Short-SHA display honors `core.abbrev` (#2576) - Cleaner `wt config show` shell-integration section for new users ## Test plan - [x] `cargo run -- hook pre-merge --yes` (3497 tests, lints clean) - [x] `cargo semver-checks check-release -p worktrunk` consulted; minor bump confirmed - [ ] CI green
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Three commits that compound. Together they cut the alias-dispatch warm critical path from ~38ms to ~12ms by overlapping the three independent pre-dispatch reads that previously ran in series.
What was slow
For
wt <alias>, the pre-dispatch path had three blocking reads in sequence:git rev-parse --git-common-dir ...(prewarm, ~7ms warm / ~10ms cold)git config --list -z(all_config, called viaproject_identifier_warm, ~10ms warm)UserConfig::load_with_warnings(TOML parse ofwt.toml, ~4ms cold)Standalone measurement showed
git config --list -zis dominated by git's process startup (~4ms of ~5ms total —git --versionalone is ~4.6ms), and the same applies to the rev-parse fork. Adding a dependency likegix-configwould only buy back the subprocess overhead. Running these reads concurrently buys it without a new dep.How it works
Repository::prewarm_atruns three sibling threads inside astd::thread::scope:prewarm_rev_parse— populatesGIT_COMMON_DIR_CACHEetc. (unchanged from before)prewarm_git_config— runsgit config --list -zand stashes the parsedIndexMapin a newGIT_CONFIG_PRELOADstaticprewarm_user_config— callsUserConfig::load_with_warningsand stashes the result inWORKTRUNK_USER_CONFIG_PRELOADRepository::atconsumes both preloads into the per-RepositoryRepoCache(all_configanduser_configOnceCells), so the first access from aRepositoryis a memory hit. On-demand fallbacks remain in place for tests and any caller that bypasses prewarm.LoadedConfigs::loadwas a sequential pair of cache reads after this — both fields became thin cache hits. The third commit inlines it at all 8 callsites and folds the surviving invariants (warning ordering, why user/project stay distinct) intoRepository::project_config's rustdoc. Net −62 lines.Naming
The git/worktrunk-config split is now visible at a glance:
GIT_*for git-side data (GIT_CONFIG_PRELOAD,GIT_COMMON_DIR_CACHE,GIT_DIRS),WORKTRUNK_*for worktrunk-side (WORKTRUNK_USER_CONFIG_PRELOAD).Numbers
cargo bench --bench alias:dispatch/warm/1: ~13.8ms → ~11.5ms (-17%)dispatch/warm/100: ~17.4ms → ~12.7ms (-27%)dispatch/cold/1: ~14.5ms → ~11.6ms (-20%)wt-perf timeline -- stubshows the three sibling spans inside the prewarm scope starting within ~22µs of each other.Behaviour change worth flagging
User-config deprecation warnings (and the "↳ To see details, run
wt config show" hint) now emit on everywtinvocation in a deprecated-config repo, not just commands that previously calledrepo.user_config(). Process-wide dedup (WARNED_DEPRECATED_PATHSfor warnings,DEPRECATION_HINT_EMITTEDfor the hint) keeps it to once per process. Behaviour is now uniform across commands; before,wt config showrendered deprecations into stdout but didn't emit them on stderr, whilewt listdid.This exposed and removed a duplication in
wt config update:format_update_previewand the--printpath were emitting warnings viaformat_deprecation_warningsdirectly, which now races prewarm and prints duplicates. Drop the redundant emission — prewarm is the single canonical source. Project-config warnings still emit viacheck_project_config's separatecheck_and_migratecall.Minor wart, not blocking: the hint says "to apply updates, run
wt config update" even when the user is running that exact command. Fixable later by reading argv before prewarm or moving the hint into command handlers.Behaviour nuance
prewarm_git_configrunsgit config --list -zfromdiscovery_pathinstead ofgit_common_dir(the rev-parse thread is racing in parallel andgit_common_dirisn't known yet). For the default config (extensions.worktreeConfigoff — the common case) the output is byte-identical: linked worktrees and the common dir share one config file. Withextensions.worktreeConfigenabled, the new code reads per-worktree values that the old code masked; that's arguably the correct behaviour when a user opts into worktree-scoped config, and worktrunk doesn't itself enable the extension.Testing
Standard pre-merge gate (
wt hook pre-merge --yes) passes — 3464 tests including config-show snapshot tests with the new stderr lines. 18 snapshot files updated to reflect the warning-uniformity change; net diff is one or two added stderr lines per test.