Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 37 additions & 5 deletions src/commands/list/collect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -380,7 +380,7 @@ fn apply_default(items: &mut [ListItem], status_contexts: &mut [StatusContext],
items[idx].is_ancestor = Some(false);
}
TaskKind::BranchDiff => {
items[idx].branch_diff = Some(BranchDiffTotals::default());
// Leave as None — UI shows `…` for skipped/failed tasks
}
TaskKind::WorkingTreeDiff => {
if let ItemKind::Worktree(data) = &mut items[idx].kind {
Expand Down Expand Up @@ -669,6 +669,13 @@ fn worktree_branch_set(worktrees: &[Worktree]) -> std::collections::HashSet<&str
///
/// The `command_timeout` parameter, if set, limits how long individual git commands can run.
/// This is useful for `wt select` to show the TUI faster by skipping slow operations.
///
/// TODO: Now that we skip expensive tasks for stale branches (see `skip_expensive_for_stale`),
/// the timeout may be unnecessary. Consider removing it if it doesn't provide value.
///
/// The `skip_expensive_for_stale` parameter enables batch-fetching ahead/behind counts and
/// skipping expensive merge-base operations for branches far behind the default branch.
/// This dramatically improves performance for repos with many stale branches.
#[allow(clippy::too_many_arguments)]
pub fn collect(
repo: &Repository,
Expand All @@ -679,6 +686,7 @@ pub fn collect(
render_table: bool,
config: &worktrunk::config::WorktrunkConfig,
command_timeout: Option<std::time::Duration>,
skip_expensive_for_stale: bool,
) -> anyhow::Result<Option<super::model::ListData>> {
use super::progressive_table::ProgressiveTable;

Expand Down Expand Up @@ -868,9 +876,10 @@ pub fn collect(
let max_width = crate::display::get_terminal_width();

// Create collection options from skip set
let options = super::collect_progressive_impl::CollectOptions {
let mut options = super::collect_progressive_impl::CollectOptions {
skip_tasks: effective_skip_tasks,
url_template: url_template.clone(),
..Default::default()
};

// Track expected results per item - populated as spawns are queued
Expand Down Expand Up @@ -948,6 +957,28 @@ pub fn collect(
// Deferred until after skeleton to avoid blocking initial render.
let integration_target = repo.effective_integration_target(&default_branch);

// Batch-fetch ahead/behind counts to identify branches that are far behind.
// This allows skipping expensive merge-base operations for diverged branches, dramatically
// improving performance on repos with many stale branches (e.g., wt select).
//
// Uses `git for-each-ref --format='%(ahead-behind:...)'` (git 2.36+) which gets all
// counts in a single command. On older git versions, returns empty and all tasks run.
if skip_expensive_for_stale {
// Branches more than 50 commits behind skip expensive merge-base operations.
// 50 is low enough to catch truly stale branches while keeping info for
// recently-diverged ones. The "behind" count is the primary expense driver -
// git must traverse all those commits to find the merge-base.
let threshold: usize = std::env::var("WORKTRUNK_TEST_SKIP_EXPENSIVE_THRESHOLD")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(50);
let ahead_behind = repo.batch_ahead_behind(&default_branch);
if !ahead_behind.is_empty() {
options.branch_ahead_behind = ahead_behind;
options.skip_expensive_threshold = Some(threshold);
}
}

// Note: URL template expansion is deferred to task spawning (in collect_worktree_progressive
// and collect_branch_progressive). This parallelizes the work and minimizes time-to-skeleton.

Expand Down Expand Up @@ -990,7 +1021,7 @@ pub fn collect(
let default_branch_clone = default_branch.clone();
let target_clone = integration_target.clone();
let expected_results_clone = expected_results.clone();
let options_clone = options.clone();
// Move options into the worker thread (not cloned - it can be large with branch_ahead_behind)
let main_path = main_worktree.path.clone();

// Prepare branch data if needed (before moving into closure)
Expand Down Expand Up @@ -1031,7 +1062,7 @@ pub fn collect(
idx,
&default_branch_clone,
&target_clone,
&options_clone,
&options,
&expected_results_clone,
&tx_worker,
));
Expand All @@ -1046,8 +1077,9 @@ pub fn collect(
*item_idx,
&default_branch_clone,
&target_clone,
&options_clone,
&options,
&expected_results_clone,
&tx_worker,
));
}

Expand Down
108 changes: 102 additions & 6 deletions src/commands/list/collect_progressive_impl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,28 @@ pub struct CollectOptions {
/// URL template from project config (e.g., "http://localhost:{{ branch | hash_port }}").
/// Expanded per-item in task spawning (post-skeleton) to minimize time-to-skeleton.
pub url_template: Option<String>,

/// Pre-fetched ahead/behind counts for branches (from batched `git for-each-ref`).
/// Used to skip expensive tasks for branches that are far behind the default branch.
/// The counts are (ahead, behind). If empty, all tasks run normally.
pub branch_ahead_behind: std::collections::HashMap<String, (usize, usize)>,

/// Threshold for skipping expensive tasks. Branches with `behind > threshold`
/// will skip merge-base-dependent tasks (HasFileChanges, IsAncestor, WouldMergeAdd,
/// BranchDiff, MergeTreeConflicts). AheadBehind uses batch data instead of skipping.
/// CommittedTreesMatch is cheap and kept for integration detection.
///
/// **Display implications:** When tasks are skipped:
/// - BranchDiff column shows `…` instead of diff stats
/// - Status symbols (conflict `✗`, integrated `⊂`) may be missing or incorrect
/// since they depend on skipped tasks
///
/// Note: `wt select` doesn't show the BranchDiff column, so `…` isn't visible there.
/// This is similar to how `✗` conflict only shows with `--full` even in `wt list`.
///
/// TODO: Consider adding a visible indicator in Status column when integration
/// checks are skipped, so users know the `⊂` symbol may be incomplete.
pub skip_expensive_threshold: Option<usize>,
}

/// Context for task computation. Cloned and moved into spawned threads.
Expand Down Expand Up @@ -161,6 +183,31 @@ fn dispatch_task(kind: TaskKind, ctx: TaskContext) -> Result<TaskResult, TaskErr
}
}

// Tasks that are expensive because they require merge-base computation or merge simulation.
// These are skipped for branches that are far behind the default branch (in wt select).
// AheadBehind is NOT here - we use batch data for it instead of skipping.
// CommittedTreesMatch is NOT here - it's a cheap tree comparison that aids integration detection.
const EXPENSIVE_TASKS: &[TaskKind] = &[
TaskKind::HasFileChanges, // git diff with three-dot range
TaskKind::IsAncestor, // git merge-base --is-ancestor
TaskKind::WouldMergeAdd, // git merge-tree simulation
TaskKind::BranchDiff, // git diff with three-dot range
TaskKind::MergeTreeConflicts, // git merge-tree simulation
];

/// Check if a branch should skip expensive tasks based on how far behind it is.
/// Returns Some((ahead, behind)) if should skip, None otherwise.
fn should_skip_expensive(branch: Option<&str>, options: &CollectOptions) -> Option<(usize, usize)> {
let threshold = options.skip_expensive_threshold?;
let branch = branch?;
let &(ahead, behind) = options.branch_ahead_behind.get(branch)?;
if behind > threshold {
Some((ahead, behind))
} else {
None
}
}

/// Generate work items for a worktree.
///
/// Returns a list of work items representing all tasks that should run for this
Expand All @@ -180,6 +227,8 @@ pub fn work_items_for_worktree(
return vec![];
}

let skip = &options.skip_tasks;

// Expand URL template for this item
let item_url = options.url_template.as_ref().and_then(|template| {
wt.branch.as_ref().and_then(|branch| {
Expand Down Expand Up @@ -210,7 +259,21 @@ pub fn work_items_for_worktree(
item_url,
};

let skip = &options.skip_tasks;
// Check if this branch is far behind and should skip expensive tasks.
// If so, we get the batch-computed (ahead, behind) to send immediately.
let batch_counts = should_skip_expensive(wt.branch.as_deref(), options);

// If we have batch counts and AheadBehind isn't skipped, send result immediately
if let Some((ahead, behind)) = batch_counts
&& !skip.contains(&TaskKind::AheadBehind)
{
expected_results.expect(item_idx, TaskKind::AheadBehind);
let _ = tx.send(Ok(TaskResult::AheadBehind {
item_idx,
counts: AheadBehind { ahead, behind },
}));
}

let mut items = Vec::with_capacity(15);

// Helper to add a work item and register the expected result
Expand Down Expand Up @@ -238,9 +301,17 @@ pub fn work_items_for_worktree(
TaskKind::CiStatus,
TaskKind::WouldMergeAdd,
] {
if !skip.contains(&kind) {
add_item(kind);
if skip.contains(&kind) {
continue;
}
// Skip AheadBehind if we already sent batch data
if batch_counts.is_some() && kind == TaskKind::AheadBehind {
continue;
}
if batch_counts.is_some() && EXPENSIVE_TASKS.contains(&kind) {
continue;
}
add_item(kind);
}
// URL status health check task (if we have a URL).
// Note: We already registered and sent an immediate UrlStatus above with url + active=None.
Expand Down Expand Up @@ -271,7 +342,10 @@ pub fn work_items_for_branch(
target: &str,
options: &CollectOptions,
expected_results: &Arc<ExpectedResults>,
tx: &Sender<Result<TaskResult, TaskError>>,
) -> Vec<WorkItem> {
let skip = &options.skip_tasks;

let ctx = TaskContext {
repo_path: repo_path.to_path_buf(),
commit_sha: commit_sha.to_string(),
Expand All @@ -282,7 +356,21 @@ pub fn work_items_for_branch(
item_url: None, // Branches without worktrees don't have URLs
};

let skip = &options.skip_tasks;
// Check if this branch is far behind and should skip expensive tasks.
// If so, we get the batch-computed (ahead, behind) to send immediately.
let batch_counts = should_skip_expensive(Some(branch_name), options);

// If we have batch counts and AheadBehind isn't skipped, send result immediately
if let Some((ahead, behind)) = batch_counts
&& !skip.contains(&TaskKind::AheadBehind)
{
expected_results.expect(item_idx, TaskKind::AheadBehind);
let _ = tx.send(Ok(TaskResult::AheadBehind {
item_idx,
counts: AheadBehind { ahead, behind },
}));
}

let mut items = Vec::with_capacity(11);

// Helper to add a work item and register the expected result
Expand All @@ -306,9 +394,17 @@ pub fn work_items_for_branch(
TaskKind::CiStatus,
TaskKind::WouldMergeAdd,
] {
if !skip.contains(&kind) {
add_item(kind);
if skip.contains(&kind) {
continue;
}
// Skip AheadBehind if we already sent batch data
if batch_counts.is_some() && kind == TaskKind::AheadBehind {
continue;
}
if batch_counts.is_some() && EXPENSIVE_TASKS.contains(&kind) {
continue;
}
add_item(kind);
}

items
Expand Down
4 changes: 4 additions & 0 deletions src/commands/list/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,9 @@ pub fn handle_list(
// Render table in collect() for all table modes (progressive + buffered)
let render_table = matches!(format, crate::OutputFormat::Table);

// For testing: allow enabling skip_expensive_for_stale via env var
let skip_expensive_for_stale = std::env::var("WORKTRUNK_TEST_SKIP_EXPENSIVE_THRESHOLD").is_ok();

let list_data = collect::collect(
&repo,
show_branches,
Expand All @@ -187,6 +190,7 @@ pub fn handle_list(
render_table,
config,
None, // No timeout for wt list
skip_expensive_for_stale,
)?;

let Some(ListData { items, .. }) = list_data else {
Expand Down
12 changes: 6 additions & 6 deletions src/commands/list/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -433,8 +433,8 @@ impl ListItem {
self.counts.unwrap_or_default()
}

pub fn branch_diff(&self) -> BranchDiffTotals {
self.branch_diff.unwrap_or_default()
pub fn branch_diff(&self) -> Option<&BranchDiffTotals> {
self.branch_diff.as_ref()
}

pub fn upstream(&self) -> UpstreamStatus {
Expand Down Expand Up @@ -556,8 +556,8 @@ impl ListItem {
}

// 5. Branch diff vs main (priority 5)
let branch_diff = self.branch_diff();
if !branch_diff.diff.is_empty()
if let Some(branch_diff) = self.branch_diff()
&& !branch_diff.diff.is_empty()
&& let Some(formatted) = ColumnKind::BranchDiff
.format_diff_plain(branch_diff.diff.added, branch_diff.diff.deleted)
{
Expand Down Expand Up @@ -2036,8 +2036,8 @@ mod tests {
#[test]
fn test_list_item_branch_diff() {
let item = ListItem::new_branch("abc123".to_string(), "feature".to_string());
let diff = item.branch_diff();
assert!(diff.diff.is_empty());
// New items have no branch_diff computed yet
assert!(item.branch_diff().is_none());
}

#[test]
Expand Down
17 changes: 14 additions & 3 deletions src/commands/list/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -333,7 +333,8 @@ struct ListRowContext<'a> {
item: &'a ListItem,
worktree_data: Option<&'a WorktreeData>,
counts: AheadBehind,
branch_diff: LineDiff,
/// None means task was skipped (show `…`), Some means computed (may be zero)
branch_diff: Option<LineDiff>,
upstream: UpstreamStatus,
commit: CommitDetails,
head: &'a str,
Expand All @@ -347,7 +348,7 @@ impl<'a> ListRowContext<'a> {
let worktree_data = item.worktree_data();
let counts = item.counts();
let commit = item.commit_details();
let branch_diff = item.branch_diff().diff;
let branch_diff = item.branch_diff().map(|bd| bd.diff);
let upstream = item.upstream();
let head = item.head();

Expand Down Expand Up @@ -478,7 +479,17 @@ impl ColumnLayout {
if ctx.item.is_main() {
return StyledLine::new();
}
self.render_diff_cell(ctx.branch_diff.added, ctx.branch_diff.deleted)
match ctx.branch_diff {
Some(diff) => self.render_diff_cell(diff.added, diff.deleted),
None => {
// Task was skipped — show ellipsis to indicate "not computed"
let mut cell = StyledLine::new();
let padding = self.width.saturating_sub(1);
cell.push_raw(" ".repeat(padding));
cell.push_styled("…", Style::new().dimmed());
cell
}
}
}
ColumnKind::Path => {
let Some(data) = ctx.worktree_data else {
Expand Down
1 change: 1 addition & 0 deletions src/commands/select.rs
Original file line number Diff line number Diff line change
Expand Up @@ -886,6 +886,7 @@ pub fn handle_select() -> anyhow::Result<()> {
false, // render_table (select renders its own UI)
&config,
command_timeout,
true, // skip_expensive_for_stale (faster for repos with many stale branches)
)?
else {
return Ok(());
Expand Down
Loading