diff --git a/src/commands/list/collect.rs b/src/commands/list/collect.rs index 341c20926..91380ab0f 100644 --- a/src/commands/list/collect.rs +++ b/src/commands/list/collect.rs @@ -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 { @@ -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, @@ -679,6 +686,7 @@ pub fn collect( render_table: bool, config: &worktrunk::config::WorktrunkConfig, command_timeout: Option, + skip_expensive_for_stale: bool, ) -> anyhow::Result> { use super::progressive_table::ProgressiveTable; @@ -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 @@ -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. @@ -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) @@ -1031,7 +1062,7 @@ pub fn collect( idx, &default_branch_clone, &target_clone, - &options_clone, + &options, &expected_results_clone, &tx_worker, )); @@ -1046,8 +1077,9 @@ pub fn collect( *item_idx, &default_branch_clone, &target_clone, - &options_clone, + &options, &expected_results_clone, + &tx_worker, )); } diff --git a/src/commands/list/collect_progressive_impl.rs b/src/commands/list/collect_progressive_impl.rs index 4b71bf78b..e36156c36 100644 --- a/src/commands/list/collect_progressive_impl.rs +++ b/src/commands/list/collect_progressive_impl.rs @@ -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, + + /// 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, + + /// 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, } /// Context for task computation. Cloned and moved into spawned threads. @@ -161,6 +183,31 @@ fn dispatch_task(kind: TaskKind, ctx: TaskContext) -> Result, 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 @@ -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| { @@ -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 @@ -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. @@ -271,7 +342,10 @@ pub fn work_items_for_branch( target: &str, options: &CollectOptions, expected_results: &Arc, + tx: &Sender>, ) -> Vec { + let skip = &options.skip_tasks; + let ctx = TaskContext { repo_path: repo_path.to_path_buf(), commit_sha: commit_sha.to_string(), @@ -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 @@ -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 diff --git a/src/commands/list/mod.rs b/src/commands/list/mod.rs index 0b2bb7967..971dbd9d7 100644 --- a/src/commands/list/mod.rs +++ b/src/commands/list/mod.rs @@ -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, @@ -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 { diff --git a/src/commands/list/model.rs b/src/commands/list/model.rs index a754c61ec..df80ff604 100644 --- a/src/commands/list/model.rs +++ b/src/commands/list/model.rs @@ -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 { @@ -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) { @@ -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] diff --git a/src/commands/list/render.rs b/src/commands/list/render.rs index 4e35a0191..ed9fef567 100644 --- a/src/commands/list/render.rs +++ b/src/commands/list/render.rs @@ -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, upstream: UpstreamStatus, commit: CommitDetails, head: &'a str, @@ -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(); @@ -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 { diff --git a/src/commands/select.rs b/src/commands/select.rs index eb3ae0fa9..fb4cb7ddb 100644 --- a/src/commands/select.rs +++ b/src/commands/select.rs @@ -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(()); diff --git a/src/git/repository/mod.rs b/src/git/repository/mod.rs index 665e51003..2f263c29f 100644 --- a/src/git/repository/mod.rs +++ b/src/git/repository/mod.rs @@ -1060,6 +1060,43 @@ impl Repository { Ok((ahead, behind)) } + /// Batch-fetch ahead/behind counts for all local branches vs a base ref. + /// + /// Uses `git for-each-ref --format='%(ahead-behind:BASE)'` (git 2.36+) to get + /// all counts in a single command. Returns a map from branch name to (ahead, behind). + /// + /// On git < 2.36 or if the command fails, returns an empty map. + pub fn batch_ahead_behind( + &self, + base: &str, + ) -> std::collections::HashMap { + let format = format!("%(refname:lstrip=2) %(ahead-behind:{})", base); + let output = match self.run_command(&[ + "for-each-ref", + &format!("--format={}", format), + "refs/heads/", + ]) { + Ok(output) => output, + Err(e) => { + // Fails on git < 2.36 (no %(ahead-behind:) support), invalid base ref, etc. + log::debug!("batch_ahead_behind({base}): git for-each-ref failed: {e}"); + return std::collections::HashMap::new(); + } + }; + + output + .lines() + .filter_map(|line| { + // Format: "branch-name ahead behind" + let mut parts = line.rsplitn(3, ' '); + let behind: usize = parts.next()?.parse().ok()?; + let ahead: usize = parts.next()?.parse().ok()?; + let branch = parts.next()?.to_string(); + Some((branch, (ahead, behind))) + }) + .collect() + } + /// List all local branches with their HEAD commit SHA. /// Returns a vector of (branch_name, commit_sha) tuples. pub fn list_local_branches(&self) -> anyhow::Result> { diff --git a/tests/integration_tests/list.rs b/tests/integration_tests/list.rs index 258374f69..eaef4f641 100644 --- a/tests/integration_tests/list.rs +++ b/tests/integration_tests/list.rs @@ -2363,3 +2363,60 @@ fn test_list_skips_operations_for_prunable_worktrees(mut repo: TestRepo) { // wt list should show the prunable worktree with ⊟ symbol but NO error warnings assert_cmd_snapshot!(list_snapshots::command(&repo, repo.root_path())); } + +/// Tests that branches far behind main show `…` instead of diff stats when +/// skip_expensive_for_stale is enabled. This saves time in `wt select` for +/// repos with many stale branches. +/// +/// The `…` indicator distinguishes "not computed" from "zero changes" (blank). +#[rstest] +fn test_list_skips_expensive_for_stale_branches(mut repo: TestRepo) { + // Create feature branch at current main + let feature_path = repo.add_worktree("feature"); + + // Advance main by 2 commits (feature will be 2 behind) + repo.commit("Second commit on main"); + repo.commit("Third commit on main"); + + // Add a change on feature so it's not integrated + std::fs::write(feature_path.join("feature.txt"), "feature content").unwrap(); + repo.git_command() + .args(["add", "feature.txt"]) + .current_dir(&feature_path) + .output() + .unwrap(); + repo.git_command() + .args(["commit", "-m", "Feature work"]) + .current_dir(&feature_path) + .output() + .unwrap(); + + // With threshold=1, feature branch (2 behind) should skip expensive tasks + // and show `…` instead of actual diff stats + assert_cmd_snapshot!({ + let mut cmd = list_snapshots::command(&repo, repo.root_path()); + cmd.arg("--full"); // Need --full to show BranchDiff column + cmd.env("WORKTRUNK_TEST_SKIP_EXPENSIVE_THRESHOLD", "1"); + cmd + }); +} + +/// Tests skip_expensive_for_stale with branch-only entries (no worktree). +/// This exercises a different code path than the worktree test above. +#[rstest] +fn test_list_skips_expensive_for_stale_branches_only(repo: TestRepo) { + // Create a branch without a worktree + repo.create_branch("stale-branch"); + + // Advance main by 2 commits (stale-branch will be 2 behind) + repo.commit("Second commit on main"); + repo.commit("Third commit on main"); + + // With threshold=1, stale-branch (2 behind) should skip expensive tasks + assert_cmd_snapshot!({ + let mut cmd = list_snapshots::command(&repo, repo.root_path()); + cmd.args(["--branches", "--full"]); + cmd.env("WORKTRUNK_TEST_SKIP_EXPENSIVE_THRESHOLD", "1"); + cmd + }); +} diff --git a/tests/snapshots/integration__integration_tests__list__list_skips_expensive_for_stale_branches.snap b/tests/snapshots/integration__integration_tests__list__list_skips_expensive_for_stale_branches.snap new file mode 100644 index 000000000..a03b0c45e --- /dev/null +++ b/tests/snapshots/integration__integration_tests__list__list_skips_expensive_for_stale_branches.snap @@ -0,0 +1,40 @@ +--- +source: tests/integration_tests/list.rs +info: + program: wt + args: + - list + - "--full" + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_AUTHOR_DATE: "2025-01-01T00:00:00Z" + GIT_COMMITTER_DATE: "2025-01-01T00:00:00Z" + GIT_CONFIG_GLOBAL: "[TEST_GIT_CONFIG]" + GIT_CONFIG_SYSTEM: /dev/null + GIT_EDITOR: "" + GIT_TERMINAL_PROMPT: "0" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + PATH: "[PATH]" + RUST_LOG: warn + SOURCE_DATE_EPOCH: "1735776000" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_TEST_SKIP_EXPENSIVE_THRESHOLD: "1" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: true +exit_code: 0 +----- stdout ----- + Branch Status HEAD± main↕ main…± Path Remote⇅ CI Commit Age Message +@ main ^ . 355b9bf5 1d Third commit on main ++ feature ↕ ↑1 ↓2 … ../repo.feature 0e04169e 1d Feature work + +○ Showing 2 worktrees, 1 ahead + +----- stderr ----- diff --git a/tests/snapshots/integration__integration_tests__list__list_skips_expensive_for_stale_branches_only.snap b/tests/snapshots/integration__integration_tests__list__list_skips_expensive_for_stale_branches_only.snap new file mode 100644 index 000000000..6d6d7f60b --- /dev/null +++ b/tests/snapshots/integration__integration_tests__list__list_skips_expensive_for_stale_branches_only.snap @@ -0,0 +1,41 @@ +--- +source: tests/integration_tests/list.rs +info: + program: wt + args: + - list + - "--branches" + - "--full" + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_AUTHOR_DATE: "2025-01-01T00:00:00Z" + GIT_COMMITTER_DATE: "2025-01-01T00:00:00Z" + GIT_CONFIG_GLOBAL: "[TEST_GIT_CONFIG]" + GIT_CONFIG_SYSTEM: /dev/null + GIT_EDITOR: "" + GIT_TERMINAL_PROMPT: "0" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + PATH: "[PATH]" + RUST_LOG: warn + SOURCE_DATE_EPOCH: "1735776000" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_TEST_SKIP_EXPENSIVE_THRESHOLD: "1" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: true +exit_code: 0 +----- stdout ----- + Branch Status HEAD± main↕ main…± Path Remote⇅ CI Commit Age Message +@ main ^ . 355b9bf5 1d Third commit on main + stale-branch /↓ ↓2 … a1e809f5 1d Initial commit + +○ Showing 1 worktrees, 1 branches + +----- stderr -----