From 9fdf274671f0e340a3b09ac64e27df96c5ceb93d Mon Sep 17 00:00:00 2001 From: "Jasmeet (Jazz) Bhatia" Date: Sun, 11 Oct 2026 20:29:41 -0700 Subject: [PATCH 1/9] perf(gui): avoid full refresh after push --- src/git/commit.rs | 28 +++++++++-- src/gui/controller/branches.rs | 10 ++-- src/gui/controller/commits.rs | 61 +++++++++++++++-------- src/gui/controller/files.rs | 88 +++++++++++++++++++++++----------- src/gui/controller/remotes.rs | 50 ++++++++++++------- src/gui/controller/tags.rs | 10 ++-- src/gui/mod.rs | 84 +++++++++++++++++++++++++++----- 7 files changed, 240 insertions(+), 91 deletions(-) diff --git a/src/git/commit.rs b/src/git/commit.rs index ab484d0..0bddf38 100644 --- a/src/git/commit.rs +++ b/src/git/commit.rs @@ -32,7 +32,30 @@ fn commit_filter_path_suggestions<'a>(paths: impl Iterator) -> V #[cfg(test)] mod commit_filter_suggestion_tests { - use super::commit_filter_path_suggestions; + use super::{GitCommands, commit_filter_path_suggestions}; + use crate::model::commit::{Commit, CommitStatus, Divergence}; + + #[test] + fn empty_unpushed_list_marks_previous_unpushed_commit_as_pushed() { + let mut commits = vec![Commit { + hash: "abc123".to_string(), + name: "test commit".to_string(), + status: CommitStatus::Unpushed, + action: String::new(), + tags: Vec::new(), + refs: Vec::new(), + extra_info: String::new(), + author_name: String::new(), + author_email: String::new(), + unix_timestamp: 0, + parents: Vec::new(), + divergence: Divergence::None, + }]; + + GitCommands::apply_unpushed_status(&mut commits, &[]); + + assert_eq!(commits[0].status, CommitStatus::Pushed); + } #[test] fn path_suggestions_include_files_and_each_parent_directory() { @@ -327,9 +350,6 @@ impl GitCommands { } pub fn apply_unpushed_status(commits: &mut [Commit], unpushed_hashes: &[String]) { - if unpushed_hashes.is_empty() { - return; - } for commit in commits { commit.status = if unpushed_hashes.contains(&commit.hash) { CommitStatus::Unpushed diff --git a/src/gui/controller/branches.rs b/src/gui/controller/branches.rs index b7ae85c..c62adb4 100644 --- a/src/gui/controller/branches.rs +++ b/src/gui/controller/branches.rs @@ -1,11 +1,10 @@ -use anyhow::Result; -use crossterm::event::{KeyCode, KeyEvent}; - use crate::config::KeybindingConfig; use crate::config::keybindings::parse_key; -use crate::gui::Gui; use crate::gui::popup::{MenuItem, MessageKind, PopupState, make_textarea}; +use crate::gui::{Gui, RemoteOpKind}; use crate::os::platform::Platform; +use anyhow::Result; +use crossterm::event::{KeyCode, KeyEvent}; pub fn handle_key(gui: &mut Gui, key: KeyEvent, keybindings: &KeybindingConfig) -> Result<()> { if super::commits::matches_key(key, &keybindings.commits.open_log_menu) { @@ -209,6 +208,7 @@ fn start_async_checkout(gui: &mut Gui, name: String) { // displayed branch. Successful completion triggers the normal model refresh. gui.pending_checkout_by_name = Some(name.clone()); gui.start_remote_op( + RemoteOpKind::Other, "Checking out", &format!("Checking out {}", name), move |git| { @@ -601,6 +601,7 @@ fn fast_forward(gui: &mut Gui) -> Result<()> { let name = branch.name.clone(); drop(model); gui.start_remote_op( + RemoteOpKind::Fetch, "Fetch", &format!("Fetching origin for {}...", name), |git| { @@ -626,6 +627,7 @@ fn set_upstream(gui: &mut Gui) -> Result<()> { on_confirm: Box::new(move |gui| { let branch = name.clone(); gui.start_remote_op( + RemoteOpKind::Push, "Push", &format!("Pushing -u origin {}...", branch), move |git| { diff --git a/src/gui/controller/commits.rs b/src/gui/controller/commits.rs index b4f8f41..2d0fec5 100644 --- a/src/gui/controller/commits.rs +++ b/src/gui/controller/commits.rs @@ -1,19 +1,18 @@ use std::collections::HashSet; use std::sync::Arc; -use anyhow::Result; -use crossterm::event::KeyEvent; - use crate::config::KeybindingConfig; use crate::config::keybindings::parse_key; use crate::git::rebase::RebaseAction; -use crate::gui::Gui; use crate::gui::popup::{ BodySoftWrap, ChecklistItem, CommitInputFocus, CommitInputKind, ListPickerItem, MenuItem, MessageKind, PopupState, make_textarea, }; +use crate::gui::{Gui, RemoteOpKind}; use crate::model::Branch; use crate::os::platform::Platform; +use anyhow::Result; +use crossterm::event::KeyEvent; pub fn handle_key(gui: &mut Gui, key: KeyEvent, keybindings: &KeybindingConfig) -> Result<()> { // Esc: cancel range select first, then clear active commit filter, then clear clipboard @@ -213,15 +212,25 @@ fn reword_commit(gui: &mut Gui) -> Result<()> { let message = message.to_string(); let hash = hash.clone(); if is_head { - gui.start_remote_op("Reword", "Rewording commit...", move |git| { - git.reword_commit(&hash, &message)?; - Ok(()) - }); + gui.start_remote_op( + RemoteOpKind::Other, + "Reword", + "Rewording commit...", + move |git| { + git.reword_commit(&hash, &message)?; + Ok(()) + }, + ); } else { - gui.start_remote_op("Reword", "Rewording commit...", move |git| { - git.reword_commit_rebase(&hash, &message)?; - Ok(()) - }); + gui.start_remote_op( + RemoteOpKind::Other, + "Reword", + "Rewording commit...", + move |git| { + git.reword_commit_rebase(&hash, &message)?; + Ok(()) + }, + ); } } Ok(()) @@ -748,10 +757,15 @@ fn amend_to_commit(gui: &mut Gui) -> Result<()> { title: "Amend".to_string(), message: "Amend staged changes to HEAD commit?".to_string(), on_confirm: Box::new(|gui| { - gui.start_remote_op("Amend", "Amending commit...", |git| { - git.amend_commit()?; - Ok(()) - }); + gui.start_remote_op( + RemoteOpKind::Other, + "Amend", + "Amending commit...", + |git| { + git.amend_commit()?; + Ok(()) + }, + ); Ok(()) }), }; @@ -766,11 +780,16 @@ fn amend_to_commit(gui: &mut Gui) -> Result<()> { message: format!("Amend staged changes to commit {}?", short), on_confirm: Box::new(move |gui| { let hash = hash.clone(); - gui.start_remote_op("Amend", "Amending commit...", move |git| { - git.create_fixup_commit(&hash)?; - git.rebase_autosquash(&format!("{}^", hash))?; - Ok(()) - }); + gui.start_remote_op( + RemoteOpKind::Other, + "Amend", + "Amending commit...", + move |git| { + git.create_fixup_commit(&hash)?; + git.rebase_autosquash(&format!("{}^", hash))?; + Ok(()) + }, + ); Ok(()) }), }; diff --git a/src/gui/controller/files.rs b/src/gui/controller/files.rs index e88ae89..b497f1b 100644 --- a/src/gui/controller/files.rs +++ b/src/gui/controller/files.rs @@ -1,15 +1,14 @@ -use anyhow::Result; -use crossterm::event::{KeyCode, KeyEvent}; - use crate::config::KeybindingConfig; use crate::config::keybindings::parse_key; -use crate::gui::Gui; use crate::gui::popup::{ CommitInputFocus, CommitInputKind, MenuItem, PopupState, make_commit_body_textarea, make_commit_summary_textarea, make_textarea, }; +use crate::gui::{Gui, RemoteOpKind}; use crate::os::platform::Platform; use crate::pager::side_by_side::DiffPanel; +use anyhow::Result; +use crossterm::event::{KeyCode, KeyEvent}; pub fn handle_key(gui: &mut Gui, key: KeyEvent, keybindings: &KeybindingConfig) -> Result<()> { if super::diff_grep::is_diff_grep_key(key) { @@ -111,10 +110,15 @@ pub fn handle_key(gui: &mut Gui, key: KeyEvent, keybindings: &KeybindingConfig) // Fetch if matches_key(key, &keybindings.files.fetch) { - gui.start_remote_op("Fetch", "Fetching from all remotes...", |git| { - git.fetch_all()?; - Ok(()) - }); + gui.start_remote_op( + RemoteOpKind::Fetch, + "Fetch", + "Fetching from all remotes...", + |git| { + git.fetch_all()?; + Ok(()) + }, + ); return Ok(()); } @@ -274,6 +278,7 @@ fn open_commit_prompt_after_detached_head_warning(gui: &mut Gui) -> Result<()> { if !message.is_empty() { let message = message.to_string(); gui.start_remote_op( + RemoteOpKind::Other, "Empty commit", "Creating empty commit...", move |git| { @@ -311,10 +316,15 @@ fn open_commit_prompt_after_detached_head_warning(gui: &mut Gui) -> Result<()> { on_confirm: Box::new(|gui, message| { if !message.is_empty() { let message = message.to_string(); - gui.start_remote_op("Commit", "Creating commit...", move |git| { - git.create_commit(&message, false)?; - Ok(()) - }); + gui.start_remote_op( + RemoteOpKind::Other, + "Commit", + "Creating commit...", + move |git| { + git.create_commit(&message, false)?; + Ok(()) + }, + ); } Ok(()) }), @@ -340,10 +350,15 @@ fn open_commit_prompt_after_detached_head_warning(gui: &mut Gui) -> Result<()> { on_confirm: Box::new(|gui, message| { if !message.is_empty() { let message = message.to_string(); - gui.start_remote_op("Commit", "Creating commit...", move |git| { - git.create_commit(&message, false)?; - Ok(()) - }); + gui.start_remote_op( + RemoteOpKind::Other, + "Commit", + "Creating commit...", + move |git| { + git.create_commit(&message, false)?; + Ok(()) + }, + ); } Ok(()) }), @@ -386,10 +401,15 @@ fn open_ai_commit_prompt(gui: &mut Gui) -> Result<()> { on_confirm: Box::new(|gui, message| { if !message.is_empty() { let message = message.to_string(); - gui.start_remote_op("Commit", "Creating commit...", move |git| { - git.create_commit(&message, false)?; - Ok(()) - }); + gui.start_remote_op( + RemoteOpKind::Other, + "Commit", + "Creating commit...", + move |git| { + git.create_commit(&message, false)?; + Ok(()) + }, + ); } Ok(()) }), @@ -410,10 +430,15 @@ fn open_ai_commit_prompt(gui: &mut Gui) -> Result<()> { on_confirm: Box::new(|gui, message| { if !message.is_empty() { let message = message.to_string(); - gui.start_remote_op("Commit", "Creating commit...", move |git| { - git.create_commit(&message, false)?; - Ok(()) - }); + gui.start_remote_op( + RemoteOpKind::Other, + "Commit", + "Creating commit...", + move |git| { + git.create_commit(&message, false)?; + Ok(()) + }, + ); } Ok(()) }), @@ -916,7 +941,7 @@ fn amend_commit(gui: &mut Gui) -> Result<()> { title: "Amend".to_string(), message: "Amend last commit with staged changes?".to_string(), on_confirm: Box::new(|gui| { - gui.start_remote_op("Amend", "Amending commit...", |git| { + gui.start_remote_op(RemoteOpKind::Other, "Amend", "Amending commit...", |git| { git.amend_commit()?; Ok(()) }); @@ -935,10 +960,15 @@ fn commit_with_editor(gui: &mut Gui) -> Result<()> { on_confirm: Box::new(|gui, message| { if !message.is_empty() { let message = message.to_string(); - gui.start_remote_op("Commit", "Creating commit...", move |git| { - git.create_commit(&message, false)?; - Ok(()) - }); + gui.start_remote_op( + RemoteOpKind::Other, + "Commit", + "Creating commit...", + move |git| { + git.create_commit(&message, false)?; + Ok(()) + }, + ); } Ok(()) }), diff --git a/src/gui/controller/remotes.rs b/src/gui/controller/remotes.rs index 34f5f31..387c4d3 100644 --- a/src/gui/controller/remotes.rs +++ b/src/gui/controller/remotes.rs @@ -3,8 +3,8 @@ use crossterm::event::{KeyCode, KeyEvent}; use crate::config::KeybindingConfig; use crate::config::keybindings::parse_key; -use crate::gui::Gui; use crate::gui::popup::{MenuItem, MessageKind, PopupState, make_textarea}; +use crate::gui::{Gui, RemoteOpKind}; pub fn handle_key(gui: &mut Gui, key: KeyEvent, keybindings: &KeybindingConfig) -> Result<()> { // Enter: drill into remote branches @@ -189,7 +189,7 @@ fn fetch_remote(gui: &mut Gui) -> Result<()> { drop(model); let msg = format!("Fetching {}...", name); - gui.start_remote_op("Fetch", &msg, move |git| { + gui.start_remote_op(RemoteOpKind::Fetch, "Fetch", &msg, move |git| { git.fetch(&name)?; Ok(()) }); @@ -358,7 +358,7 @@ fn fetch_and_checkout( ) -> Result<()> { let remote_name = remote_name.to_string(); let msg = format!("Fetching {}...", remote_name); - gui.start_remote_op("Fetch", &msg, move |git| { + gui.start_remote_op(RemoteOpKind::Fetch, "Fetch", &msg, move |git| { git.fetch(&remote_name)?; if let Some(branch) = branch_to_checkout.as_deref() { if !branch.is_empty() { @@ -396,13 +396,13 @@ fn show_push_menu(gui: &mut Gui) -> Result<()> { action: Some(Box::new(move |gui| { let msg = format!("Pushing {} to origin...", b1); if is_tracking { - gui.start_remote_op("Push", &msg, |git| { + gui.start_remote_op(RemoteOpKind::Push, "Push", &msg, |git| { git.push(false)?; Ok(()) }); } else { let branch = b1.clone(); - gui.start_remote_op("Push", &msg, move |git| { + gui.start_remote_op(RemoteOpKind::Push, "Push", &msg, move |git| { git.push_with_upstream("origin", &branch)?; Ok(()) }); @@ -415,10 +415,15 @@ fn show_push_menu(gui: &mut Gui) -> Result<()> { description: "Force push with safety check".to_string(), key: Some("f".to_string()), action: Some(Box::new(move |gui| { - gui.start_remote_op("Push", "Force pushing (with lease)...", |git| { - git.push(true)?; - Ok(()) - }); + gui.start_remote_op( + RemoteOpKind::Push, + "Push", + "Force pushing (with lease)...", + |git| { + git.push(true)?; + Ok(()) + }, + ); Ok(()) })), }, @@ -429,6 +434,7 @@ fn show_push_menu(gui: &mut Gui) -> Result<()> { action: Some(Box::new(move |gui| { let branch = b2.clone(); gui.start_remote_op( + RemoteOpKind::Push, "Push", &format!("Pushing -u origin {}...", branch), move |git| { @@ -455,10 +461,15 @@ fn show_pull_menu(gui: &mut Gui) -> Result<()> { description: "Pull from upstream".to_string(), key: Some("p".to_string()), action: Some(Box::new(move |gui| { - gui.start_remote_op("Pull", "Pulling from upstream...", |git| { - git.pull()?; - Ok(()) - }); + gui.start_remote_op( + RemoteOpKind::Pull, + "Pull", + "Pulling from upstream...", + |git| { + git.pull()?; + Ok(()) + }, + ); Ok(()) })), }, @@ -467,10 +478,15 @@ fn show_pull_menu(gui: &mut Gui) -> Result<()> { description: "Fetch from all remotes".to_string(), key: Some("f".to_string()), action: Some(Box::new(move |gui| { - gui.start_remote_op("Fetch", "Fetching from all remotes...", |git| { - git.fetch_all()?; - Ok(()) - }); + gui.start_remote_op( + RemoteOpKind::Fetch, + "Fetch", + "Fetching from all remotes...", + |git| { + git.fetch_all()?; + Ok(()) + }, + ); Ok(()) })), }, diff --git a/src/gui/controller/tags.rs b/src/gui/controller/tags.rs index 72756b4..5013729 100644 --- a/src/gui/controller/tags.rs +++ b/src/gui/controller/tags.rs @@ -1,12 +1,11 @@ -use anyhow::Result; -use crossterm::event::{KeyCode, KeyEvent}; - use crate::config::KeybindingConfig; -use crate::gui::Gui; use crate::gui::popup::{ ListPickerCore, ListPickerItem, MenuItem, PopupState, make_command_palette_search_textarea, make_textarea, }; +use crate::gui::{Gui, RemoteOpKind}; +use anyhow::Result; +use crossterm::event::{KeyCode, KeyEvent}; pub fn handle_key(gui: &mut Gui, key: KeyEvent, _keybindings: &KeybindingConfig) -> Result<()> { // Enter: view tag commits @@ -213,7 +212,7 @@ fn confirm_remote_tag_delete( ) }; - gui.start_remote_op(op_title, &message, move |git| { + gui.start_remote_op(RemoteOpKind::Other, op_title, &message, move |git| { git.delete_remote_tag(&remote, &name)?; if delete_local_after_remote { git.delete_tag(&name)?; @@ -252,6 +251,7 @@ fn push_tag(gui: &mut Gui) -> Result<()> { on_confirm: Box::new(move |gui| { let tag = name.clone(); gui.start_remote_op( + RemoteOpKind::Other, "Push", &format!("Pushing tag {} to origin...", tag), move |git| { diff --git a/src/gui/mod.rs b/src/gui/mod.rs index cf297b9..c6d590c 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -269,6 +269,14 @@ pub(crate) enum DiffPayload { Empty, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum RemoteOpKind { + Push, + Pull, + Fetch, + Other, +} + struct DiffJob { generation: u64, diff_key: String, @@ -667,9 +675,9 @@ pub struct Gui { /// Diff source for the next AI commit message generation. ai_commit_source: AiCommitSource, /// Receiver for background remote operations (push, pull, fetch). - remote_op_rx: mpsc::Receiver>>, + remote_op_rx: mpsc::Receiver<(RemoteOpKind, Result>)>, /// Sender cloned into background threads for remote operations. - remote_op_tx: mpsc::Sender>>, + remote_op_tx: mpsc::Sender<(RemoteOpKind, Result>)>, /// Async light files refresh (status-only) so Space-spam doesn't freeze. files_refresh_rx: Option>>>, files_refresh_in_progress: bool, @@ -2361,6 +2369,7 @@ impl Gui { if !msg.is_empty() { let message = msg.to_string(); gui.start_remote_op( + RemoteOpKind::Other, "Commit", "Creating commit...", move |git| { @@ -2632,7 +2641,7 @@ impl Gui { if self.shell_command_ui_active() || self.deferred_notification_ui_active() { return; } - if let Ok(result) = self.remote_op_rx.try_recv() { + if let Ok((kind, result)) = self.remote_op_rx.try_recv() { self.remote_op_label = None; match result { Ok(head) => { @@ -2646,7 +2655,18 @@ impl Gui { model.head_branch_name = head.branch_name; self.needs_diff_refresh = true; } - self.needs_refresh = true; + match kind { + RemoteOpKind::Push => { + if self.refresh_after_push().is_err() { + // Fall back to the existing full refresh if the + // targeted post-push refresh fails. + self.needs_refresh = true; + } + } + _ => { + self.needs_refresh = true; + } + } self.remote_op_success_at = Some(Instant::now()); if was_checkout { // A pre-checkout stream must not overwrite confirmed HEAD @@ -2700,6 +2720,36 @@ impl Gui { } } + fn refresh_after_push(&mut self) -> Result<()> { + let branches = self.git.load_branches()?; + let remotes = self.git.load_remotes()?; + let unpushed = self.git.unpushed_commit_hashes()?; + + { + let mut model = self.model.lock().unwrap(); + + model.branches = branches; + model.remotes = remotes; + GitCommands::apply_unpushed_status(&mut model.commits, &unpushed); + + if !self.remote_branches_name.is_empty() { + let refreshed = model + .remotes + .iter() + .find(|remote| remote.name == self.remote_branches_name) + .map(|remote| remote.branches.clone()); + + if let Some(branches) = refreshed { + model.sub_remote_branches = branches; + } + } + } + + self.last_refresh_at = Instant::now(); + + Ok(()) + } + /// Kick off a status-only files refresh on a background thread. fn start_files_refresh_async(&mut self) { if self.files_refresh_in_progress { @@ -2924,7 +2974,7 @@ impl Gui { /// Run a remote operation (push/pull/fetch) on a background thread. /// Non-blocking: corner toast + branch-side label; input stays free (like AI commit). - pub fn start_remote_op(&mut self, title: &str, _message: &str, op: F) + pub fn start_remote_op(&mut self, kind: RemoteOpKind, title: &str, _message: &str, op: F) where F: FnOnce(&GitCommands) -> Result<()> + Send + 'static, { @@ -2939,11 +2989,14 @@ impl Gui { "Fetch" => "Fetching", other => other, }; + self.remote_op_label = Some(label.to_string()); self.remote_op_success_at = None; + let git = Arc::clone(&self.git); let tx = self.remote_op_tx.clone(); let is_checkout = self.pending_checkout_by_name.is_some(); + std::thread::spawn(move || { let result = op(&git).map(|()| { if is_checkout { @@ -2954,7 +3007,8 @@ impl Gui { None } }); - let _ = tx.send(result); + + let _ = tx.send((kind, result)); }); } @@ -9727,7 +9781,10 @@ mod terminal_mouse_tests { assert_eq!(gui.deferred_notifications.len(), 2); gui.remote_op_label = Some("Fetch".into()); gui.remote_op_tx - .send(Err(anyhow::anyhow!("queued fetch failure"))) + .send(( + RemoteOpKind::Fetch, + Err(anyhow::anyhow!("queued fetch failure")), + )) .unwrap(); gui.receive_deferred_notifications(); gui.receive_remote_op_results(); @@ -9916,7 +9973,10 @@ mod terminal_mouse_tests { }) .unwrap(); gui.remote_op_tx - .send(Err(anyhow::anyhow!("concurrent fetch failed"))) + .send(( + RemoteOpKind::Fetch, + Err(anyhow::anyhow!("concurrent fetch failed")), + )) .unwrap(); gui.handle_key(parse_key(":").unwrap()).unwrap(); gui.handle_paste("printf shell-result".into()); @@ -9969,7 +10029,7 @@ mod terminal_mouse_tests { let mut gui = tree_test_gui(&repo); gui.remote_op_label = Some("Fetch".into()); gui.remote_op_tx - .send(Err(anyhow::anyhow!("fetch failed"))) + .send((RemoteOpKind::Fetch, Err(anyhow::anyhow!("fetch failed")))) .unwrap(); start_test_custom_command(&mut gui, "printf shell-result", true); gui.receive_remote_op_results(); @@ -10552,7 +10612,7 @@ mod terminal_mouse_tests { let repo = TempRepo::new("remote-op-refresh"); let mut gui = checkout_test_gui(&repo); let head = repo.git(&["rev-parse", "HEAD"]); - gui.start_remote_op("Fetch", "", |_| Ok(())); + gui.start_remote_op(RemoteOpKind::Fetch, "Fetch", "", |_| Ok(())); wait_for_test_checkout(&mut gui); assert_checkout_head(&gui, "feature", &head); assert!(gui.needs_refresh); @@ -10568,7 +10628,9 @@ mod terminal_mouse_tests { repo.git(&["checkout", "main"]); gui.remote_op_label = Some("Checking out".into()); gui.pending_checkout_by_name = Some("main".into()); - gui.remote_op_tx.send(Ok(None)).unwrap(); + gui.remote_op_tx + .send((RemoteOpKind::Other, Ok(None))) + .unwrap(); gui.receive_remote_op_results(); assert!(matches!(gui.popup, PopupState::None)); assert!(gui.pending_checkout_by_name.is_none()); From 628841fa5c5e090a734650b82516e81ffcd2a561 Mon Sep 17 00:00:00 2001 From: "Jasmeet (Jazz) Bhatia" Date: Sun, 11 Oct 2026 23:21:48 -0700 Subject: [PATCH 2/9] perf(gui): target refresh after pull --- src/git/mod.rs | 123 +++++++++++++++++++++++++++++++++++++++++++++++++ src/gui/mod.rs | 74 ++++++++++++++++++++++++++++- 2 files changed, 196 insertions(+), 1 deletion(-) diff --git a/src/git/mod.rs b/src/git/mod.rs index b1cdc4b..61b3499 100644 --- a/src/git/mod.rs +++ b/src/git/mod.rs @@ -325,6 +325,129 @@ impl GitCommands { } } + /// Refresh model state that can be affected by a successful pull. + /// + /// Unlike a full model refresh: + /// - stash and repo URL are unchanged and are not reloaded + /// - files use status-only loading, avoiding expensive numstat/hunk scans + /// + /// Other potentially pull-affected state is still refreshed. + pub fn load_model_after_pull_streaming( + self: &Arc, + tx: &mpsc::Sender, + commit_filter: Option, + ) { + macro_rules! spawn_part { + ($tx:expr, $self:expr, $variant:ident, $expr:expr) => {{ + let tx = $tx.clone(); + let git = Arc::clone($self); + std::thread::spawn(move || { + if let Ok(data) = $expr(&git) { + let _ = tx.send(ModelPart::$variant(data)); + } + }); + }}; + } + + // Pull can change HEAD-relative file status, but we don't need the + // expensive per-file numstat/hunk scans immediately. The selected diff + // is refreshed separately by the GUI. + spawn_part!(tx, self, Files, |g: &GitCommands| { + g.load_files_status_only() + }); + + spawn_part!(tx, self, Branches, |g: &GitCommands| g.load_branches()); + + if let Some(filter) = commit_filter { + let tx = tx.clone(); + let git = Arc::clone(self); + std::thread::spawn(move || { + let unpushed = git.unpushed_commit_hashes().unwrap_or_default(); + if let Ok(mut commits) = + git.load_filtered_commits_page(&filter, DEFAULT_COMMIT_LIMIT, 0) + { + Self::apply_unpushed_status(&mut commits, &unpushed); + let _ = tx.send(ModelPart::Commits(commits)); + } + }); + } else { + spawn_part!(tx, self, Commits, |g: &GitCommands| { + g.load_commits(DEFAULT_COMMIT_LIMIT) + }); + } + + spawn_part!(tx, self, Remotes, |g: &GitCommands| g.load_remotes()); + + // A pull may auto-follow tags, so keep tags correct for now. + // We can remove the redundant ls-remote calls separately once measured. + spawn_part!(tx, self, Tags, |g: &GitCommands| g.load_tags()); + + // Pull can move the current worktree's HEAD. + spawn_part!(tx, self, Worktrees, |g: &GitCommands| { + g.load_worktrees() + .or_else(|_| Ok::<_, anyhow::Error>(Vec::new())) + }); + + // A changed superproject HEAD can change gitlink state. + spawn_part!(tx, self, Submodules, |g: &GitCommands| { + g.load_submodules() + .or_else(|_| Ok::<_, anyhow::Error>(Vec::new())) + }); + + // Pull moving HEAD changes reflog history. + spawn_part!(tx, self, Reflog, |g: &GitCommands| { + g.load_reflog(100) + .or_else(|_| Ok::<_, anyhow::Error>(Vec::new())) + }); + + { + let tx = tx.clone(); + let git = Arc::clone(self); + std::thread::spawn(move || { + if let Ok((added, deleted)) = git.diff_shortstat() { + let _ = tx.send(ModelPart::DiffStats { added, deleted }); + } + }); + } + + { + let tx = tx.clone(); + let git = Arc::clone(self); + std::thread::spawn(move || { + if let Ok(head) = git.head_state() { + let _ = tx.send(ModelPart::Head { + hash: head.hash, + branch_name: head.branch_name, + }); + } + }); + } + + { + let tx = tx.clone(); + let git = Arc::clone(self); + std::thread::spawn(move || { + let _ = tx.send(ModelPart::Contributors(git.load_contributors(500, 10))); + }); + } + + { + let tx = tx.clone(); + let git = Arc::clone(self); + std::thread::spawn(move || { + if let Ok(status) = git.repo_status() { + let _ = tx.send(ModelPart::RepoStatus { + is_rebasing: status.is_rebasing, + is_merging: status.is_merging, + is_cherry_picking: status.is_cherry_picking, + is_bisecting: status.is_bisecting, + rebase_onto_hash: status.rebase_onto_hash, + }); + } + }); + } + } + /// Refresh just the working tree files. pub fn refresh_files(&self) -> Result> { self.load_files() diff --git a/src/gui/mod.rs b/src/gui/mod.rs index c6d590c..919d7e4 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -587,6 +587,7 @@ struct CommitPageResult { } const COMMIT_PAGE_PREFETCH_THRESHOLD: usize = 100; +const PULL_MODEL_PART_COUNT: usize = MODEL_PART_COUNT - 2; pub struct Gui { pub config: Arc, @@ -749,6 +750,7 @@ pub struct Gui { initial_load_rx: Option>, /// How many model parts have arrived so far (out of MODEL_PART_COUNT). initial_load_received: usize, + initial_load_expected: usize, /// True while a background `load_model_streaming` refresh is in flight. /// Prevents stacking concurrent full refreshes on the UI thread. refresh_in_progress: bool, @@ -1115,6 +1117,7 @@ impl Gui { model: Arc::new(Mutex::new(model)), initial_load_rx: Some(initial_load_rx), initial_load_received: 0, + initial_load_expected: MODEL_PART_COUNT, refresh_in_progress: false, context_mgr, layout: LayoutState::default(), @@ -1439,7 +1442,7 @@ impl Gui { self.needs_diff_refresh = true; } // All parts received — done loading. - if self.initial_load_received >= MODEL_PART_COUNT { + if self.initial_load_received >= self.initial_load_expected { self.initial_load_rx = None; let was_refresh = self.refresh_in_progress; self.refresh_in_progress = false; @@ -2663,6 +2666,13 @@ impl Gui { self.needs_refresh = true; } } + RemoteOpKind::Pull => { + if !self.start_background_pull_refresh() { + // Another model refresh is already in flight. Queue the + // normal refresh rather than risk leaving stale state. + self.needs_refresh = true; + } + } _ => { self.needs_refresh = true; } @@ -8775,6 +8785,7 @@ impl Gui { let (tx, rx) = mpsc::channel(); self.initial_load_rx = Some(rx); self.initial_load_received = 0; + self.initial_load_expected = MODEL_PART_COUNT; self.refresh_in_progress = true; self.needs_refresh = false; self.reset_commit_pagination(); @@ -8915,6 +8926,36 @@ impl Gui { Ok(()) } + /// Kick off the model refresh required after a successful pull. + /// + /// Reuses the normal streaming ModelPart path, but avoids reloading state + /// that pull cannot affect and avoids expensive per-file diff-stat scans. + fn start_background_pull_refresh(&mut self) -> bool { + if self.refresh_in_progress || self.initial_load_rx.is_some() { + return false; + } + + let (tx, rx) = mpsc::channel(); + + self.initial_load_rx = Some(rx); + self.initial_load_received = 0; + self.initial_load_expected = PULL_MODEL_PART_COUNT; + self.refresh_in_progress = true; + self.needs_refresh = false; + + self.reset_commit_pagination(); + self.diff_preview_cache.retain_immutable(); + + let git = Arc::clone(&self.git); + let commit_filter = self.commit_filter_for_load(); + + std::thread::spawn(move || { + git.load_model_after_pull_streaming(&tx, commit_filter); + }); + + true + } + /// Lightweight refresh that only reloads files and diff stats. /// Prefer the async status-only path after stage/unstage; this full /// variant is kept for callers that need numstat immediately. @@ -10621,6 +10662,37 @@ mod terminal_mouse_tests { assert!(matches!(gui.popup, PopupState::None)); } + #[test] + fn pull_uses_targeted_background_refresh() { + let repo = TempRepo::new("pull-targeted-refresh"); + let mut gui = checkout_test_gui(&repo); + + gui.start_remote_op(RemoteOpKind::Pull, "Pull", "", |_| Ok(())); + wait_for_test_checkout(&mut gui); + + // Pull should start the targeted streaming refresh instead of queueing + // the normal full-model refresh. + assert!(!gui.needs_refresh); + assert!(gui.refresh_in_progress); + assert!(gui.initial_load_rx.is_some()); + assert_eq!(gui.initial_load_expected, PULL_MODEL_PART_COUNT); + + // Drain the targeted refresh so its worker threads finish before this + // test drops the temporary repository. + let parts: Vec<_> = gui.initial_load_rx.take().unwrap().into_iter().collect(); + + assert_eq!(parts.len(), PULL_MODEL_PART_COUNT); + + // These are deliberately omitted from post-pull refreshes because a + // successful pull cannot change them. + assert!(!parts.iter().any(|part| matches!(part, ModelPart::Stash(_)))); + assert!( + !parts + .iter() + .any(|part| matches!(part, ModelPart::RepoUrl(_))) + ); + } + #[test] fn successful_checkout_without_head_metadata_still_refreshes() { let repo = TempRepo::new("checkout-metadata-fallback"); From 3cf858712ea72463eff345e8123b5d6b68cb22c8 Mon Sep 17 00:00:00 2001 From: "Jasmeet (Jazz) Bhatia" Date: Mon, 12 Oct 2026 00:07:09 -0700 Subject: [PATCH 3/9] perf(git): avoid remote tag queries after pull --- src/git/mod.rs | 5 ++- src/git/tag.rs | 83 +++++++++++++++++++++++++++++++++++++++++++++----- src/gui/mod.rs | 11 ++++++- 3 files changed, 90 insertions(+), 9 deletions(-) diff --git a/src/git/mod.rs b/src/git/mod.rs index 61b3499..ef3cb16 100644 --- a/src/git/mod.rs +++ b/src/git/mod.rs @@ -336,6 +336,7 @@ impl GitCommands { self: &Arc, tx: &mpsc::Sender, commit_filter: Option, + previous_tag_remote_state: std::collections::HashMap, ) { macro_rules! spawn_part { ($tx:expr, $self:expr, $variant:ident, $expr:expr) => {{ @@ -380,7 +381,9 @@ impl GitCommands { // A pull may auto-follow tags, so keep tags correct for now. // We can remove the redundant ls-remote calls separately once measured. - spawn_part!(tx, self, Tags, |g: &GitCommands| g.load_tags()); + spawn_part!(tx, self, Tags, move |g: &GitCommands| { + g.load_tags_after_pull(&previous_tag_remote_state) + }); // Pull can move the current worktree's HEAD. spawn_part!(tx, self, Worktrees, |g: &GitCommands| { diff --git a/src/git/tag.rs b/src/git/tag.rs index de3fa9d..16a4307 100644 --- a/src/git/tag.rs +++ b/src/git/tag.rs @@ -1,4 +1,4 @@ -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use anyhow::Result; @@ -7,6 +7,32 @@ use crate::model::Tag; impl GitCommands { pub fn load_tags(&self) -> Result> { + let mut tags = self.load_local_tags()?; + let remote_tags = self.remote_tag_names(); + + for tag in &mut tags { + tag.on_remote = remote_tags.contains(&tag.name); + } + + Ok(tags) + } + + /// Refresh tags after a successful pull without querying every remote. + /// + /// Existing tags retain their previously known remote state. Tags that + /// appeared during the pull are assumed to have been fetched from the + /// upstream remote. + pub fn load_tags_after_pull( + &self, + previous_remote_state: &HashMap, + ) -> Result> { + let mut tags = self.load_local_tags()?; + apply_pull_remote_state(&mut tags, previous_remote_state); + Ok(tags) + } + + /// Load local tag refs without querying any remotes. + fn load_local_tags(&self) -> Result> { // Peel annotated tags to the commit they point at. let format = "%(refname:short)|%(if)%(*objectname)%(then)%(*objectname:short)%(else)%(objectname:short)%(end)|%(subject)"; let result = self @@ -23,21 +49,17 @@ impl GitCommands { return Ok(Vec::new()); } - let remote_tags = self.remote_tag_names(); - let tags = result .stdout .lines() .filter_map(|line| { let parts: Vec<&str> = line.splitn(3, '|').collect(); if parts.len() >= 2 { - let name = parts[0].to_string(); - let on_remote = remote_tags.contains(name.as_str()); Some(Tag { - name, + name: parts[0].to_string(), hash: parts[1].to_string(), message: parts.get(2).unwrap_or(&"").to_string(), - on_remote, + on_remote: false, }) } else { None @@ -91,6 +113,7 @@ impl GitCommands { let repo_path = self.repo_path.clone(); let remote = remote.to_string(); let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { let result = crate::os::cmd::CmdBuilder::git_no_optional_locks() .cwd_path(&repo_path) @@ -99,6 +122,7 @@ impl GitCommands { .run(); let _ = tx.send(result); }); + match rx.recv_timeout(TIMEOUT) { Ok(Ok(result)) if result.success => Some(result.stdout), _ => None, @@ -138,3 +162,48 @@ impl GitCommands { Ok(()) } } + +fn apply_pull_remote_state(tags: &mut [Tag], previous_remote_state: &HashMap) { + for tag in tags { + tag.on_remote = previous_remote_state + .get(&tag.name) + .copied() + .unwrap_or(true); + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use super::*; + + fn tag(name: &str) -> Tag { + Tag { + name: name.to_string(), + hash: "deadbeef".to_string(), + message: String::new(), + on_remote: false, + } + } + + #[test] + fn pull_tag_refresh_preserves_known_remote_state_and_marks_new_tags_remote() { + let previous = HashMap::from([ + ("already-remote".to_string(), true), + ("local-only".to_string(), false), + ]); + + let mut tags = vec![ + tag("already-remote"), + tag("local-only"), + tag("newly-fetched"), + ]; + + apply_pull_remote_state(&mut tags, &previous); + + assert!(tags[0].on_remote); + assert!(!tags[1].on_remote); + assert!(tags[2].on_remote); + } +} diff --git a/src/gui/mod.rs b/src/gui/mod.rs index 919d7e4..9b67b53 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -8946,11 +8946,20 @@ impl Gui { self.reset_commit_pagination(); self.diff_preview_cache.retain_immutable(); + let previous_tag_remote_state = { + let model = self.model.lock().unwrap(); + model + .tags + .iter() + .map(|tag| (tag.name.clone(), tag.on_remote)) + .collect::>() + }; + let git = Arc::clone(&self.git); let commit_filter = self.commit_filter_for_load(); std::thread::spawn(move || { - git.load_model_after_pull_streaming(&tx, commit_filter); + git.load_model_after_pull_streaming(&tx, commit_filter, previous_tag_remote_state); }); true From 5f49362a6fdfd46e7d1e1159137fca07f45ee6ad Mon Sep 17 00:00:00 2001 From: "Jasmeet (Jazz) Bhatia" Date: Mon, 12 Oct 2026 00:57:17 -0700 Subject: [PATCH 4/9] perf(gui): target refresh after fetch --- src/git/mod.rs | 57 +++++++++++++++++++- src/git/tag.rs | 17 +++--- src/gui/controller/remotes.rs | 13 ++++- src/gui/mod.rs | 97 +++++++++++++++++++++++++++++++++-- 4 files changed, 170 insertions(+), 14 deletions(-) diff --git a/src/git/mod.rs b/src/git/mod.rs index ef3cb16..1227ca3 100644 --- a/src/git/mod.rs +++ b/src/git/mod.rs @@ -325,6 +325,61 @@ impl GitCommands { } } + pub fn load_model_after_fetch_streaming( + self: &Arc, + tx: &mpsc::Sender, + commit_filter: Option, + previous_tag_remote_state: std::collections::HashMap, + ) { + macro_rules! spawn_part { + ($tx:expr, $self:expr, $variant:ident, $expr:expr) => {{ + let tx = $tx.clone(); + let git = Arc::clone($self); + std::thread::spawn(move || { + if let Ok(data) = $expr(&git) { + let _ = tx.send(ModelPart::$variant(data)); + } + }); + }}; + } + + // Fetch can change upstream divergence / remote-facing branch state. + spawn_part!(tx, self, Branches, |g: &GitCommands| g.load_branches()); + + // The commits panel walks all refs, so newly fetched history must appear. + if let Some(filter) = commit_filter { + let tx = tx.clone(); + let git = Arc::clone(self); + std::thread::spawn(move || { + let unpushed = git.unpushed_commit_hashes().unwrap_or_default(); + if let Ok(mut commits) = + git.load_filtered_commits_page(&filter, DEFAULT_COMMIT_LIMIT, 0) + { + Self::apply_unpushed_status(&mut commits, &unpushed); + let _ = tx.send(ModelPart::Commits(commits)); + } + }); + } else { + spawn_part!(tx, self, Commits, |g: &GitCommands| { + g.load_commits(DEFAULT_COMMIT_LIMIT) + }); + } + + // Remote-tracking branches can change. + spawn_part!(tx, self, Remotes, |g: &GitCommands| g.load_remotes()); + + // Refresh local tags without re-querying the remotes we just fetched. + { + let tx = tx.clone(); + let git = Arc::clone(self); + std::thread::spawn(move || { + if let Ok(tags) = git.load_tags_after_remote_update(&previous_tag_remote_state) { + let _ = tx.send(ModelPart::Tags(tags)); + } + }); + } + } + /// Refresh model state that can be affected by a successful pull. /// /// Unlike a full model refresh: @@ -382,7 +437,7 @@ impl GitCommands { // A pull may auto-follow tags, so keep tags correct for now. // We can remove the redundant ls-remote calls separately once measured. spawn_part!(tx, self, Tags, move |g: &GitCommands| { - g.load_tags_after_pull(&previous_tag_remote_state) + g.load_tags_after_remote_update(&previous_tag_remote_state) }); // Pull can move the current worktree's HEAD. diff --git a/src/git/tag.rs b/src/git/tag.rs index 16a4307..c93c578 100644 --- a/src/git/tag.rs +++ b/src/git/tag.rs @@ -17,17 +17,16 @@ impl GitCommands { Ok(tags) } - /// Refresh tags after a successful pull without querying every remote. + /// Refresh tags after a successful remote update without querying every remote. /// - /// Existing tags retain their previously known remote state. Tags that - /// appeared during the pull are assumed to have been fetched from the - /// upstream remote. - pub fn load_tags_after_pull( + /// Existing tags retain their previously known remote state. Tags that appeared + /// during the remote operation are assumed to have been fetched from a remote. + pub fn load_tags_after_remote_update( &self, previous_remote_state: &HashMap, ) -> Result> { let mut tags = self.load_local_tags()?; - apply_pull_remote_state(&mut tags, previous_remote_state); + apply_remote_update_state(&mut tags, previous_remote_state); Ok(tags) } @@ -163,7 +162,7 @@ impl GitCommands { } } -fn apply_pull_remote_state(tags: &mut [Tag], previous_remote_state: &HashMap) { +fn apply_remote_update_state(tags: &mut [Tag], previous_remote_state: &HashMap) { for tag in tags { tag.on_remote = previous_remote_state .get(&tag.name) @@ -188,7 +187,7 @@ mod tests { } #[test] - fn pull_tag_refresh_preserves_known_remote_state_and_marks_new_tags_remote() { + fn remote_update_tag_refresh_preserves_known_remote_state_and_marks_new_tags_remote() { let previous = HashMap::from([ ("already-remote".to_string(), true), ("local-only".to_string(), false), @@ -200,7 +199,7 @@ mod tests { tag("newly-fetched"), ]; - apply_pull_remote_state(&mut tags, &previous); + apply_remote_update_state(&mut tags, &previous); assert!(tags[0].on_remote); assert!(!tags[1].on_remote); diff --git a/src/gui/controller/remotes.rs b/src/gui/controller/remotes.rs index 387c4d3..f9a7d73 100644 --- a/src/gui/controller/remotes.rs +++ b/src/gui/controller/remotes.rs @@ -356,9 +356,19 @@ fn fetch_and_checkout( remote_name: &str, branch_to_checkout: Option, ) -> Result<()> { + let kind = if branch_to_checkout + .as_deref() + .is_some_and(|branch| !branch.is_empty()) + { + RemoteOpKind::FetchAndCheckout + } else { + RemoteOpKind::Fetch + }; + let remote_name = remote_name.to_string(); let msg = format!("Fetching {}...", remote_name); - gui.start_remote_op(RemoteOpKind::Fetch, "Fetch", &msg, move |git| { + + gui.start_remote_op(kind, "Fetch", &msg, move |git| { git.fetch(&remote_name)?; if let Some(branch) = branch_to_checkout.as_deref() { if !branch.is_empty() { @@ -367,6 +377,7 @@ fn fetch_and_checkout( } Ok(()) }); + Ok(()) } diff --git a/src/gui/mod.rs b/src/gui/mod.rs index 9b67b53..6345c53 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -173,6 +173,7 @@ const DIFF_PREFETCH_BEHIND: usize = 1; /// At most this many prefetch loads queued or running at once. const DIFF_PREFETCH_INFLIGHT_MAX: usize = 2; const DIFF_PREFETCH_WORKERS: usize = 2; +const FETCH_MODEL_PART_COUNT: usize = 4; fn plain_char_key(key: KeyEvent, expected: char) -> bool { let modifiers = if expected.is_uppercase() { @@ -274,6 +275,7 @@ pub(crate) enum RemoteOpKind { Push, Pull, Fetch, + FetchAndCheckout, Other, } @@ -2673,7 +2675,12 @@ impl Gui { self.needs_refresh = true; } } - _ => { + RemoteOpKind::Fetch => { + if !self.start_background_fetch_refresh() { + self.needs_refresh = true; + } + } + RemoteOpKind::FetchAndCheckout | RemoteOpKind::Other => { self.needs_refresh = true; } } @@ -8797,6 +8804,40 @@ impl Gui { }); } + fn start_background_fetch_refresh(&mut self) -> bool { + if self.refresh_in_progress || self.initial_load_rx.is_some() { + return false; + } + + let (tx, rx) = mpsc::channel(); + + self.initial_load_rx = Some(rx); + self.initial_load_received = 0; + self.initial_load_expected = FETCH_MODEL_PART_COUNT; + self.refresh_in_progress = true; + self.needs_refresh = false; + + self.reset_commit_pagination(); + + let previous_tag_remote_state = { + let model = self.model.lock().unwrap(); + model + .tags + .iter() + .map(|tag| (tag.name.clone(), tag.on_remote)) + .collect::>() + }; + + let git = Arc::clone(&self.git); + let commit_filter = self.commit_filter_for_load(); + + std::thread::spawn(move || { + git.load_model_after_fetch_streaming(&tx, commit_filter, previous_tag_remote_state); + }); + + true + } + fn commit_filter_for_load(&self) -> Option { let has = self.commit_path_filter.is_some() || !self.commit_author_filter.is_empty() @@ -10658,12 +10699,15 @@ mod terminal_mouse_tests { } #[test] - fn ordinary_remote_operation_keeps_normal_refresh_behavior() { + fn fetch_and_checkout_keeps_normal_refresh_behavior() { let repo = TempRepo::new("remote-op-refresh"); let mut gui = checkout_test_gui(&repo); let head = repo.git(&["rev-parse", "HEAD"]); - gui.start_remote_op(RemoteOpKind::Fetch, "Fetch", "", |_| Ok(())); + + gui.start_remote_op(RemoteOpKind::FetchAndCheckout, "Fetch", "", |_| Ok(())); + wait_for_test_checkout(&mut gui); + assert_checkout_head(&gui, "feature", &head); assert!(gui.needs_refresh); assert!(!gui.refresh_in_progress); @@ -10671,6 +10715,53 @@ mod terminal_mouse_tests { assert!(matches!(gui.popup, PopupState::None)); } + #[test] + fn fetch_uses_targeted_background_refresh() { + let repo = TempRepo::new("fetch-targeted-refresh"); + let mut gui = checkout_test_gui(&repo); + + gui.start_remote_op(RemoteOpKind::Fetch, "Fetch", "", |_| Ok(())); + wait_for_test_checkout(&mut gui); + + assert!(!gui.needs_refresh); + assert!(gui.refresh_in_progress); + assert!(gui.initial_load_rx.is_some()); + assert_eq!(gui.initial_load_expected, FETCH_MODEL_PART_COUNT); + + let parts: Vec<_> = gui.initial_load_rx.take().unwrap().into_iter().collect(); + + assert_eq!(parts.len(), FETCH_MODEL_PART_COUNT); + + assert!( + parts + .iter() + .any(|part| matches!(part, ModelPart::Branches(_))) + ); + assert!( + parts + .iter() + .any(|part| matches!(part, ModelPart::Commits(_))) + ); + assert!( + parts + .iter() + .any(|part| matches!(part, ModelPart::Remotes(_))) + ); + assert!(parts.iter().any(|part| matches!(part, ModelPart::Tags(_)))); + assert!(!parts.iter().any(|part| matches!(part, ModelPart::Files(_)))); + assert!(!parts.iter().any(|part| matches!(part, ModelPart::Stash(_)))); + assert!( + !parts + .iter() + .any(|part| matches!(part, ModelPart::Head { .. })) + ); + assert!( + !parts + .iter() + .any(|part| matches!(part, ModelPart::DiffStats { .. })) + ); + } + #[test] fn pull_uses_targeted_background_refresh() { let repo = TempRepo::new("pull-targeted-refresh"); From a4d752e2de3b828aa1e1226aa8fdcfd1f135aea0 Mon Sep 17 00:00:00 2001 From: "Jasmeet (Jazz) Bhatia" Date: Mon, 12 Oct 2026 01:49:10 -0700 Subject: [PATCH 5/9] perf(gui): target refresh after auto-fetch --- src/gui/mod.rs | 35 ++++++++++++++++++++++++++++++----- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/src/gui/mod.rs b/src/gui/mod.rs index 6345c53..497b177 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -2626,14 +2626,16 @@ impl Gui { }); } - /// Collect auto-fetch completions. Success triggers a full refresh so the - /// branches/commits panes reflect any new upstream commits. Failures - /// (offline, auth prompt suppressed, etc.) are intentionally silent — - /// surfacing them as popups every 60s would be worse than missing data. + /// Collect auto-fetch completions. When refs changed, reuse the targeted + /// fetch refresh rather than reloading the entire model. Failures + /// (offline, auth prompt suppressed, etc.) remain intentionally silent. fn receive_auto_fetch_results(&mut self) { while let Ok(result) = self.auto_fetch_rx.try_recv() { self.auto_fetch_in_flight = false; - if matches!(result, Ok(true)) { + + if matches!(result, Ok(true)) && !self.start_background_fetch_refresh() { + // Another model refresh is already in flight. Queue the normal + // refresh rather than risk leaving stale remote state. self.needs_refresh = true; } } @@ -10762,6 +10764,29 @@ mod terminal_mouse_tests { ); } + #[test] + fn auto_fetch_uses_targeted_background_refresh() { + let repo = TempRepo::new("auto-fetch-targeted-refresh"); + let mut gui = checkout_test_gui(&repo); + + gui.auto_fetch_in_flight = true; + gui.auto_fetch_tx.send(Ok(true)).unwrap(); + + gui.receive_auto_fetch_results(); + + assert!(!gui.auto_fetch_in_flight); + assert!(!gui.needs_refresh); + assert!(gui.refresh_in_progress); + assert!(gui.initial_load_rx.is_some()); + assert_eq!(gui.initial_load_expected, FETCH_MODEL_PART_COUNT); + + // Drain the targeted refresh so its worker threads finish before the + // temporary repository is dropped. + let parts: Vec<_> = gui.initial_load_rx.take().unwrap().into_iter().collect(); + + assert_eq!(parts.len(), FETCH_MODEL_PART_COUNT); + } + #[test] fn pull_uses_targeted_background_refresh() { let repo = TempRepo::new("pull-targeted-refresh"); From c39653b0b8984d134b2d318c17440e116b451830 Mon Sep 17 00:00:00 2001 From: "Jasmeet (Jazz) Bhatia" Date: Mon, 12 Oct 2026 14:59:46 -0700 Subject: [PATCH 6/9] perf(gui): avoid duplicate periodic refresh --- src/gui/mod.rs | 36 ++++++++++++++++++++++++++++++------ 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/src/gui/mod.rs b/src/gui/mod.rs index 497b177..e53d3dc 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -977,6 +977,16 @@ impl Gui { }); } + fn periodic_refresh_due(&self) -> bool { + let refresh_interval = self.config.user_config.refresher.refresh_interval; + + self.config.user_config.git.auto_refresh + && refresh_interval > 0 + && !self.refresh_in_progress + && self.initial_load_rx.is_none() + && self.last_refresh_at.elapsed().as_secs() >= refresh_interval + } + fn show_notification(&mut self, notification: PopupState) { if self.shell_command_ui_active() || self.deferred_notification_ui_active() { // Auto-refresh can report the same failure repeatedly during a @@ -1738,12 +1748,7 @@ impl Gui { break; } - // Background auto-refresh on refresher.refreshInterval (0 = disabled). - let refresh_interval = self.config.user_config.refresher.refresh_interval; - if self.config.user_config.git.auto_refresh - && refresh_interval > 0 - && self.last_refresh_at.elapsed().as_secs() >= refresh_interval - { + if self.periodic_refresh_due() { self.needs_refresh = true; } @@ -10700,6 +10705,25 @@ mod terminal_mouse_tests { assert_eq!(gui.input_wait_timeout(), Duration::from_millis(16)); } + #[test] + fn periodic_refresh_does_not_requeue_while_refresh_is_running() { + let repo = TempRepo::new("periodic-refresh-no-requeue"); + let mut gui = checkout_test_gui(&repo); + + let mut config = crate::config::AppConfig::default(); + config.user_config.git.auto_refresh = true; + config.user_config.refresher.refresh_interval = 1; + gui.config = Arc::new(config); + + gui.last_refresh_at = Instant::now().checked_sub(Duration::from_secs(2)).unwrap(); + + assert!(gui.periodic_refresh_due()); + + gui.refresh_in_progress = true; + + assert!(!gui.periodic_refresh_due()); + } + #[test] fn fetch_and_checkout_keeps_normal_refresh_behavior() { let repo = TempRepo::new("remote-op-refresh"); From 122dcd831b4cc43542c3e7a4554b146ec065ed61 Mon Sep 17 00:00:00 2001 From: "Jasmeet (Jazz) Bhatia" Date: Mon, 12 Oct 2026 15:39:56 -0700 Subject: [PATCH 7/9] perf(gui): target periodic refresh --- src/git/mod.rs | 24 ++++++++++++++++++++++++ src/gui/mod.rs | 45 ++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/src/git/mod.rs b/src/git/mod.rs index 1227ca3..703d50a 100644 --- a/src/git/mod.rs +++ b/src/git/mod.rs @@ -325,6 +325,30 @@ impl GitCommands { } } + pub fn load_model_periodic_refresh_streaming(self: &Arc, tx: &mpsc::Sender) { + // refreshInterval tracks working-tree file state. + { + let tx = tx.clone(); + let git = Arc::clone(self); + std::thread::spawn(move || { + if let Ok(files) = git.load_files_status_only() { + let _ = tx.send(ModelPart::Files(files)); + } + }); + } + + // Submodule state can also change independently of lazygitrs. + { + let tx = tx.clone(); + let git = Arc::clone(self); + std::thread::spawn(move || { + if let Ok(submodules) = git.load_submodules() { + let _ = tx.send(ModelPart::Submodules(submodules)); + } + }); + } + } + pub fn load_model_after_fetch_streaming( self: &Arc, tx: &mpsc::Sender, diff --git a/src/gui/mod.rs b/src/gui/mod.rs index e53d3dc..66e284d 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -174,6 +174,7 @@ const DIFF_PREFETCH_BEHIND: usize = 1; const DIFF_PREFETCH_INFLIGHT_MAX: usize = 2; const DIFF_PREFETCH_WORKERS: usize = 2; const FETCH_MODEL_PART_COUNT: usize = 4; +const PERIODIC_MODEL_PART_COUNT: usize = 2; fn plain_char_key(key: KeyEvent, expected: char) -> bool { let modifiers = if expected.is_uppercase() { @@ -1748,7 +1749,7 @@ impl Gui { break; } - if self.periodic_refresh_due() { + if self.periodic_refresh_due() && !self.start_background_periodic_refresh() { self.needs_refresh = true; } @@ -8811,6 +8812,27 @@ impl Gui { }); } + fn start_background_periodic_refresh(&mut self) -> bool { + if self.refresh_in_progress || self.initial_load_rx.is_some() { + return false; + } + + let (tx, rx) = mpsc::channel(); + + self.initial_load_rx = Some(rx); + self.initial_load_received = 0; + self.initial_load_expected = PERIODIC_MODEL_PART_COUNT; + self.refresh_in_progress = true; + + let git = Arc::clone(&self.git); + + std::thread::spawn(move || { + git.load_model_periodic_refresh_streaming(&tx); + }); + + true + } + fn start_background_fetch_refresh(&mut self) -> bool { if self.refresh_in_progress || self.initial_load_rx.is_some() { return false; @@ -10705,6 +10727,27 @@ mod terminal_mouse_tests { assert_eq!(gui.input_wait_timeout(), Duration::from_millis(16)); } + #[test] + fn periodic_refresh_uses_targeted_model_parts() { + let repo = TempRepo::new("periodic-targeted-refresh"); + let mut gui = checkout_test_gui(&repo); + + assert!(gui.start_background_periodic_refresh()); + assert!(gui.refresh_in_progress); + assert!(gui.initial_load_rx.is_some()); + assert_eq!(gui.initial_load_expected, PERIODIC_MODEL_PART_COUNT); + + let parts: Vec<_> = gui.initial_load_rx.take().unwrap().into_iter().collect(); + + assert_eq!(parts.len(), PERIODIC_MODEL_PART_COUNT); + assert!(parts.iter().any(|part| matches!(part, ModelPart::Files(_)))); + assert!( + parts + .iter() + .any(|part| matches!(part, ModelPart::Submodules(_))) + ); + } + #[test] fn periodic_refresh_does_not_requeue_while_refresh_is_running() { let repo = TempRepo::new("periodic-refresh-no-requeue"); From b2c86e485059082894cd31a6560d71fa43b73c05 Mon Sep 17 00:00:00 2001 From: "Jasmeet (Jazz) Bhatia" Date: Mon, 12 Oct 2026 22:42:18 -0700 Subject: [PATCH 8/9] perf(gui): combine commit detail queries --- src/git/commit.rs | 45 ++++++++++++++++++++++++++++++++++++++++++++- src/gui/mod.rs | 14 ++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/src/git/commit.rs b/src/git/commit.rs index 0bddf38..7c1f6ac 100644 --- a/src/git/commit.rs +++ b/src/git/commit.rs @@ -32,7 +32,7 @@ fn commit_filter_path_suggestions<'a>(paths: impl Iterator) -> V #[cfg(test)] mod commit_filter_suggestion_tests { - use super::{GitCommands, commit_filter_path_suggestions}; + use super::{GitCommands, commit_filter_path_suggestions, parse_commit_details_output}; use crate::model::commit::{Commit, CommitStatus, Divergence}; #[test] @@ -81,6 +81,34 @@ mod commit_filter_suggestion_tests { ] ); } + + #[test] + fn commit_details_output_preserves_message_and_parses_shortstat() { + let output = concat!( + "Fix scheduler regression\n", + "\n", + "Preserve the full commit body across multiple lines.\n", + "This line contains additional context.\n", + "\0", + "\n", + " 2 files changed, 7 insertions(+), 3 deletions(-)\n", + ); + + let (message, stat) = parse_commit_details_output(output); + + assert_eq!( + message, + concat!( + "Fix scheduler regression\n", + "\n", + "Preserve the full commit body across multiple lines.\n", + "This line contains additional context." + ) + ); + assert_eq!(stat.files_changed, 2); + assert_eq!(stat.insertions, 7); + assert_eq!(stat.deletions, 3); + } } impl GitCommands { @@ -427,6 +455,15 @@ impl GitCommands { Ok(result.stdout.trim().to_string()) } + pub fn commit_details(&self, hash: &str) -> Result<(String, CommitStat)> { + let result = self + .git() + .args(&["log", "-1", "--shortstat", "--format=%B%x00", hash]) + .run_expecting_success()?; + + Ok(parse_commit_details_output(&result.stdout)) + } + pub fn commit_message_body(&self, hash: &str) -> Result { let result = self .git() @@ -475,6 +512,12 @@ impl GitCommands { } } +fn parse_commit_details_output(output: &str) -> (String, CommitStat) { + let (message, shortstat) = output.split_once('\0').unwrap_or((output, "")); + + (message.trim().to_string(), parse_shortstat(shortstat)) +} + fn parse_shortstat(output: &str) -> CommitStat { let line = output .lines() diff --git a/src/gui/mod.rs b/src/gui/mod.rs index 66e284d..4d6affc 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -2270,6 +2270,19 @@ impl Gui { if generation_counter.load(Ordering::Relaxed) != generation { return; } + + if !stat_cached && !message_cached { + if let Ok((message, stat)) = git.commit_details(&hash) { + if let Ok(mut cache) = stat_cache.lock() { + cache.insert(hash.clone(), stat); + } + if let Ok(mut cache) = message_cache.lock() { + cache.insert(hash, message); + } + } + return; + } + if !stat_cached { if let Ok(stat) = git.commit_stat(&hash) { if let Ok(mut cache) = stat_cache.lock() { @@ -2281,6 +2294,7 @@ impl Gui { if generation_counter.load(Ordering::Relaxed) != generation { return; } + if !message_cached { if let Ok(message) = git.commit_message_full(&hash) { if let Ok(mut cache) = message_cache.lock() { From 6bea0ff829ff60b0af0e8b20916192dcdaaf3d45 Mon Sep 17 00:00:00 2001 From: "Jasmeet (Jazz) Bhatia" Date: Sat, 17 Oct 2026 15:25:47 -0700 Subject: [PATCH 9/9] perf(gui): optimize refresh after checkout --- src/git/commit.rs | 129 ++++++++++++++++++++++++++++++++++++++++++++-- src/git/mod.rs | 108 ++++++++++++++++++++++++++++++++++++++ src/gui/mod.rs | 75 ++++++++++++++++++++++++++- 3 files changed, 307 insertions(+), 5 deletions(-) diff --git a/src/git/commit.rs b/src/git/commit.rs index 7c1f6ac..9f14e2d 100644 --- a/src/git/commit.rs +++ b/src/git/commit.rs @@ -1,10 +1,9 @@ -use anyhow::Result; - -use super::GitCommands; +use super::{GitCommands, HeadState}; use crate::model::{ Commit, CommitStatus, commit::{CommitStat, Divergence}, }; +use anyhow::Result; use std::collections::HashSet; #[derive(Debug, Clone, Default)] @@ -33,6 +32,7 @@ fn commit_filter_path_suggestions<'a>(paths: impl Iterator) -> V #[cfg(test)] mod commit_filter_suggestion_tests { use super::{GitCommands, commit_filter_path_suggestions, parse_commit_details_output}; + use crate::git::HeadState; use crate::model::commit::{Commit, CommitStatus, Divergence}; #[test] @@ -57,6 +57,94 @@ mod commit_filter_suggestion_tests { assert_eq!(commits[0].status, CommitStatus::Pushed); } + #[test] + fn checkout_head_state_moves_head_decoration() { + let mut commits = vec![ + Commit { + hash: "old".into(), + name: "old".into(), + status: CommitStatus::Pushed, + action: String::new(), + tags: Vec::new(), + refs: vec!["HEAD -> main".into(), "origin/main".into()], + extra_info: String::new(), + author_name: String::new(), + author_email: String::new(), + unix_timestamp: 0, + parents: Vec::new(), + divergence: Divergence::None, + }, + Commit { + hash: "new".into(), + name: "new".into(), + status: CommitStatus::Pushed, + action: String::new(), + tags: Vec::new(), + refs: vec!["feature".into()], + extra_info: String::new(), + author_name: String::new(), + author_email: String::new(), + unix_timestamp: 0, + parents: Vec::new(), + divergence: Divergence::None, + }, + ]; + + let head = HeadState { + hash: "new".into(), + branch_name: "feature".into(), + }; + + GitCommands::apply_checkout_head_state(&mut commits, &head); + + assert_eq!(commits[0].refs, vec!["main", "origin/main"]); + assert_eq!(commits[1].refs, vec!["HEAD -> feature"]); + } + + #[test] + fn checkout_head_state_handles_detached_head() { + let mut commits = vec![ + Commit { + hash: "old".into(), + name: "old".into(), + status: CommitStatus::Pushed, + action: String::new(), + tags: Vec::new(), + refs: vec!["HEAD -> main".into()], + extra_info: String::new(), + author_name: String::new(), + author_email: String::new(), + unix_timestamp: 0, + parents: Vec::new(), + divergence: Divergence::None, + }, + Commit { + hash: "new".into(), + name: "new".into(), + status: CommitStatus::Pushed, + action: String::new(), + tags: Vec::new(), + refs: Vec::new(), + extra_info: String::new(), + author_name: String::new(), + author_email: String::new(), + unix_timestamp: 0, + parents: Vec::new(), + divergence: Divergence::None, + }, + ]; + + let head = HeadState { + hash: "new".into(), + branch_name: String::new(), + }; + + GitCommands::apply_checkout_head_state(&mut commits, &head); + + assert_eq!(commits[0].refs, vec!["main"]); + assert_eq!(commits[1].refs, vec!["HEAD"]); + } + #[test] fn path_suggestions_include_files_and_each_parent_directory() { let suggestions = commit_filter_path_suggestions( @@ -387,6 +475,41 @@ impl GitCommands { } } + pub(crate) fn apply_checkout_head_state(commits: &mut [Commit], head: &HeadState) { + // Remove the old HEAD marker while preserving the underlying local branch + // decoration. Detached HEAD is represented by a bare "HEAD" ref. + for commit in commits.iter_mut() { + commit.refs.retain(|reference| reference != "HEAD"); + + for reference in &mut commit.refs { + if let Some(branch) = reference.strip_prefix("HEAD -> ") { + *reference = branch.to_string(); + } + } + } + + let Some(commit) = commits.iter_mut().find(|commit| commit.hash == head.hash) else { + return; + }; + + if head.branch_name.is_empty() { + commit.refs.insert(0, "HEAD".to_string()); + return; + } + + let head_ref = format!("HEAD -> {}", head.branch_name); + + if let Some(index) = commit + .refs + .iter() + .position(|reference| reference == &head.branch_name) + { + commit.refs[index] = head_ref; + } else { + commit.refs.insert(0, head_ref); + } + } + pub fn create_commit(&self, message: &str, sign_off: bool) -> Result<()> { let mut cmd = self.git(); cmd = cmd.arg("commit").arg("-m").arg(message); diff --git a/src/git/mod.rs b/src/git/mod.rs index 703d50a..84dd058 100644 --- a/src/git/mod.rs +++ b/src/git/mod.rs @@ -530,6 +530,114 @@ impl GitCommands { } } + /// Refresh model state that can be affected by a successful checkout. + /// + /// Checkout can change HEAD-relative files, branch state, commit decorations, + /// worktree state, submodule state, reflog, diff stats, contributors, and + /// repository status. It cannot change stash, remotes, tags, or the repo URL. + pub fn load_model_after_checkout_streaming( + self: &Arc, + tx: &mpsc::Sender, + mut commits: Vec, + ) { + macro_rules! spawn_part { + ($tx:expr, $self:expr, $variant:ident, $expr:expr) => {{ + let tx = $tx.clone(); + let git = Arc::clone($self); + std::thread::spawn(move || { + if let Ok(data) = $expr(&git) { + let _ = tx.send(ModelPart::$variant(data)); + } + }); + }}; + } + + // Checkout can change HEAD-relative file status, but we don't need the + // expensive per-file numstat/hunk scans immediately. The selected diff + // is refreshed separately by the GUI. + spawn_part!(tx, self, Files, |g: &GitCommands| { + g.load_files_status_only() + }); + + spawn_part!(tx, self, Branches, |g: &GitCommands| g.load_branches()); + + { + let tx = tx.clone(); + let git = Arc::clone(self); + + std::thread::spawn(move || { + // HEAD changes commit decorations, but checkout does not change the + // commit graph itself. Reuse the already-loaded history rather than + // walking every ref again. + if let Ok(head) = git.head_state() { + GitCommands::apply_checkout_head_state(&mut commits, &head); + + let _ = tx.send(ModelPart::Head { + hash: head.hash, + branch_name: head.branch_name, + }); + } + + let unpushed = git.unpushed_commit_hashes().unwrap_or_default(); + GitCommands::apply_unpushed_status(&mut commits, &unpushed); + + let _ = tx.send(ModelPart::Commits(commits)); + }); + } + + // Pull can move the current worktree's HEAD. + spawn_part!(tx, self, Worktrees, |g: &GitCommands| { + g.load_worktrees() + .or_else(|_| Ok::<_, anyhow::Error>(Vec::new())) + }); + + // A changed superproject HEAD can change gitlink state. + spawn_part!(tx, self, Submodules, |g: &GitCommands| { + g.load_submodules() + .or_else(|_| Ok::<_, anyhow::Error>(Vec::new())) + }); + + // Checkout moving HEAD changes reflog history. + spawn_part!(tx, self, Reflog, |g: &GitCommands| { + g.load_reflog(100) + .or_else(|_| Ok::<_, anyhow::Error>(Vec::new())) + }); + + { + let tx = tx.clone(); + let git = Arc::clone(self); + std::thread::spawn(move || { + if let Ok((added, deleted)) = git.diff_shortstat() { + let _ = tx.send(ModelPart::DiffStats { added, deleted }); + } + }); + } + + { + let tx = tx.clone(); + let git = Arc::clone(self); + std::thread::spawn(move || { + let _ = tx.send(ModelPart::Contributors(git.load_contributors(500, 10))); + }); + } + + { + let tx = tx.clone(); + let git = Arc::clone(self); + std::thread::spawn(move || { + if let Ok(status) = git.repo_status() { + let _ = tx.send(ModelPart::RepoStatus { + is_rebasing: status.is_rebasing, + is_merging: status.is_merging, + is_cherry_picking: status.is_cherry_picking, + is_bisecting: status.is_bisecting, + rebase_onto_hash: status.rebase_onto_hash, + }); + } + }); + } + } + /// Refresh just the working tree files. pub fn refresh_files(&self) -> Result> { self.load_files() diff --git a/src/gui/mod.rs b/src/gui/mod.rs index 4d6affc..1995845 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -168,6 +168,7 @@ const DIFF_PREVIEW_CACHE_ENTRIES: usize = 24; const DIFF_PREVIEW_CACHE_BYTES: usize = 32 * 1024 * 1024; const MAX_CACHED_DIFF_BYTES: usize = 8 * 1024 * 1024; /// How many diffs below/above the selection to warm in the preview cache. +const CHECKOUT_MODEL_PART_COUNT: usize = MODEL_PART_COUNT - 4; const DIFF_PREFETCH_AHEAD: usize = 3; const DIFF_PREFETCH_BEHIND: usize = 1; /// At most this many prefetch loads queued or running at once. @@ -2717,7 +2718,9 @@ impl Gui { self.needs_files_refresh = false; // Dispatch before the next paint/input wait, not after an // idle 50/200ms poll. HEAD is already visible above. - self.start_background_refresh(); + if !self.start_background_checkout_refresh() { + self.needs_refresh = true; + } } } Err(e) => { @@ -9010,6 +9013,37 @@ impl Gui { Ok(()) } + /// Kick off the model refresh required after a successful checkout. + fn start_background_checkout_refresh(&mut self) -> bool { + if self.refresh_in_progress || self.initial_load_rx.is_some() { + return false; + } + + let (tx, rx) = mpsc::channel(); + + self.initial_load_rx = Some(rx); + self.initial_load_received = 0; + self.initial_load_expected = CHECKOUT_MODEL_PART_COUNT; + self.refresh_in_progress = true; + self.needs_refresh = false; + + self.reset_commit_pagination(); + self.diff_preview_cache.retain_immutable(); + + let commits = { + let model = self.model.lock().unwrap(); + model.commits.clone() + }; + + let git = Arc::clone(&self.git); + + std::thread::spawn(move || { + git.load_model_after_checkout_streaming(&tx, commits); + }); + + true + } + /// Kick off the model refresh required after a successful pull. /// /// Reuses the normal streaming ModelPart path, but avoids reloading state @@ -10718,7 +10752,7 @@ mod terminal_mouse_tests { assert!(gui.refresh_in_progress); assert_eq!(gui.initial_load_received, 0); let parts: Vec<_> = gui.initial_load_rx.take().unwrap().into_iter().collect(); - assert_eq!(parts.len(), MODEL_PART_COUNT); + assert_eq!(parts.len(), CHECKOUT_MODEL_PART_COUNT); assert!(parts.iter().any(|part| matches!( part, ModelPart::Head { branch_name, .. } if branch_name == "main" ))); @@ -10781,6 +10815,43 @@ mod terminal_mouse_tests { assert!(!gui.periodic_refresh_due()); } + #[test] + fn checkout_uses_targeted_background_refresh() { + let repo = TempRepo::new("checkout-targeted-refresh"); + let mut gui = checkout_test_gui(&repo); + + request_test_checkout(&mut gui, ' ', "main"); + wait_for_test_checkout(&mut gui); + + assert!(!gui.needs_refresh); + assert!(gui.refresh_in_progress); + assert!(gui.initial_load_rx.is_some()); + assert_eq!(gui.initial_load_expected, CHECKOUT_MODEL_PART_COUNT); + + let parts: Vec<_> = gui.initial_load_rx.take().unwrap().into_iter().collect(); + + assert_eq!(parts.len(), CHECKOUT_MODEL_PART_COUNT); + + assert!( + parts + .iter() + .any(|part| matches!(part, ModelPart::Commits(_))) + ); + + assert!(!parts.iter().any(|part| matches!(part, ModelPart::Stash(_)))); + assert!( + !parts + .iter() + .any(|part| matches!(part, ModelPart::Remotes(_))) + ); + assert!(!parts.iter().any(|part| matches!(part, ModelPart::Tags(_)))); + assert!( + !parts + .iter() + .any(|part| matches!(part, ModelPart::RepoUrl(_))) + ); + } + #[test] fn fetch_and_checkout_keeps_normal_refresh_behavior() { let repo = TempRepo::new("remote-op-refresh");