perf(picker): cache log/branch-diff/upstream-diff renders to disk (#2628)

The picker pre-computes four previews per item at skeleton time. Three
of them — Log, BranchDiff, UpstreamDiff — are derivable from git object
SHAs at a given terminal width, so rendering them on every \`wt switch\`
duplicates work the prior invocation already did. This adds a SHA-keyed
disk cache alongside the existing \`sha_cache\` so warm-cache
invocations short-circuit the git subprocesses.

## What's cached, by mode

- **BranchDiff / UpstreamDiff**: the rendered diff string itself. Pure
functions of `(base_sha, head_sha, width)` — cache value is what the
existing `compute_*_preview` returns. Default-branch and upstream SHAs
are resolved via `rev-parse` so the key stays stable across `git fetch`.
- **Log**: a small struct (raw `git log --graph` output + per-commit
`(insertions, deletions)` map). The render path recomputes `merge-base`
+ `rev-list` for the dim/bright split and runs `format_log_output`
against `epoch_now()` on every call. Why split: dim/bright shifts as
`main` advances, and relative-time strings drift with wall-clock —
neither is in the SHA-deterministic part. Caching just the raw log +
stats means the cache key stays `(branch_head_sha, w, h)` (no `main`
dimension, no time bucket) while output stays correct.
- **WorkingTree** is intentionally not cached — its inputs include the
mutable working tree, which has no cheap stable hash. Summary already
has its own cache.

Layout:
`.git/wt/cache/picker-preview/{mode}-{sha}[-{sha}]-{w}[-{h}].json`. Diff
modes cache pre-pager strings (the pager step in
`compute_and_page_preview` runs on every read, so changing the
configured pager invalidates nothing). LRU bound is 500 entries —
rendered diffs are tens of KB each, vs the 80-byte SHA-pair entries
`sha_cache` is sized for at 5000.

## State management

Bundled into the existing "git commands cache" user-facing category in
\`wt config state get\` and \`state clear --all\` — both \`sha_cache\`
and the new picker preview cache surface as one count and clear
together. Implementation stays in \`commands/picker/preview_cache.rs\`
(picker-owned, distinct LRU bound) rather than mixing into
\`git/repository/sha_cache.rs\`. Because the picker module is
`#[cfg(unix)]`, two cfg-conditional shims in `state.rs` keep the
JSON/table shape platform-agnostic on Windows.

## Key files

- \`src/commands/picker/preview_cache.rs\` — new module, primitives +
\`LogCacheEntry\` + state integration
- \`src/commands/picker/items.rs\` — cache integration in
\`compute_log_preview\` / \`compute_branch_diff_preview\` /
\`compute_upstream_diff_preview\`; the log path splits cacheable raw
work from per-call dim/format
- \`src/commands/config/state.rs\` — bundled clear/show plus the
cfg-conditional shims
- \`src/cli/config.rs\` — help text for the bundled category

## Tests

Round-trip + width/SHA invalidation in the new module, plus per-mode
short-circuit / writeback tests through each \`compute_*_preview\`.
Includes a regression test for worktrunk-bot's review feedback
(\`log_cache_dim_split_tracks_main_advance\`): branches feature off
main, advances main to include feature's commit, asserts the cached log
re-dims via the stripped bold-green branch decoration.

> _This was written by Claude Code on behalf of Maximilian Roos_

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Maximilian Roos
2026-05-06 20:33:03 -07:00
committed by GitHub
parent 2fbbbc229b
commit 5a654fbc35
7 changed files with 629 additions and 82 deletions
+1 -1
View File
@@ -605,7 +605,7 @@ pub enum StateCommand {
- **Vars**: Custom variables per branch
- **CI status**: Cached GitHub/GitLab CI status per branch (30s TTL)
- **Summaries**: Cached LLM-generated branch summaries (shown in `wt list --full` and `wt switch` preview)
- **Git commands cache**: SHA-keyed merge-tree, ancestry, and diff-stats results
- **Git commands cache**: SHA-keyed disk caches — merge-tree, ancestry, diff-stats, and `wt switch` preview renders
- **Hints**: One-time hints that have been shown
- **Log files**: Operation and debug logs
- **Trash**: Staged worktree directories awaiting background deletion
+53 -12
View File
@@ -18,7 +18,10 @@
//! - Vars (git config `worktrunk.state.<branch>.vars.*`)
//! - CI status cache (`.git/wt/cache/ci-status/`)
//! - Summary cache (`.git/wt/cache/summary/`)
//! - Git commands cache (`.git/wt/cache/{merge-tree-conflicts,is-ancestor,…}/`)
//! - Git commands cache (`.git/wt/cache/{merge-tree-conflicts,is-ancestor,picker-preview,…}/`)
//! — one user-facing category covering every SHA-keyed disk cache, even
//! when implementation lives in different modules (`sha_cache` for parsed
//! results, `commands::picker::preview_cache` for rendered previews)
//! - Hints (git config `worktrunk.hints.*`)
//! - Logs (`.git/wt/logs/`)
//! - Trash (`.git/wt/trash/`)
@@ -59,6 +62,38 @@ use crate::display::format_relative_time_short;
use crate::help_pager::show_help_in_pager;
use crate::summary::CachedSummary;
// ==================== Picker preview cache shims ====================
//
// `commands::picker` is gated `#[cfg(unix)]` (see `commands/mod.rs`), so its
// preview cache module disappears entirely on Windows. The bundled "git
// commands cache" category still needs to compile and report consistent
// counts on every platform — these shims forward to the picker cache on
// unix and return 0 / `Ok(0)` elsewhere so call sites stay platform-agnostic.
fn picker_preview_count(repo: &Repository) -> usize {
#[cfg(unix)]
{
crate::commands::picker::preview_cache::count_all(repo)
}
#[cfg(not(unix))]
{
let _ = repo;
0
}
}
fn picker_preview_clear(repo: &Repository) -> anyhow::Result<usize> {
#[cfg(unix)]
{
crate::commands::picker::preview_cache::clear_all(repo)
}
#[cfg(not(unix))]
{
let _ = repo;
Ok(0)
}
}
// ==================== Path Helpers ====================
/// Get the user config path, or error if it cannot be determined.
@@ -962,14 +997,17 @@ pub fn handle_state_clear_all() -> anyhow::Result<()> {
cleared_any = true;
}
// Clear git commands cache (merge-tree, ancestry, diff results)
let sha_cleared = sha_cache::clear_all(&repo)?;
if sha_cleared > 0 {
// Clear all SHA-keyed git command caches: parsed results
// (merge-tree, ancestry, diff-stats) plus rendered picker previews
// (log, branch-diff, upstream-diff). Surfaced as one user-facing
// category — see the parity docstring at the top of this file.
let cache_cleared = sha_cache::clear_all(&repo)? + picker_preview_clear(&repo)?;
if cache_cleared > 0 {
eprintln!(
"{}",
success_message(cformat!(
"Cleared <bold>{sha_cleared}</> git commands cache entr{}",
if sha_cleared == 1 { "y" } else { "ies" }
"Cleared <bold>{cache_cleared}</> git commands cache entr{}",
if cache_cleared == 1 { "y" } else { "ies" }
))
);
cleared_any = true;
@@ -1139,7 +1177,7 @@ fn handle_state_show_json(repo: &Repository) -> anyhow::Result<()> {
"markers": markers,
"ci_status": ci_status,
"summaries": summaries,
"git_commands_cache": sha_cache::count_all(repo),
"git_commands_cache": sha_cache::count_all(repo) + picker_preview_count(repo),
"vars": vars_data,
"command_log": command_log,
"hook_output": hook_output,
@@ -1260,17 +1298,20 @@ fn handle_state_show_table(repo: &Repository) -> anyhow::Result<()> {
}
writeln!(out)?;
// Show git commands cache summary
// Show git commands cache summary. Spans both `sha_cache` (parsed
// SHA-keyed results) and the picker preview cache (rendered SHA-keyed
// previews) — one user-facing category covering every SHA-keyed disk
// cache, regardless of which module owns the entries.
writeln!(out, "{}", format_heading("GIT COMMANDS CACHE", None))?;
let sha_count = sha_cache::count_all(repo);
if sha_count == 0 {
let cache_count = sha_cache::count_all(repo) + picker_preview_count(repo);
if cache_count == 0 {
writeln!(out, "{}", format_with_gutter("(none)", None))?;
} else {
let label = if sha_count == 1 { "entry" } else { "entries" };
let label = if cache_count == 1 { "entry" } else { "entries" };
writeln!(
out,
"{}",
format_with_gutter(&format!("{sha_count} {label}"), None)
format_with_gutter(&format!("{cache_count} {label}"), None)
)?;
}
writeln!(out)?;
+301 -68
View File
@@ -19,6 +19,7 @@ use super::log_formatter::{
};
use super::pager::{diff_pager, pipe_through_pager};
use super::preview::{PreviewMode, PreviewStateData};
use super::preview_cache;
/// Cache key for pre-computed previews: (branch_name, mode).
pub(super) type PreviewCacheKey = (String, PreviewMode);
@@ -300,6 +301,12 @@ impl WorktreeSkimItem {
/// Independent of `item.counts` — `compute_diff_preview`'s empty-diff
/// fallback covers the ahead=0 case, so the preview is correct even
/// before the list-row pipeline has populated counts.
///
/// The default branch is resolved to its SHA so the disk cache stays
/// invariant across `git fetch` (which moves the *ref* but not the
/// captured SHA). When resolution fails, we fall through to the
/// uncached path with the branch name in the diff range — same git
/// behavior as before, just no cache write.
fn compute_branch_diff_preview(repo: &Repository, item: &ListItem, width: usize) -> String {
let branch = item.branch_name();
let reset = Reset;
@@ -309,30 +316,63 @@ impl WorktreeSkimItem {
);
};
let merge_base = format!("{}...{}", default_branch, item.head());
compute_diff_preview(
let base_sha = repo
.run_command(&["rev-parse", &default_branch])
.ok()
.map(|s| s.trim().to_string());
if let Some(ref base) = base_sha
&& let Some(cached) = preview_cache::read_branch_diff(repo, base, item.head(), width)
{
return cached;
}
// Use the resolved SHA in the diff range when available so the
// cache key and the diff agree on which commit was the base.
let base_ref = base_sha.as_deref().unwrap_or(&default_branch);
let merge_base = format!("{base_ref}...{}", item.head());
let result = compute_diff_preview(
repo,
&["diff", &merge_base],
&cformat!(
"{INFO_SYMBOL}{reset} <bold>{branch}</>{reset} has no file changes vs <bold>{default_branch}</>{reset}"
),
width,
)
);
if let Some(ref base) = base_sha {
preview_cache::write_branch_diff(repo, base, item.head(), width, &result);
}
result
}
/// Compute Tab 4: Upstream diff preview (ahead/behind vs tracking branch)
///
/// Independent of `item.upstream` — a single
/// `git rev-list --left-right --count HEAD...@{u}` probes both
/// existence (non-zero exit when `@{u}` is unresolvable) and counts,
/// so the preview is correct even before the list-row pipeline has
/// populated upstream.
/// Independent of `item.upstream` — `git rev-parse {branch}@{{u}}`
/// probes existence (non-zero exit when `@{{u}}` is unresolvable) and
/// also yields the upstream SHA for cache keying. The follow-up
/// `rev-list --left-right --count` then runs against the resolved SHAs
/// so the count and the cached diff agree on which upstream commit was
/// in play.
fn compute_upstream_diff_preview(repo: &Repository, item: &ListItem, width: usize) -> String {
let branch = item.branch_name();
let reset = Reset;
let upstream_ref = format!("{branch}@{{u}}");
let probe_range = format!("{}...{upstream_ref}", item.head());
let Ok(upstream_sha_raw) = repo.run_command(&["rev-parse", &upstream_ref]) else {
return cformat!(
"{INFO_SYMBOL}{reset} <bold>{branch}</>{reset} has no upstream tracking branch\n"
);
};
let upstream_sha = upstream_sha_raw.trim();
if let Some(cached) =
preview_cache::read_upstream_diff(repo, item.head(), upstream_sha, width)
{
return cached;
}
let probe_range = format!("{}...{upstream_sha}", item.head());
let Ok(counts) = repo.run_command(&["rev-list", "--left-right", "--count", &probe_range])
else {
return cformat!(
@@ -354,14 +394,10 @@ impl WorktreeSkimItem {
);
};
if ahead == 0 && behind == 0 {
return cformat!(
"{INFO_SYMBOL}{reset} <bold>{branch}</>{reset} is up to date with upstream\n"
);
}
if ahead > 0 && behind > 0 {
let range = format!("{}...{}", upstream_ref, item.head());
let result = if ahead == 0 && behind == 0 {
cformat!("{INFO_SYMBOL}{reset} <bold>{branch}</>{reset} is up to date with upstream\n")
} else if ahead > 0 && behind > 0 {
let range = format!("{upstream_sha}...{}", item.head());
compute_diff_preview(
repo,
&["diff", &range],
@@ -371,7 +407,7 @@ impl WorktreeSkimItem {
width,
)
} else if ahead > 0 {
let range = format!("{}...{}", upstream_ref, item.head());
let range = format!("{upstream_sha}...{}", item.head());
compute_diff_preview(
repo,
&["diff", &range],
@@ -381,7 +417,7 @@ impl WorktreeSkimItem {
width,
)
} else {
let range = format!("{}...{}", item.head(), upstream_ref);
let range = format!("{}...{upstream_sha}", item.head());
compute_diff_preview(
repo,
&["diff", &range],
@@ -390,11 +426,22 @@ impl WorktreeSkimItem {
),
width,
)
}
};
preview_cache::write_upstream_diff(repo, item.head(), upstream_sha, width, &result);
result
}
/// Compute log preview for a worktree item.
/// This can be called from background threads for pre-computation.
///
/// Splits work into a SHA-deterministic part that's safe to disk-cache
/// (raw `git log --graph` output and the per-commit insertions/deletions
/// map from `batch_fetch_stats`) and a path that has to recompute on
/// every call (merge-base + rev-list for the dim/bright split, plus
/// `format_log_output` for relative timestamps). This keeps the cache
/// key out of `main`'s SHA — a `git fetch` advancing `origin/main`
/// doesn't invalidate any entry — while preserving correctness as
/// `main` and wall-clock advance.
pub(super) fn compute_log_preview(
repo: &Repository,
item: &ListItem,
@@ -407,7 +454,6 @@ impl WorktreeSkimItem {
// Tab header takes 3 lines (tabs + controls + blank)
const HEADER_LINES: usize = 3;
let mut output = String::new();
let show_timestamps = width >= TIMESTAMP_WIDTH_THRESHOLD;
// Calculate how many log lines fit in preview (height minus header)
let log_limit = height.saturating_sub(HEADER_LINES).max(1);
@@ -415,45 +461,22 @@ impl WorktreeSkimItem {
let branch = item.branch_name();
let reset = Reset;
let Some(default_branch) = repo.default_branch() else {
output.push_str(&cformat!(
"{INFO_SYMBOL}{reset} <bold>{branch}</>{reset} has no commits\n"
));
return output;
return cformat!("{INFO_SYMBOL}{reset} <bold>{branch}</>{reset} has no commits\n");
};
// Get merge-base with default branch
// merge-base / rev-list run on every call — they're how the
// dim/bright split tracks main's current position. See the cache
// entry docstring for why we keep this off the SHA-keyed disk cache.
//
// Note on error handling: This code runs in an interactive preview pane that updates
// on every keystroke. We intentionally use silent fallbacks rather than propagating
// errors to avoid disruptive error messages during navigation. The preview is
// supplementary - users can still select worktrees even if preview fails.
//
// Alternative: Check specific conditions (default branch exists, valid HEAD, etc.) before
// running git commands. This would provide better diagnostics but adds latency to
// every preview render. Trade-off: simplicity + speed vs. detailed error messages.
// Error handling note: this code runs in an interactive preview
// pane. Silent fallbacks beat disruptive errors during navigation;
// the preview is supplementary, users can still select worktrees
// even if a probe fails.
let Ok(merge_base_output) = repo.run_command(&["merge-base", &default_branch, head]) else {
output.push_str(&cformat!(
"{INFO_SYMBOL}{reset} <bold>{branch}</>{reset} has no commits\n"
));
return output;
return cformat!("{INFO_SYMBOL}{reset} <bold>{branch}</>{reset} has no commits\n");
};
let merge_base = merge_base_output.trim();
let is_default_branch = branch == default_branch;
// Format strings for git log
// Without timestamps: hash (colored/dimmed), then message
// Format includes full hash (for matching) between SOH and NUL delimiters.
// Display content uses \x1f to separate fields for timestamp parsing.
// Format: SOH full_hash NUL short_hash \x1f timestamp \x1f decorations+message
// Using delimiters allows parsing without assuming fixed hash length (SHA-256 safe)
// Note: Use %x01/%x00 (git's hex escapes) to avoid embedding control chars in argv
let timestamp_format = format!(
"--format=%x01%H%x00%C(auto)%h{}%ct{}%C(auto)%d%C(reset) %s",
FIELD_DELIM, FIELD_DELIM
);
let no_timestamp_format = "--format=%x01%H%x00%C(auto)%h%C(auto)%d%C(reset) %s";
let log_limit_str = log_limit.to_string();
// Get commits after merge-base (for dimming logic)
@@ -474,12 +497,65 @@ impl WorktreeSkimItem {
Some(commits) // Some(empty) means dim everything
};
// Get graph output (no --numstat to avoid blank continuation lines)
// Cacheable: the raw `git log --graph` output plus per-commit
// stats. Both are pure functions of (head, width, height); on a
// disk-cache hit we skip the `git log` and `git diff-tree` calls
// entirely. On miss we compute and write through.
let entry = match preview_cache::read_log(repo, head, width, height) {
Some(cached) => cached,
None => {
let Some(fresh) =
Self::compute_log_raw_and_stats(repo, head, log_limit, show_timestamps)
else {
// Match prior behavior: empty output on `git log` failure.
return String::new();
};
preview_cache::write_log(repo, head, width, height, &fresh);
fresh
}
};
let (processed, _hashes) =
process_log_with_dimming(&entry.raw_log, unique_commits.as_ref());
if show_timestamps {
// `format_log_output` reads `epoch_now()` so relative-time
// strings ("5m" / "2h" / "3d") track wall-clock on every call,
// even when serving from cache.
format_log_output(&processed, &entry.stats)
} else {
// Strip hash markers (SOH...NUL) since we're not using format_log_output
strip_hash_markers(&processed)
}
}
/// Run `git log --graph` and (when timestamps are shown) `batch_fetch_stats`,
/// returning the SHA-deterministic payload to store in the disk cache.
/// Returns `None` only when `git log` itself fails — caller renders an
/// empty preview in that case.
fn compute_log_raw_and_stats(
repo: &Repository,
head: &str,
log_limit: usize,
show_timestamps: bool,
) -> Option<preview_cache::LogCacheEntry> {
// Format strings for git log
// Without timestamps: hash (colored/dimmed), then message
// Format includes full hash (for matching) between SOH and NUL delimiters.
// Display content uses \x1f to separate fields for timestamp parsing.
// Format: SOH full_hash NUL short_hash \x1f timestamp \x1f decorations+message
// Using delimiters allows parsing without assuming fixed hash length (SHA-256 safe)
// Note: Use %x01/%x00 (git's hex escapes) to avoid embedding control chars in argv
let timestamp_format = format!(
"--format=%x01%H%x00%C(auto)%h{}%ct{}%C(auto)%d%C(reset) %s",
FIELD_DELIM, FIELD_DELIM
);
let no_timestamp_format = "--format=%x01%H%x00%C(auto)%h%C(auto)%d%C(reset) %s";
let format: &str = if show_timestamps {
&timestamp_format
} else {
no_timestamp_format
};
let log_limit_str = log_limit.to_string();
let args = vec![
"log",
"--graph",
@@ -491,20 +567,20 @@ impl WorktreeSkimItem {
head,
];
if let Ok(log_output) = repo.run_command(&args) {
let (processed, hashes) =
process_log_with_dimming(&log_output, unique_commits.as_ref());
if show_timestamps {
// Batch fetch stats for all commits
let stats = batch_fetch_stats(repo, &hashes);
output.push_str(&format_log_output(&processed, &stats));
} else {
// Strip hash markers (SOH...NUL) since we're not using format_log_output
output.push_str(&strip_hash_markers(&processed));
}
}
let raw_log = repo.run_command(&args).ok()?;
output
let stats = if show_timestamps {
// Pull hashes from the raw log via `process_log_with_dimming`
// with `unique_commits = None` — that path doesn't apply any
// dim styling, so we get a clean hash list for the stats fetch
// without baking dimming into the cached value.
let (_processed, hashes) = process_log_with_dimming(&raw_log, None);
batch_fetch_stats(repo, &hashes)
} else {
std::collections::HashMap::new()
};
Some(preview_cache::LogCacheEntry { raw_log, stats })
}
}
@@ -641,6 +717,163 @@ mod tests {
);
}
#[test]
fn branch_diff_cache_short_circuits_recompute() {
// Pre-populate the disk cache with a sentinel value, then call
// compute — a hit must return the sentinel verbatim instead of
// running git diff. Proves the SHA + width key is the lookup path
// and that a hit short-circuits before `compute_diff_preview`.
let (t, repo) = repo_with_main();
repo.run_command(&["checkout", "-b", "feature"]).unwrap();
std::fs::write(t.path().join("real.txt"), "real\n").unwrap();
repo.run_command(&["add", "real.txt"]).unwrap();
repo.run_command(&["commit", "-m", "real"]).unwrap();
let item = item_at(&repo, "feature");
let base_sha = repo
.run_command(&["rev-parse", "main"])
.unwrap()
.trim()
.to_string();
let sentinel = "SENTINEL_FROM_CACHE";
super::preview_cache::write_branch_diff(&repo, &base_sha, item.head(), 80, sentinel);
let output = WorktreeSkimItem::compute_branch_diff_preview(&repo, &item, 80);
assert_eq!(output, sentinel, "cache hit must return cached value");
}
#[test]
fn branch_diff_cache_writeback_on_miss() {
// After a miss, the next call's cache key must be populated. Width
// is part of the key, so a different width still misses.
let (t, repo) = repo_with_main();
repo.run_command(&["checkout", "-b", "feature"]).unwrap();
std::fs::write(t.path().join("wb.txt"), "wb\n").unwrap();
repo.run_command(&["add", "wb.txt"]).unwrap();
repo.run_command(&["commit", "-m", "wb"]).unwrap();
let item = item_at(&repo, "feature");
let base_sha = repo
.run_command(&["rev-parse", "main"])
.unwrap()
.trim()
.to_string();
assert!(
super::preview_cache::read_branch_diff(&repo, &base_sha, item.head(), 80).is_none()
);
let _ = WorktreeSkimItem::compute_branch_diff_preview(&repo, &item, 80);
assert!(
super::preview_cache::read_branch_diff(&repo, &base_sha, item.head(), 80).is_some()
);
// Different width: miss.
assert!(
super::preview_cache::read_branch_diff(&repo, &base_sha, item.head(), 100).is_none()
);
}
#[test]
fn log_cache_writeback_on_miss() {
// First call populates the cache; the entry must exist after.
// Width is part of the key, so a different width still misses.
let (t, repo) = repo_with_main();
repo.run_command(&["checkout", "-b", "feature"]).unwrap();
std::fs::write(t.path().join("log.txt"), "x\n").unwrap();
repo.run_command(&["add", "log.txt"]).unwrap();
repo.run_command(&["commit", "-m", "log"]).unwrap();
let item = item_at(&repo, "feature");
assert!(super::preview_cache::read_log(&repo, item.head(), 80, 24).is_none());
let _ = WorktreeSkimItem::compute_log_preview(&repo, &item, 80, 24);
let entry = super::preview_cache::read_log(&repo, item.head(), 80, 24)
.expect("cache populated after first compute");
assert!(
!entry.raw_log.is_empty(),
"cached raw log should be non-empty"
);
assert!(
super::preview_cache::read_log(&repo, item.head(), 100, 24).is_none(),
"different width still misses"
);
}
#[test]
fn log_cache_dim_split_tracks_main_advance() {
// Regression for worktrunk-bot's review on PR #2628: the cache key
// is only `(branch_head_sha, w, h)` — main's SHA isn't included —
// so a `git fetch` advancing the default branch must NOT serve
// stale dim/bright styling. The dim split runs on every call from
// a fresh `merge-base` + `rev-list`, even on cache hit.
//
// Setup: feature branches off main, gets a unique commit, then
// main advances to include that commit. Before main advances,
// feature's commit is "unique" (bright). After main advances and
// contains the commit, it's no longer unique (dim).
let (t, repo) = repo_with_main();
repo.run_command(&["checkout", "-b", "feature"]).unwrap();
std::fs::write(t.path().join("f.txt"), "feat\n").unwrap();
repo.run_command(&["add", "f.txt"]).unwrap();
repo.run_command(&["commit", "-m", "feature commit"])
.unwrap();
let feature_head = repo
.run_command(&["rev-parse", "feature"])
.unwrap()
.trim()
.to_string();
let item = ListItem::new_branch(feature_head.clone(), "feature".to_string());
// The dim/bright signal we check is the bold-green branch
// decoration `\x1b[1;32m` — `git log --format=%C(auto)%d` colors
// the branch name (e.g. `feature`) bold-green when bright, and
// `process_log_with_dimming`'s dim path runs `display.ansi_strip()`
// which removes that escape. The dim SGR `\x1b[2m` is unsuitable
// because `format_log_output` already wraps every relative-time
// column in dim, so it appears even in bright lines.
let before = WorktreeSkimItem::compute_log_preview(&repo, &item, 80, 24);
let before_subject_line = before
.lines()
.find(|l| l.contains("feature commit"))
.expect("subject line present before advance");
assert!(
before_subject_line.contains("\x1b[1;32m"),
"before main advance, unique commit should be bright (bold-green branch decoration present), got: {before_subject_line:?}"
);
// Advance main to include feature's commit. Same `feature_head`,
// same cache key — but the dim split now changes because rev-list
// returns no unique commits.
repo.run_command(&["checkout", "main"]).unwrap();
repo.run_command(&["merge", "--ff-only", "feature"])
.unwrap();
repo.run_command(&["checkout", "feature"]).unwrap();
let after = WorktreeSkimItem::compute_log_preview(&repo, &item, 80, 24);
let after_subject_line = after
.lines()
.find(|l| l.contains("feature commit"))
.expect("subject line present after advance");
assert!(
!after_subject_line.contains("\x1b[1;32m"),
"after main advance, commit should be dimmed (bold-green stripped by dim path), got: {after_subject_line:?}"
);
}
#[test]
fn upstream_diff_cache_short_circuits_recompute() {
let (_t, repo) = repo_with_tracked_pair();
let item = item_at(&repo, "feature");
let upstream_sha = repo
.run_command(&["rev-parse", "upstream-base"])
.unwrap()
.trim()
.to_string();
let sentinel = "SENTINEL_UPSTREAM_VALUE";
super::preview_cache::write_upstream_diff(&repo, item.head(), &upstream_sha, 80, sentinel);
let output = WorktreeSkimItem::compute_upstream_diff_preview(&repo, &item, 80);
assert_eq!(output, sentinel);
}
#[test]
fn upstream_diff_no_tracking_branch() {
// Branch with no configured upstream should hit the no-upstream path
+1
View File
@@ -91,6 +91,7 @@ mod items;
mod log_formatter;
mod pager;
mod preview;
pub(crate) mod preview_cache;
mod preview_orchestrator;
mod progressive_handler;
mod summary;
+264
View File
@@ -0,0 +1,264 @@
//! Persistent cache for picker preview content, keyed by SHA + dimensions.
//!
//! Three of the picker's preview modes are deterministic functions of git
//! object SHAs at a given terminal width: Log on `(branch_head_sha)`,
//! BranchDiff on `(default_head_sha, branch_head_sha)`, and UpstreamDiff on
//! `(branch_head_sha, upstream_head_sha)`. Identical inputs produce identical
//! output, so a disk cache hit short-circuits the git subprocess on
//! subsequent `wt switch` invocations. WorkingTree is intentionally not
//! cached — its inputs include the mutable working tree, which has no cheap
//! stable hash. Summary has its own cache (`crate::summary`).
//!
//! Layout: `.git/wt/cache/picker-preview/{mode}-{sha}[-{sha}]-{w}[-{h}].json`.
//! The diff modes cache the pre-pager rendered string; the pager step in
//! `compute_and_page_preview` runs on every read, so changing the
//! configured pager invalidates nothing — the cache is pager-agnostic.
//! The Log mode caches a small struct (raw `git log` output + per-commit
//! stats) and recomputes the dim/bright split and relative-time formatting
//! on every render — see [`LogCacheEntry`] for why.
//!
//! No explicit invalidation: SHAs are content-addressed, so a `git fetch`
//! that moves the default branch or upstream produces fresh keys; the LRU
//! sweep prunes stale entries.
//!
//! Per-kind LRU bound is intentionally small (rendered diffs can be tens to
//! hundreds of KB, much larger than the 80-byte SHA-pair entries in
//! `git/repository/sha_cache.rs`). See [`worktrunk::cache`] for read/write/LRU
//! mechanics, torn-write semantics, and the user-initiated clear error
//! policy.
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use worktrunk::cache;
use worktrunk::git::Repository;
const KIND: &str = "picker-preview";
/// Cached payload for the Log preview.
///
/// The Log render has two time-varying inputs that must be recomputed on
/// every call rather than baked into the cache: the dim/bright split (from
/// `merge-base(default_branch, head)` + `rev-list --right-only`, which
/// shifts as `main` advances) and the relative-time strings ("5m", "2h",
/// "3d", computed against `epoch_now()`). To keep the cache key simple
/// (just `(branch_head_sha, w, h)` — main's SHA stays out so a `git fetch`
/// doesn't invalidate every entry), we cache the SHA-deterministic
/// artifacts only: the raw `git log --graph` output (with `%ct` timestamps
/// embedded) and the per-commit `(insertions, deletions)` map from
/// `batch_fetch_stats`. The render path re-runs `process_log_with_dimming`
/// against fresh `unique_commits` and `format_log_output` against
/// `epoch_now()`, so output stays correct as `main` and wall-clock advance.
#[derive(Serialize, Deserialize)]
pub(super) struct LogCacheEntry {
pub raw_log: String,
/// Empty when `width < TIMESTAMP_WIDTH_THRESHOLD` (the no-timestamp
/// path doesn't fetch stats). Keys are full commit SHAs.
pub stats: HashMap<String, (usize, usize)>,
}
/// 500 entries × tens-of-KB rendered diffs ≈ tens of MB. Tunable; the
/// user-visible knob is `wt config state clear`.
const MAX_ENTRIES: usize = 500;
fn log_key(sha: &str, w: usize, h: usize) -> String {
format!("log-{sha}-{w}-{h}.json")
}
fn branch_diff_key(base_sha: &str, branch_sha: &str, w: usize) -> String {
format!("branch-diff-{base_sha}-{branch_sha}-{w}.json")
}
fn upstream_diff_key(branch_sha: &str, upstream_sha: &str, w: usize) -> String {
format!("upstream-diff-{branch_sha}-{upstream_sha}-{w}.json")
}
pub(super) fn read_log(repo: &Repository, sha: &str, w: usize, h: usize) -> Option<LogCacheEntry> {
cache::read(repo, KIND, &log_key(sha, w, h))
}
pub(super) fn write_log(repo: &Repository, sha: &str, w: usize, h: usize, value: &LogCacheEntry) {
cache::write_with_lru(repo, KIND, &log_key(sha, w, h), value, MAX_ENTRIES);
}
pub(super) fn read_branch_diff(
repo: &Repository,
base_sha: &str,
branch_sha: &str,
w: usize,
) -> Option<String> {
cache::read(repo, KIND, &branch_diff_key(base_sha, branch_sha, w))
}
pub(super) fn write_branch_diff(
repo: &Repository,
base_sha: &str,
branch_sha: &str,
w: usize,
value: &str,
) {
cache::write_with_lru(
repo,
KIND,
&branch_diff_key(base_sha, branch_sha, w),
&value,
MAX_ENTRIES,
);
}
pub(super) fn read_upstream_diff(
repo: &Repository,
branch_sha: &str,
upstream_sha: &str,
w: usize,
) -> Option<String> {
cache::read(repo, KIND, &upstream_diff_key(branch_sha, upstream_sha, w))
}
pub(super) fn write_upstream_diff(
repo: &Repository,
branch_sha: &str,
upstream_sha: &str,
w: usize,
value: &str,
) {
cache::write_with_lru(
repo,
KIND,
&upstream_diff_key(branch_sha, upstream_sha, w),
&value,
MAX_ENTRIES,
);
}
/// Clear all cached preview entries, returning the count of `.json` files
/// removed. Called by `wt config state clear`; see
/// [`worktrunk::cache::clear_json_files`] for the missing-dir /
/// concurrent-removal / error-propagation semantics.
pub(crate) fn clear_all(repo: &Repository) -> anyhow::Result<usize> {
cache::clear_json_files(&cache::cache_dir(repo, KIND))
}
/// Count cached preview entries for `wt config state get`.
pub(crate) fn count_all(repo: &Repository) -> usize {
cache::count_json_files(&cache::cache_dir(repo, KIND))
}
#[cfg(test)]
mod tests {
use super::*;
use worktrunk::testing::TestRepo;
fn sample_log_entry() -> LogCacheEntry {
let mut stats = HashMap::new();
stats.insert("abc123".to_string(), (5, 2));
LogCacheEntry {
raw_log: "raw log content".to_string(),
stats,
}
}
#[test]
fn log_roundtrip() {
let test = TestRepo::with_initial_commit();
let repo = Repository::at(test.root_path()).unwrap();
assert!(read_log(&repo, "deadbeef", 80, 24).is_none());
write_log(&repo, "deadbeef", 80, 24, &sample_log_entry());
let read = read_log(&repo, "deadbeef", 80, 24).expect("entry exists");
assert_eq!(read.raw_log, "raw log content");
assert_eq!(read.stats.get("abc123"), Some(&(5, 2)));
}
#[test]
fn log_width_invalidates() {
let test = TestRepo::with_initial_commit();
let repo = Repository::at(test.root_path()).unwrap();
write_log(&repo, "deadbeef", 80, 24, &sample_log_entry());
// Different width misses — render width changes the requested log
// format (with vs without timestamps), so cached entries cannot be
// reused. Different height misses for the same reason via log_limit.
assert!(read_log(&repo, "deadbeef", 100, 24).is_none());
assert!(read_log(&repo, "deadbeef", 80, 30).is_none());
}
#[test]
fn log_sha_invalidates() {
let test = TestRepo::with_initial_commit();
let repo = Repository::at(test.root_path()).unwrap();
write_log(&repo, "deadbeef", 80, 24, &sample_log_entry());
assert!(read_log(&repo, "cafe", 80, 24).is_none());
}
#[test]
fn branch_diff_roundtrip_and_asymmetric() {
let test = TestRepo::with_initial_commit();
let repo = Repository::at(test.root_path()).unwrap();
write_branch_diff(&repo, "base", "tip", 80, "rendered diff");
assert_eq!(
read_branch_diff(&repo, "base", "tip", 80),
Some("rendered diff".to_string())
);
// Asymmetric: swapping is a different key.
assert_eq!(read_branch_diff(&repo, "tip", "base", 80), None);
}
#[test]
fn upstream_diff_roundtrip() {
let test = TestRepo::with_initial_commit();
let repo = Repository::at(test.root_path()).unwrap();
write_upstream_diff(&repo, "branch", "upstream", 80, "rendered upstream diff");
assert_eq!(
read_upstream_diff(&repo, "branch", "upstream", 80),
Some("rendered upstream diff".to_string())
);
}
#[test]
fn modes_share_kind_but_distinct_keys() {
// Same SHA + width across modes must not collide — the mode prefix
// in the filename is what keeps Log, BranchDiff, and UpstreamDiff
// separated under a single cache kind.
let test = TestRepo::with_initial_commit();
let repo = Repository::at(test.root_path()).unwrap();
write_log(&repo, "x", 80, 24, &sample_log_entry());
write_branch_diff(&repo, "x", "x", 80, "branch-diff-value");
write_upstream_diff(&repo, "x", "x", 80, "upstream-diff-value");
assert_eq!(
read_log(&repo, "x", 80, 24).unwrap().raw_log,
"raw log content"
);
assert_eq!(
read_branch_diff(&repo, "x", "x", 80).unwrap(),
"branch-diff-value"
);
assert_eq!(
read_upstream_diff(&repo, "x", "x", 80).unwrap(),
"upstream-diff-value"
);
assert_eq!(count_all(&repo), 3);
}
#[test]
fn clear_all_removes_entries() {
let test = TestRepo::with_initial_commit();
let repo = Repository::at(test.root_path()).unwrap();
write_log(&repo, "a", 80, 24, &sample_log_entry());
write_log(&repo, "b", 80, 24, &sample_log_entry());
write_branch_diff(&repo, "base", "tip", 80, "z");
assert_eq!(count_all(&repo), 3);
let removed = clear_all(&repo).unwrap();
assert_eq!(removed, 3);
assert_eq!(count_all(&repo), 0);
assert!(read_log(&repo, "a", 80, 24).is_none());
}
}
@@ -21,13 +21,17 @@ info:
WORKTRUNK_APPROVALS_PATH: /nonexistent/wt/approvals.toml
WORKTRUNK_CONFIG_PATH: /nonexistent/wt/config.toml
WORKTRUNK_SYSTEM_CONFIG_PATH: /etc/xdg/worktrunk/config.toml
WORKTRUNK_TEST_BASH_INSTALLED: "0"
WORKTRUNK_TEST_CLAUDE_INSTALLED: "0"
WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1"
WORKTRUNK_TEST_EPOCH: "1735776000"
WORKTRUNK_TEST_FISH_INSTALLED: "0"
WORKTRUNK_TEST_NUSHELL_ENV: "0"
WORKTRUNK_TEST_OPENCODE_INSTALLED: "0"
WORKTRUNK_TEST_POWERSHELL_ENV: "0"
WORKTRUNK_TEST_POWERSHELL_INSTALLED: "0"
WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1"
WORKTRUNK_TEST_ZSH_INSTALLED: "0"
---
success: true
exit_code: 0
@@ -21,13 +21,17 @@ info:
WORKTRUNK_APPROVALS_PATH: /nonexistent/wt/approvals.toml
WORKTRUNK_CONFIG_PATH: /nonexistent/wt/config.toml
WORKTRUNK_SYSTEM_CONFIG_PATH: /etc/xdg/worktrunk/config.toml
WORKTRUNK_TEST_BASH_INSTALLED: "0"
WORKTRUNK_TEST_CLAUDE_INSTALLED: "0"
WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1"
WORKTRUNK_TEST_EPOCH: "1735776000"
WORKTRUNK_TEST_FISH_INSTALLED: "0"
WORKTRUNK_TEST_NUSHELL_ENV: "0"
WORKTRUNK_TEST_OPENCODE_INSTALLED: "0"
WORKTRUNK_TEST_POWERSHELL_ENV: "0"
WORKTRUNK_TEST_POWERSHELL_INSTALLED: "0"
WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1"
WORKTRUNK_TEST_ZSH_INSTALLED: "0"
---
success: true
exit_code: 0
@@ -66,7 +70,7 @@ Shows all stored state including:
- Vars: Custom variables per branch
- CI status: Cached GitHub/GitLab CI status per branch (30s TTL)
- Summaries: Cached LLM-generated branch summaries (shown in wt list --full and wt switch preview)
- Git commands cache: SHA-keyed merge-tree, ancestry, and diff-stats results
- Git commands cache: SHA-keyed disk caches — merge-tree, ancestry, diff-stats, and wt switch preview renders
- Hints: One-time hints that have been shown
- Log files: Operation and debug logs
- Trash: Staged worktree directories awaiting background deletion