mirror of
https://github.com/max-sixty/worktrunk.git
synced 2026-09-14 20:00:38 +08:00
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:
+1
-1
@@ -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
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
×tamp_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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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:
|
||||
- [1mVars[0m: Custom variables per branch
|
||||
- [1mCI status[0m: Cached GitHub/GitLab CI status per branch (30s TTL)
|
||||
- [1mSummaries[0m: Cached LLM-generated branch summaries (shown in [2mwt list --full[0m and [2mwt switch[0m preview)
|
||||
- [1mGit commands cache[0m: SHA-keyed merge-tree, ancestry, and diff-stats results
|
||||
- [1mGit commands cache[0m: SHA-keyed disk caches — merge-tree, ancestry, diff-stats, and [2mwt switch[0m preview renders
|
||||
- [1mHints[0m: One-time hints that have been shown
|
||||
- [1mLog files[0m: Operation and debug logs
|
||||
- [1mTrash[0m: Staged worktree directories awaiting background deletion
|
||||
|
||||
Reference in New Issue
Block a user