From 218d7b52c6e97614957fe1dc6c748d9149d09bb0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sampo=20Kivist=C3=B6?= Date: Thu, 9 Apr 2026 11:37:38 +0300 Subject: [PATCH 1/8] WIP: setting for git --- Cargo.lock | 1 + crates/gitcomet-core/src/merge_extraction.rs | 9 +- crates/gitcomet-core/src/process.rs | 364 +++++++++- crates/gitcomet-git-gix/src/util.rs | 5 +- crates/gitcomet-state/src/model.rs | 2 + crates/gitcomet-state/src/msg/message.rs | 2 + crates/gitcomet-state/src/session.rs | 72 ++ crates/gitcomet-state/src/store/effects.rs | 666 +++++++++++++++++- .../gitcomet-state/src/store/effects/clone.rs | 5 +- crates/gitcomet-state/src/store/mod.rs | 17 + crates/gitcomet-state/src/store/reducer.rs | 94 +++ .../gitcomet-state/src/store/tests/effects.rs | 60 ++ .../tests/session_integration.rs | 1 + crates/gitcomet-ui-gpui/src/app.rs | 3 + crates/gitcomet-ui-gpui/src/view/mod.rs | 83 ++- .../gitcomet-ui-gpui/src/view/mod_helpers.rs | 10 + .../src/view/panels/tests/file_diff.rs | 59 +- .../src/view/settings_window.rs | 502 +++++++++---- crates/gitcomet-ui-gpui/src/view/splash.rs | 196 +++++- .../gitcomet-ui-gpui/src/view/state_apply.rs | 9 +- crates/gitcomet-ui-gpui/src/view/tests.rs | 138 ++++ crates/gitcomet-ui-gpui/src/view/tooltip.rs | 1 + crates/gitcomet/Cargo.toml | 1 + crates/gitcomet/src/cli/git_config.rs | 6 +- crates/gitcomet/src/difftool_mode.rs | 6 +- crates/gitcomet/src/main.rs | 8 + crates/gitcomet/src/setup_mode.rs | 6 +- 27 files changed, 2144 insertions(+), 182 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8d0c7b54..44ea9ed5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2247,6 +2247,7 @@ dependencies = [ "gitcomet-core", "gitcomet-git", "gitcomet-git-gix", + "gitcomet-state", "gitcomet-ui", "gitcomet-ui-gpui", "mimalloc", diff --git a/crates/gitcomet-core/src/merge_extraction.rs b/crates/gitcomet-core/src/merge_extraction.rs index e37e99c6..03884538 100644 --- a/crates/gitcomet-core/src/merge_extraction.rs +++ b/crates/gitcomet-core/src/merge_extraction.rs @@ -5,13 +5,15 @@ //! into production code so it can be reused outside ad-hoc test harnesses. use crate::merge::{MergeOptions, merge_file}; -use crate::process::configure_background_command; +use crate::process::git_command; use rustc_hash::FxHashSet as HashSet; use std::collections::BTreeSet; use std::fmt; use std::io; use std::path::{Path, PathBuf}; -use std::process::{Command, Output}; +#[cfg(test)] +use std::process::Command; +use std::process::Output; fn bytes_to_text_preserving_utf8(bytes: &[u8]) -> String { use std::fmt::Write as _; @@ -526,8 +528,7 @@ fn read_blob_bytes_optional( } fn run_git(repo: &Path, args: &[&str]) -> Result { - let mut command = Command::new("git"); - configure_background_command(&mut command); + let mut command = git_command(); command .args(args) .current_dir(repo) diff --git a/crates/gitcomet-core/src/process.rs b/crates/gitcomet-core/src/process.rs index bf4b8287..08dbee5b 100644 --- a/crates/gitcomet-core/src/process.rs +++ b/crates/gitcomet-core/src/process.rs @@ -1,5 +1,107 @@ -use std::ffi::OsStr; +use crate::path_utils::canonicalize_or_original; +use std::ffi::{OsStr, OsString}; +use std::path::{Path, PathBuf}; use std::process::Command; +#[cfg(test)] +use std::sync::Mutex; +use std::sync::{OnceLock, RwLock}; + +#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Default)] +pub enum GitExecutablePreference { + #[default] + SystemPath, + Custom(PathBuf), +} + + +impl GitExecutablePreference { + pub fn from_optional_path(path: Option) -> Self { + match path { + Some(path) if path.as_os_str().is_empty() => Self::Custom(PathBuf::new()), + Some(path) => Self::Custom(normalize_git_executable_path(path)), + _ => Self::SystemPath, + } + } + + pub fn custom_path(&self) -> Option<&Path> { + match self { + Self::SystemPath => None, + Self::Custom(path) => Some(path.as_path()), + } + } + + pub fn display_label(&self) -> String { + match self { + Self::SystemPath => "System PATH".to_string(), + Self::Custom(path) if path.as_os_str().is_empty() => { + "Custom executable (not selected)".to_string() + } + Self::Custom(path) => path.display().to_string(), + } + } + + fn command_program(&self) -> OsString { + match self { + Self::SystemPath => OsString::from("git"), + Self::Custom(path) => path.as_os_str().to_os_string(), + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum GitExecutableAvailability { + Available { version_output: String }, + Unavailable { detail: String }, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GitRuntimeState { + pub preference: GitExecutablePreference, + pub availability: GitExecutableAvailability, +} + +impl Default for GitRuntimeState { + fn default() -> Self { + current_git_runtime() + } +} + +impl GitRuntimeState { + pub fn is_available(&self) -> bool { + matches!( + self.availability, + GitExecutableAvailability::Available { .. } + ) + } + + pub fn version_output(&self) -> Option<&str> { + match &self.availability { + GitExecutableAvailability::Available { version_output } => { + Some(version_output.as_str()) + } + GitExecutableAvailability::Unavailable { .. } => None, + } + } + + pub fn unavailable_detail(&self) -> Option<&str> { + match &self.availability { + GitExecutableAvailability::Available { .. } => None, + GitExecutableAvailability::Unavailable { detail } => Some(detail.as_str()), + } + } +} + +fn git_runtime_slot() -> &'static RwLock { + static SLOT: OnceLock> = OnceLock::new(); + SLOT.get_or_init(|| RwLock::new(probe_git_runtime(GitExecutablePreference::SystemPath))) +} + +#[cfg(test)] +fn git_runtime_test_lock() -> &'static Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) +} /// Create a background subprocess command preconfigured to avoid creating a /// visible console window on Windows. @@ -9,6 +111,38 @@ pub fn background_command(program: impl AsRef) -> Command { command } +pub fn git_command() -> Command { + git_command_for_preference(¤t_git_runtime().preference) +} + +pub fn current_git_runtime() -> GitRuntimeState { + git_runtime_slot() + .read() + .unwrap_or_else(|err| err.into_inner()) + .clone() +} + +pub fn current_git_executable_preference() -> GitExecutablePreference { + current_git_runtime().preference +} + +pub fn install_git_executable_preference(preference: GitExecutablePreference) -> GitRuntimeState { + let next = probe_git_runtime(preference); + *git_runtime_slot() + .write() + .unwrap_or_else(|err| err.into_inner()) = next.clone(); + next +} + +pub fn install_git_executable_path(path: Option) -> GitRuntimeState { + install_git_executable_preference(GitExecutablePreference::from_optional_path(path)) +} + +pub fn refresh_git_runtime() -> GitRuntimeState { + let preference = current_git_executable_preference(); + install_git_executable_preference(preference) +} + /// Configure a background subprocess so it does not create a visible console /// window on Windows when GitComet is running as a GUI-subsystem app. pub fn configure_background_command(command: &mut std::process::Command) { @@ -25,3 +159,231 @@ pub fn configure_background_command(command: &mut std::process::Command) { let _ = command; } } + +pub fn normalize_git_executable_path(path: PathBuf) -> PathBuf { + if path.as_os_str().is_empty() { + return path; + } + let path = if path.is_absolute() { + path + } else { + std::env::current_dir() + .unwrap_or_else(|_| PathBuf::from(".")) + .join(path) + }; + canonicalize_or_original(path) +} + +fn git_command_for_preference(preference: &GitExecutablePreference) -> Command { + background_command(preference.command_program()) +} + +fn probe_git_runtime(preference: GitExecutablePreference) -> GitRuntimeState { + if matches!( + &preference, + GitExecutablePreference::Custom(path) if path.as_os_str().is_empty() + ) { + return GitRuntimeState { + preference, + availability: GitExecutableAvailability::Unavailable { + detail: "Custom Git executable is not configured. Choose an executable or switch back to System PATH.".to_string(), + }, + }; + } + + let executable_label = preference.display_label(); + let mut command = git_command_for_preference(&preference); + command.arg("--version"); + + let availability = match command.output() { + Ok(output) if output.status.success() => { + let version_output = if !output.stdout.is_empty() { + bytes_to_text_preserving_utf8(&output.stdout) + .trim() + .to_string() + } else { + bytes_to_text_preserving_utf8(&output.stderr) + .trim() + .to_string() + }; + if version_output.is_empty() { + GitExecutableAvailability::Unavailable { + detail: format!( + "Git executable at {executable_label} returned no version text." + ), + } + } else { + GitExecutableAvailability::Available { version_output } + } + } + Ok(output) => { + let detail = bytes_to_text_preserving_utf8(&output.stderr) + .trim() + .to_string(); + let detail = if detail.is_empty() { + format!( + "Git executable at {executable_label} exited with {status}.", + status = output.status + ) + } else { + format!("Git executable at {executable_label} failed: {detail}") + }; + GitExecutableAvailability::Unavailable { detail } + } + Err(err) => GitExecutableAvailability::Unavailable { + detail: match preference { + GitExecutablePreference::SystemPath => { + format!("Git executable was not found in System PATH: {err}") + } + GitExecutablePreference::Custom(_) => { + format!("Configured Git executable at {executable_label} is unavailable: {err}") + } + }, + }, + }; + + GitRuntimeState { + preference, + availability, + } +} + +fn bytes_to_text_preserving_utf8(bytes: &[u8]) -> String { + use std::fmt::Write as _; + + let mut out = String::with_capacity(bytes.len()); + let mut cursor = 0usize; + while cursor < bytes.len() { + match std::str::from_utf8(&bytes[cursor..]) { + Ok(valid) => { + out.push_str(valid); + break; + } + Err(err) => { + let valid_len = err.valid_up_to(); + if valid_len > 0 { + let valid = &bytes[cursor..cursor + valid_len]; + out.push_str( + std::str::from_utf8(valid) + .expect("slice identified by valid_up_to must be valid UTF-8"), + ); + cursor += valid_len; + } + + let invalid_len = err.error_len().unwrap_or(1); + let invalid_end = cursor.saturating_add(invalid_len).min(bytes.len()); + for byte in &bytes[cursor..invalid_end] { + let _ = write!(out, "\\x{byte:02x}"); + } + cursor = invalid_end; + } + } + } + + out +} + +#[cfg(test)] +pub fn lock_git_runtime_test() -> std::sync::MutexGuard<'static, ()> { + git_runtime_test_lock() + .lock() + .unwrap_or_else(|err| err.into_inner()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + #[test] + fn normalize_git_executable_path_makes_relative_paths_absolute() { + let path = normalize_git_executable_path(PathBuf::from("test-git")); + assert!( + path.is_absolute(), + "expected absolute path, got {}", + path.display() + ); + } + + #[test] + fn install_git_executable_preference_reports_missing_custom_path() { + let _lock = lock_git_runtime_test(); + let original = current_git_executable_preference(); + + let missing = std::env::temp_dir().join("gitcomet-missing-git-executable"); + let state = + install_git_executable_preference(GitExecutablePreference::Custom(missing.clone())); + + assert!(!state.is_available()); + assert_eq!( + state.preference, + GitExecutablePreference::Custom(missing.clone()) + ); + assert!( + state + .unavailable_detail() + .expect("expected unavailable detail") + .contains(&missing.display().to_string()) + ); + + let _ = install_git_executable_preference(original); + } + + #[test] + fn install_git_executable_preference_uses_custom_executable() { + let _lock = lock_git_runtime_test(); + let original = current_git_executable_preference(); + + let dir = tempfile::tempdir().expect("create temp dir"); + #[cfg(unix)] + let script = dir.path().join("git"); + #[cfg(windows)] + let script = dir.path().join("git.cmd"); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + + fs::write(&script, "#!/bin/sh\necho 'git version 9.9.9-test'\n").expect("write script"); + let mut permissions = fs::metadata(&script).expect("metadata").permissions(); + permissions.set_mode(0o700); + fs::set_permissions(&script, permissions).expect("set permissions"); + } + + #[cfg(windows)] + { + fs::write(&script, "@echo off\r\necho git version 9.9.9-test\r\n") + .expect("write script"); + } + + let state = + install_git_executable_preference(GitExecutablePreference::Custom(script.clone())); + assert!(state.is_available()); + assert_eq!(state.version_output(), Some("git version 9.9.9-test")); + + let _ = install_git_executable_preference(original); + } + + #[test] + fn install_git_executable_preference_reports_missing_custom_selection() { + let _lock = lock_git_runtime_test(); + let original = current_git_executable_preference(); + + let state = + install_git_executable_preference(GitExecutablePreference::Custom(PathBuf::new())); + + assert!(!state.is_available()); + assert_eq!( + state.preference, + GitExecutablePreference::Custom(PathBuf::new()) + ); + assert_eq!( + state.unavailable_detail(), + Some( + "Custom Git executable is not configured. Choose an executable or switch back to System PATH." + ) + ); + + let _ = install_git_executable_preference(original); + } +} diff --git a/crates/gitcomet-git-gix/src/util.rs b/crates/gitcomet-git-gix/src/util.rs index c8c4acbb..104b7372 100644 --- a/crates/gitcomet-git-gix/src/util.rs +++ b/crates/gitcomet-git-gix/src/util.rs @@ -9,7 +9,7 @@ use gitcomet_core::auth::{ }; use gitcomet_core::domain::{Commit, CommitId, CommitParentIds, LogPage}; use gitcomet_core::error::{Error, ErrorKind, GitFailure, GitFailureId}; -use gitcomet_core::process::configure_background_command; +use gitcomet_core::process::{configure_background_command, git_command}; use gitcomet_core::services::{CommandOutput, Result}; use std::fs; use std::io; @@ -173,8 +173,7 @@ fn apply_test_git_command_environment(cmd: &mut Command) { } pub(crate) fn git_workdir_cmd_for(workdir: &Path) -> Command { - let mut cmd = Command::new("git"); - configure_background_command(&mut cmd); + let mut cmd = git_command(); apply_test_git_command_environment(&mut cmd); cmd.arg("-C").arg(workdir); cmd diff --git a/crates/gitcomet-state/src/model.rs b/crates/gitcomet-state/src/model.rs index c20373c8..5ef46de1 100644 --- a/crates/gitcomet-state/src/model.rs +++ b/crates/gitcomet-state/src/model.rs @@ -5,6 +5,7 @@ use gitcomet_core::conflict_session::{ ConflictPayload, ConflictSession, ConflictStageParts, canonicalize_stage_parts, }; use gitcomet_core::domain::*; +use gitcomet_core::process::GitRuntimeState; use gitcomet_core::services::BlameLine; use std::collections::VecDeque; use std::path::PathBuf; @@ -230,6 +231,7 @@ pub struct AppState { pub notifications: Vec, pub banner_error: Option, pub auth_prompt: Option, + pub git_runtime: GitRuntimeState, } #[derive(Clone, Debug, Eq, PartialEq)] diff --git a/crates/gitcomet-state/src/msg/message.rs b/crates/gitcomet-state/src/msg/message.rs index 0d184f6e..fb6754e0 100644 --- a/crates/gitcomet-state/src/msg/message.rs +++ b/crates/gitcomet-state/src/msg/message.rs @@ -2,6 +2,7 @@ use crate::model::{ConflictFileLoadMode, RepoId}; use gitcomet_core::conflict_session::ConflictSession; use gitcomet_core::domain::*; use gitcomet_core::error::Error; +use gitcomet_core::process::GitRuntimeState; use gitcomet_core::services::GitRepository; use gitcomet_core::services::{CommandOutput, ConflictSide, PullMode, RemoteUrlKind, ResetMode}; use std::path::PathBuf; @@ -88,6 +89,7 @@ pub enum Msg { secret: String, }, CancelAuthPrompt, + SetGitRuntimeState(GitRuntimeState), SetActiveRepo { repo_id: RepoId, }, diff --git a/crates/gitcomet-state/src/session.rs b/crates/gitcomet-state/src/session.rs index d5c1dc70..d582a6d2 100644 --- a/crates/gitcomet-state/src/session.rs +++ b/crates/gitcomet-state/src/session.rs @@ -34,6 +34,7 @@ pub struct UiSession { pub history_show_author: Option, pub history_show_date: Option, pub history_show_sha: Option, + pub git_executable_path: Option, } #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] @@ -92,6 +93,7 @@ struct UiSessionFileV2 { history_show_author: Option, history_show_date: Option, history_show_sha: Option, + git_executable_path: Option, repo_history_scopes: Option>, repo_fetch_prune_deleted_remote_tracking_branches: Option>, } @@ -147,6 +149,10 @@ pub fn load_from_path(path: &Path) -> UiSession { history_show_author: file.history_show_author, history_show_date: file.history_show_date, history_show_sha: file.history_show_sha, + git_executable_path: file + .git_executable_path + .as_deref() + .map(path_from_storage_key), } } @@ -349,6 +355,7 @@ pub struct UiSettings { pub history_show_author: Option, pub history_show_date: Option, pub history_show_sha: Option, + pub git_executable_path: Option>, } pub fn persist_ui_settings(settings: UiSettings) -> io::Result<()> { @@ -414,6 +421,9 @@ pub fn persist_ui_settings_to_path(settings: UiSettings, path: &Path) -> io::Res if let Some(value) = settings.history_show_sha { file.history_show_sha = Some(value); } + if let Some(path) = settings.git_executable_path { + file.git_executable_path = path.map(|path| path_storage_key(&path)); + } persist_to_path(path, &file) } @@ -1537,6 +1547,7 @@ mod tests { history_show_author: None, history_show_date: None, history_show_sha: None, + git_executable_path: None, }, &path, ) @@ -1593,6 +1604,7 @@ mod tests { history_show_author: None, history_show_date: None, history_show_sha: None, + git_executable_path: None, }, &path, ) @@ -1646,6 +1658,7 @@ mod tests { history_show_author: None, history_show_date: None, history_show_sha: None, + git_executable_path: None, }, &path, ) @@ -1699,6 +1712,7 @@ mod tests { history_show_author: None, history_show_date: None, history_show_sha: None, + git_executable_path: None, }, &path, ) @@ -1752,6 +1766,7 @@ mod tests { history_show_author: None, history_show_date: None, history_show_sha: None, + git_executable_path: None, }, &path, ) @@ -1808,6 +1823,7 @@ mod tests { history_show_author: None, history_show_date: None, history_show_sha: None, + git_executable_path: None, }, &path, ) @@ -1862,6 +1878,7 @@ mod tests { history_show_author: None, history_show_date: None, history_show_sha: None, + git_executable_path: None, }, &path, ) @@ -1870,6 +1887,61 @@ mod tests { let loaded = load_from_path(&path); assert_eq!(loaded.theme_mode.as_deref(), Some("dark")); } + + #[test] + fn persist_ui_settings_round_trips_empty_custom_git_executable_path() { + let dir = env::temp_dir().join(format!( + "gitcomet-ui-settings-test-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::SystemTime::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + )); + let _ = fs::create_dir_all(&dir); + let path = dir.join("session.json"); + + persist_to_path( + &path, + &UiSessionFileV2 { + version: CURRENT_SESSION_FILE_VERSION, + open_repos: Vec::new(), + active_repo: None, + ..UiSessionFileV2::default() + }, + ) + .expect("seed session file"); + + persist_ui_settings_to_path( + UiSettings { + window_width: None, + window_height: None, + sidebar_width: None, + details_width: None, + repo_sidebar_collapsed_items: None, + theme_mode: None, + ui_font_family: None, + editor_font_family: None, + use_font_ligatures: None, + date_time_format: None, + timezone: None, + show_timezone: None, + change_tracking_view: None, + change_tracking_height: None, + untracked_height: None, + history_show_author: None, + history_show_date: None, + history_show_sha: None, + git_executable_path: Some(Some(PathBuf::new())), + }, + &path, + ) + .expect("persist ui settings"); + + let loaded = load_from_path(&path); + assert_eq!(loaded.git_executable_path, Some(PathBuf::new())); + } + #[test] fn persist_repo_history_scope_round_trips() { let dir = env::temp_dir().join(format!( diff --git a/crates/gitcomet-state/src/store/effects.rs b/crates/gitcomet-state/src/store/effects.rs index f0c8a74e..9697188e 100644 --- a/crates/gitcomet-state/src/store/effects.rs +++ b/crates/gitcomet-state/src/store/effects.rs @@ -6,9 +6,11 @@ mod repo_load; mod util; use crate::model::AppState; -use crate::msg::{Effect, Msg}; +use crate::msg::{Effect, Msg, RepoCommandKind}; use crate::session; use gitcomet_core::domain::DiffTarget; +use gitcomet_core::error::{Error, ErrorKind}; +use gitcomet_core::process::GitRuntimeState; use gitcomet_core::services::{GitBackend, GitRepository}; use rustc_hash::FxHashMap as HashMap; use std::sync::{Arc, RwLock, mpsc}; @@ -40,6 +42,657 @@ fn selected_conflict_file_path( .and_then(|repo| repo.conflict_state.conflict_file_path.clone()) } +fn effect_requires_available_git(effect: &Effect) -> bool { + !matches!(effect, Effect::PersistSession { .. }) +} + +fn git_unavailable_error(runtime: &GitRuntimeState) -> Error { + Error::new(ErrorKind::Backend( + runtime + .unavailable_detail() + .unwrap_or("Git executable is unavailable.") + .to_string(), + )) +} + +fn send_unavailable_git_effect_result( + thread_state: &Arc>>, + msg_tx: &mpsc::Sender, + effect: Effect, + runtime: &GitRuntimeState, +) { + let send = |msg| util::send_or_log(msg_tx, msg); + + match effect { + Effect::PersistSession { .. } => {} + Effect::OpenRepo { repo_id, path } => { + send(Msg::Internal(crate::msg::InternalMsg::RepoOpenedErr { + repo_id, + spec: gitcomet_core::domain::RepoSpec { workdir: path }, + error: git_unavailable_error(runtime), + })) + } + Effect::LoadBranches { repo_id } => { + send(Msg::Internal(crate::msg::InternalMsg::BranchesLoaded { + repo_id, + result: Err(git_unavailable_error(runtime)), + })) + } + Effect::LoadRemotes { repo_id } => { + send(Msg::Internal(crate::msg::InternalMsg::RemotesLoaded { + repo_id, + result: Err(git_unavailable_error(runtime)), + })) + } + Effect::LoadRemoteBranches { repo_id } => send(Msg::Internal( + crate::msg::InternalMsg::RemoteBranchesLoaded { + repo_id, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::LoadStatus { repo_id } => { + send(Msg::Internal(crate::msg::InternalMsg::StatusLoaded { + repo_id, + result: Err(git_unavailable_error(runtime)), + })) + } + Effect::LoadHeadBranch { repo_id } => { + send(Msg::Internal(crate::msg::InternalMsg::HeadBranchLoaded { + repo_id, + result: Err(git_unavailable_error(runtime)), + })) + } + Effect::LoadUpstreamDivergence { repo_id } => send(Msg::Internal( + crate::msg::InternalMsg::UpstreamDivergenceLoaded { + repo_id, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::LoadLog { + repo_id, + scope, + cursor, + .. + } => send(Msg::Internal(crate::msg::InternalMsg::LogLoaded { + repo_id, + scope, + cursor, + result: Err(git_unavailable_error(runtime)), + })), + Effect::LoadTags { repo_id } => send(Msg::Internal(crate::msg::InternalMsg::TagsLoaded { + repo_id, + result: Err(git_unavailable_error(runtime)), + })), + Effect::LoadRemoteTags { repo_id } => { + send(Msg::Internal(crate::msg::InternalMsg::RemoteTagsLoaded { + repo_id, + result: Err(git_unavailable_error(runtime)), + })) + } + Effect::LoadStashes { repo_id, .. } => { + send(Msg::Internal(crate::msg::InternalMsg::StashesLoaded { + repo_id, + result: Err(git_unavailable_error(runtime)), + })) + } + Effect::LoadConflictFile { repo_id, path, .. } => { + send(Msg::Internal(crate::msg::InternalMsg::ConflictFileLoaded { + repo_id, + path, + result: Box::new(Err(git_unavailable_error(runtime))), + conflict_session: None, + })) + } + Effect::LoadReflog { repo_id, .. } => { + send(Msg::Internal(crate::msg::InternalMsg::ReflogLoaded { + repo_id, + result: Err(git_unavailable_error(runtime)), + })) + } + Effect::SaveWorktreeFile { + repo_id, + path, + stage, + .. + } => send(Msg::Internal( + crate::msg::InternalMsg::RepoCommandFinished { + repo_id, + command: RepoCommandKind::SaveWorktreeFile { path, stage }, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::LoadFileHistory { repo_id, path, .. } => { + send(Msg::Internal(crate::msg::InternalMsg::FileHistoryLoaded { + repo_id, + path, + result: Err(git_unavailable_error(runtime)), + })) + } + Effect::LoadBlame { repo_id, path, rev } => { + send(Msg::Internal(crate::msg::InternalMsg::BlameLoaded { + repo_id, + path, + rev, + result: Err(git_unavailable_error(runtime)), + })) + } + Effect::LoadWorktrees { repo_id } => { + send(Msg::Internal(crate::msg::InternalMsg::WorktreesLoaded { + repo_id, + result: Err(git_unavailable_error(runtime)), + })) + } + Effect::LoadSubmodules { repo_id } => { + send(Msg::Internal(crate::msg::InternalMsg::SubmodulesLoaded { + repo_id, + result: Err(git_unavailable_error(runtime)), + })) + } + Effect::LoadRebaseAndMergeState { repo_id } => { + send(Msg::Internal(crate::msg::InternalMsg::RebaseStateLoaded { + repo_id, + result: Err(git_unavailable_error(runtime)), + })); + send(Msg::Internal( + crate::msg::InternalMsg::MergeCommitMessageLoaded { + repo_id, + result: Err(git_unavailable_error(runtime)), + }, + )); + } + Effect::LoadRebaseState { repo_id } => { + send(Msg::Internal(crate::msg::InternalMsg::RebaseStateLoaded { + repo_id, + result: Err(git_unavailable_error(runtime)), + })) + } + Effect::LoadMergeCommitMessage { repo_id } => send(Msg::Internal( + crate::msg::InternalMsg::MergeCommitMessageLoaded { + repo_id, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::LoadCommitDetails { repo_id, commit_id } => send(Msg::Internal( + crate::msg::InternalMsg::CommitDetailsLoaded { + repo_id, + commit_id, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::LoadDiff { repo_id, target } => { + send(Msg::Internal(crate::msg::InternalMsg::DiffLoaded { + repo_id, + target, + result: Err(git_unavailable_error(runtime)), + })) + } + Effect::LoadDiffFile { repo_id, target } => { + send(Msg::Internal(crate::msg::InternalMsg::DiffFileLoaded { + repo_id, + target, + result: Err(git_unavailable_error(runtime)), + })) + } + Effect::LoadDiffPreviewTextFile { + repo_id, + target, + side, + } => send(Msg::Internal( + crate::msg::InternalMsg::DiffPreviewTextFileLoaded { + repo_id, + target, + side, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::LoadDiffFileImage { repo_id, target } => send(Msg::Internal( + crate::msg::InternalMsg::DiffFileImageLoaded { + repo_id, + target, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::LoadSelectedDiff { + repo_id, + load_patch_diff, + load_file_text, + preview_text_side, + load_file_image, + } => { + let Some(target) = selected_diff_target(thread_state, repo_id) else { + return; + }; + if load_file_image { + send(Msg::Internal( + crate::msg::InternalMsg::DiffFileImageLoaded { + repo_id, + target: target.clone(), + result: Err(git_unavailable_error(runtime)), + }, + )); + } + if let Some(side) = preview_text_side { + send(Msg::Internal( + crate::msg::InternalMsg::DiffPreviewTextFileLoaded { + repo_id, + target: target.clone(), + side, + result: Err(git_unavailable_error(runtime)), + }, + )); + } + if load_file_text { + send(Msg::Internal(crate::msg::InternalMsg::DiffFileLoaded { + repo_id, + target: target.clone(), + result: Err(git_unavailable_error(runtime)), + })); + } + if load_patch_diff { + send(Msg::Internal(crate::msg::InternalMsg::DiffLoaded { + repo_id, + target, + result: Err(git_unavailable_error(runtime)), + })); + } + } + Effect::LoadSelectedConflictFile { repo_id, .. } => { + let Some(path) = selected_conflict_file_path(thread_state, repo_id) else { + return; + }; + send(Msg::Internal(crate::msg::InternalMsg::ConflictFileLoaded { + repo_id, + path, + result: Box::new(Err(git_unavailable_error(runtime))), + conflict_session: None, + })); + } + Effect::CheckoutBranch { repo_id, .. } + | Effect::CheckoutRemoteBranch { repo_id, .. } + | Effect::CheckoutCommit { repo_id, .. } + | Effect::CherryPickCommit { repo_id, .. } + | Effect::RevertCommit { repo_id, .. } + | Effect::CreateBranch { repo_id, .. } + | Effect::CreateBranchAndCheckout { repo_id, .. } + | Effect::DeleteBranch { repo_id, .. } + | Effect::ForceDeleteBranch { repo_id, .. } + | Effect::StagePath { repo_id, .. } + | Effect::StagePaths { repo_id, .. } + | Effect::UnstagePath { repo_id, .. } + | Effect::UnstagePaths { repo_id, .. } + | Effect::DiscardWorktreeChangesPath { repo_id, .. } + | Effect::DiscardWorktreeChangesPaths { repo_id, .. } + | Effect::Stash { repo_id, .. } + | Effect::ApplyStash { repo_id, .. } + | Effect::PopStash { repo_id, .. } + | Effect::DropStash { repo_id, .. } => { + send(Msg::Internal(crate::msg::InternalMsg::RepoActionFinished { + repo_id, + result: Err(git_unavailable_error(runtime)), + })) + } + Effect::CloneRepo { url, dest, .. } => { + send(Msg::Internal(crate::msg::InternalMsg::CloneRepoFinished { + url, + dest, + result: Err(git_unavailable_error(runtime)), + })) + } + Effect::ExportPatch { + repo_id, + commit_id, + dest, + } => send(Msg::Internal( + crate::msg::InternalMsg::RepoCommandFinished { + repo_id, + command: RepoCommandKind::ExportPatch { commit_id, dest }, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::ApplyPatch { repo_id, patch } => send(Msg::Internal( + crate::msg::InternalMsg::RepoCommandFinished { + repo_id, + command: RepoCommandKind::ApplyPatch { patch }, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::AddWorktree { + repo_id, + path, + reference, + } => send(Msg::Internal( + crate::msg::InternalMsg::RepoCommandFinished { + repo_id, + command: RepoCommandKind::AddWorktree { path, reference }, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::RemoveWorktree { repo_id, path } => send(Msg::Internal( + crate::msg::InternalMsg::RepoCommandFinished { + repo_id, + command: RepoCommandKind::RemoveWorktree { path }, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::ForceRemoveWorktree { repo_id, path } => send(Msg::Internal( + crate::msg::InternalMsg::RepoCommandFinished { + repo_id, + command: RepoCommandKind::ForceRemoveWorktree { path }, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::AddSubmodule { + repo_id, url, path, .. + } => send(Msg::Internal( + crate::msg::InternalMsg::RepoCommandFinished { + repo_id, + command: RepoCommandKind::AddSubmodule { url, path }, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::UpdateSubmodules { repo_id, .. } => send(Msg::Internal( + crate::msg::InternalMsg::RepoCommandFinished { + repo_id, + command: RepoCommandKind::UpdateSubmodules, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::RemoveSubmodule { repo_id, path } => send(Msg::Internal( + crate::msg::InternalMsg::RepoCommandFinished { + repo_id, + command: RepoCommandKind::RemoveSubmodule { path }, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::StageHunk { repo_id, .. } => send(Msg::Internal( + crate::msg::InternalMsg::RepoCommandFinished { + repo_id, + command: RepoCommandKind::StageHunk, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::UnstageHunk { repo_id, .. } => send(Msg::Internal( + crate::msg::InternalMsg::RepoCommandFinished { + repo_id, + command: RepoCommandKind::UnstageHunk, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::ApplyWorktreePatch { + repo_id, reverse, .. + } => send(Msg::Internal( + crate::msg::InternalMsg::RepoCommandFinished { + repo_id, + command: RepoCommandKind::ApplyWorktreePatch { reverse }, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::Commit { repo_id, .. } => { + send(Msg::Internal(crate::msg::InternalMsg::CommitFinished { + repo_id, + result: Err(git_unavailable_error(runtime)), + })) + } + Effect::CommitAmend { repo_id, .. } => send(Msg::Internal( + crate::msg::InternalMsg::CommitAmendFinished { + repo_id, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::FetchAll { + repo_id, prune: _, .. + } => send(Msg::Internal( + crate::msg::InternalMsg::RepoCommandFinished { + repo_id, + command: RepoCommandKind::FetchAll, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::PruneMergedBranches { repo_id } => send(Msg::Internal( + crate::msg::InternalMsg::RepoCommandFinished { + repo_id, + command: RepoCommandKind::PruneMergedBranches, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::PruneLocalTags { repo_id } => send(Msg::Internal( + crate::msg::InternalMsg::RepoCommandFinished { + repo_id, + command: RepoCommandKind::PruneLocalTags, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::Pull { repo_id, mode, .. } => send(Msg::Internal( + crate::msg::InternalMsg::RepoCommandFinished { + repo_id, + command: RepoCommandKind::Pull { mode }, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::PullBranch { + repo_id, + remote, + branch, + .. + } => send(Msg::Internal( + crate::msg::InternalMsg::RepoCommandFinished { + repo_id, + command: RepoCommandKind::PullBranch { remote, branch }, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::MergeRef { repo_id, reference } => send(Msg::Internal( + crate::msg::InternalMsg::RepoCommandFinished { + repo_id, + command: RepoCommandKind::MergeRef { reference }, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::SquashRef { repo_id, reference } => send(Msg::Internal( + crate::msg::InternalMsg::RepoCommandFinished { + repo_id, + command: RepoCommandKind::SquashRef { reference }, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::Push { repo_id, .. } => send(Msg::Internal( + crate::msg::InternalMsg::RepoCommandFinished { + repo_id, + command: RepoCommandKind::Push, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::ForcePush { repo_id, .. } => send(Msg::Internal( + crate::msg::InternalMsg::RepoCommandFinished { + repo_id, + command: RepoCommandKind::ForcePush, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::PushSetUpstream { + repo_id, + remote, + branch, + .. + } => send(Msg::Internal( + crate::msg::InternalMsg::RepoCommandFinished { + repo_id, + command: RepoCommandKind::PushSetUpstream { remote, branch }, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::SetUpstreamBranch { + repo_id, + branch, + upstream, + } => send(Msg::Internal( + crate::msg::InternalMsg::RepoCommandFinished { + repo_id, + command: RepoCommandKind::SetUpstreamBranch { branch, upstream }, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::UnsetUpstreamBranch { repo_id, branch } => send(Msg::Internal( + crate::msg::InternalMsg::RepoCommandFinished { + repo_id, + command: RepoCommandKind::UnsetUpstreamBranch { branch }, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::DeleteRemoteBranch { + repo_id, + remote, + branch, + .. + } => send(Msg::Internal( + crate::msg::InternalMsg::RepoCommandFinished { + repo_id, + command: RepoCommandKind::DeleteRemoteBranch { remote, branch }, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::Reset { + repo_id, + target, + mode, + } => send(Msg::Internal( + crate::msg::InternalMsg::RepoCommandFinished { + repo_id, + command: RepoCommandKind::Reset { mode, target }, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::Rebase { repo_id, onto } => send(Msg::Internal( + crate::msg::InternalMsg::RepoCommandFinished { + repo_id, + command: RepoCommandKind::Rebase { onto }, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::RebaseContinue { repo_id } => send(Msg::Internal( + crate::msg::InternalMsg::RepoCommandFinished { + repo_id, + command: RepoCommandKind::RebaseContinue, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::RebaseAbort { repo_id } => send(Msg::Internal( + crate::msg::InternalMsg::RepoCommandFinished { + repo_id, + command: RepoCommandKind::RebaseAbort, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::MergeAbort { repo_id } => send(Msg::Internal( + crate::msg::InternalMsg::RepoCommandFinished { + repo_id, + command: RepoCommandKind::MergeAbort, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::CreateTag { + repo_id, + name, + target, + } => send(Msg::Internal( + crate::msg::InternalMsg::RepoCommandFinished { + repo_id, + command: RepoCommandKind::CreateTag { name, target }, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::DeleteTag { repo_id, name } => send(Msg::Internal( + crate::msg::InternalMsg::RepoCommandFinished { + repo_id, + command: RepoCommandKind::DeleteTag { name }, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::PushTag { + repo_id, + remote, + name, + .. + } => send(Msg::Internal( + crate::msg::InternalMsg::RepoCommandFinished { + repo_id, + command: RepoCommandKind::PushTag { remote, name }, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::DeleteRemoteTag { + repo_id, + remote, + name, + .. + } => send(Msg::Internal( + crate::msg::InternalMsg::RepoCommandFinished { + repo_id, + command: RepoCommandKind::DeleteRemoteTag { remote, name }, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::AddRemote { repo_id, name, url } => send(Msg::Internal( + crate::msg::InternalMsg::RepoCommandFinished { + repo_id, + command: RepoCommandKind::AddRemote { name, url }, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::RemoveRemote { repo_id, name } => send(Msg::Internal( + crate::msg::InternalMsg::RepoCommandFinished { + repo_id, + command: RepoCommandKind::RemoveRemote { name }, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::SetRemoteUrl { + repo_id, + name, + url, + kind, + } => send(Msg::Internal( + crate::msg::InternalMsg::RepoCommandFinished { + repo_id, + command: RepoCommandKind::SetRemoteUrl { name, url, kind }, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::CheckoutConflictSide { + repo_id, + path, + side, + } => send(Msg::Internal( + crate::msg::InternalMsg::RepoCommandFinished { + repo_id, + command: RepoCommandKind::CheckoutConflict { path, side }, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::AcceptConflictDeletion { repo_id, path } => send(Msg::Internal( + crate::msg::InternalMsg::RepoCommandFinished { + repo_id, + command: RepoCommandKind::AcceptConflictDeletion { path }, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::CheckoutConflictBase { repo_id, path } => send(Msg::Internal( + crate::msg::InternalMsg::RepoCommandFinished { + repo_id, + command: RepoCommandKind::CheckoutConflictBase { path }, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::LaunchMergetool { repo_id, path } => send(Msg::Internal( + crate::msg::InternalMsg::RepoCommandFinished { + repo_id, + command: RepoCommandKind::LaunchMergetool { path }, + result: Err(git_unavailable_error(runtime)), + }, + )), + } +} + pub(super) fn schedule_effect( executor: &TaskExecutor, session_persist_executor: &TaskExecutor, @@ -49,6 +702,17 @@ pub(super) fn schedule_effect( msg_tx: mpsc::Sender, effect: Effect, ) { + if effect_requires_available_git(&effect) { + let runtime = { + let state = thread_state.read().unwrap_or_else(|e| e.into_inner()); + state.git_runtime.clone() + }; + if !runtime.is_available() { + send_unavailable_git_effect_result(thread_state, &msg_tx, effect, &runtime); + return; + } + } + match effect { Effect::PersistSession { repo_id, action } => { let state_snapshot = { diff --git a/crates/gitcomet-state/src/store/effects/clone.rs b/crates/gitcomet-state/src/store/effects/clone.rs index 4d78af80..3d69de70 100644 --- a/crates/gitcomet-state/src/store/effects/clone.rs +++ b/crates/gitcomet-state/src/store/effects/clone.rs @@ -9,7 +9,7 @@ use gitcomet_core::auth::{ take_staged_git_auth, }; use gitcomet_core::error::{Error, ErrorKind}; -use gitcomet_core::process::configure_background_command; +use gitcomet_core::process::git_command; use gitcomet_core::services::CommandOutput; use std::fs; use std::io::{BufRead as _, BufReader, Read as _}; @@ -446,8 +446,7 @@ pub(super) fn schedule_clone_repo( return; } - let mut cmd = Command::new("git"); - configure_background_command(&mut cmd); + let mut cmd = git_command(); cmd.arg("-c") .arg("color.ui=false") .arg("clone") diff --git a/crates/gitcomet-state/src/store/mod.rs b/crates/gitcomet-state/src/store/mod.rs index 8ea742ed..64ca78d4 100644 --- a/crates/gitcomet-state/src/store/mod.rs +++ b/crates/gitcomet-state/src/store/mod.rs @@ -1,6 +1,7 @@ use crate::model::{AppState, RepoId}; use crate::msg::{Msg, StoreEvent}; use gitcomet_core::path_utils::canonicalize_or_original; +use gitcomet_core::process::refresh_git_runtime; use gitcomet_core::services::{GitBackend, GitRepository}; use rustc_hash::FxHashMap as HashMap; use std::path::PathBuf; @@ -445,6 +446,22 @@ impl AppStore { } pub fn dispatch(&self, msg: Msg) { + if reducer::msg_requires_available_git(&msg) { + let runtime = refresh_git_runtime(); + let current_runtime = { + let state = self.state.read().unwrap_or_else(|e| e.into_inner()); + state.git_runtime.clone() + }; + if current_runtime != runtime { + send_or_log( + &self.msg_tx, + Msg::SetGitRuntimeState(runtime), + SendFailureKind::StoreDispatch, + "AppStore::dispatch/set-git-runtime-state", + ); + } + } + send_or_log( &self.msg_tx, msg, diff --git a/crates/gitcomet-state/src/store/reducer.rs b/crates/gitcomet-state/src/store/reducer.rs index 79d295e7..a3943194 100644 --- a/crates/gitcomet-state/src/store/reducer.rs +++ b/crates/gitcomet-state/src/store/reducer.rs @@ -59,6 +59,92 @@ fn begin_commit_action(state: &mut AppState, repo_id: RepoId) { } } +pub(crate) fn msg_requires_available_git(msg: &Msg) -> bool { + matches!( + msg, + Msg::OpenRepo(_) + | Msg::RestoreSession { .. } + | Msg::ReloadRepo { .. } + | Msg::RepoExternallyChanged { .. } + | Msg::SetHistoryScope { .. } + | Msg::LoadMoreHistory { .. } + | Msg::SelectCommit { .. } + | Msg::SelectDiff { .. } + | Msg::SelectConflictDiff { .. } + | Msg::LoadStashes { .. } + | Msg::LoadConflictFile { .. } + | Msg::LoadReflog { .. } + | Msg::LoadFileHistory { .. } + | Msg::LoadBlame { .. } + | Msg::LoadWorktrees { .. } + | Msg::LoadSubmodules { .. } + | Msg::RefreshBranches { .. } + | Msg::StageHunk { .. } + | Msg::UnstageHunk { .. } + | Msg::ApplyWorktreePatch { .. } + | Msg::CheckoutBranch { .. } + | Msg::CheckoutRemoteBranch { .. } + | Msg::CheckoutCommit { .. } + | Msg::CherryPickCommit { .. } + | Msg::RevertCommit { .. } + | Msg::CreateBranch { .. } + | Msg::CreateBranchAndCheckout { .. } + | Msg::DeleteBranch { .. } + | Msg::ForceDeleteBranch { .. } + | Msg::CloneRepo { .. } + | Msg::ExportPatch { .. } + | Msg::ApplyPatch { .. } + | Msg::AddWorktree { .. } + | Msg::RemoveWorktree { .. } + | Msg::ForceRemoveWorktree { .. } + | Msg::AddSubmodule { .. } + | Msg::UpdateSubmodules { .. } + | Msg::RemoveSubmodule { .. } + | Msg::StagePath { .. } + | Msg::StagePaths { .. } + | Msg::UnstagePath { .. } + | Msg::UnstagePaths { .. } + | Msg::DiscardWorktreeChangesPath { .. } + | Msg::DiscardWorktreeChangesPaths { .. } + | Msg::SaveWorktreeFile { .. } + | Msg::Commit { .. } + | Msg::CommitAmend { .. } + | Msg::FetchAll { .. } + | Msg::PruneMergedBranches { .. } + | Msg::PruneLocalTags { .. } + | Msg::Pull { .. } + | Msg::PullBranch { .. } + | Msg::MergeRef { .. } + | Msg::SquashRef { .. } + | Msg::Push { .. } + | Msg::ForcePush { .. } + | Msg::PushSetUpstream { .. } + | Msg::SetUpstreamBranch { .. } + | Msg::UnsetUpstreamBranch { .. } + | Msg::DeleteRemoteBranch { .. } + | Msg::Reset { .. } + | Msg::Rebase { .. } + | Msg::RebaseContinue { .. } + | Msg::RebaseAbort { .. } + | Msg::MergeAbort { .. } + | Msg::CreateTag { .. } + | Msg::DeleteTag { .. } + | Msg::PushTag { .. } + | Msg::DeleteRemoteTag { .. } + | Msg::AddRemote { .. } + | Msg::RemoveRemote { .. } + | Msg::SetRemoteUrl { .. } + | Msg::CheckoutConflictSide { .. } + | Msg::AcceptConflictDeletion { .. } + | Msg::CheckoutConflictBase { .. } + | Msg::LaunchMergetool { .. } + | Msg::Stash { .. } + | Msg::ApplyStash { .. } + | Msg::PopStash { .. } + | Msg::DropStash { .. } + ) +} + #[cfg(test)] pub(super) fn push_diagnostic( repo_state: &mut crate::model::RepoState, @@ -426,6 +512,10 @@ pub(super) fn reduce( state: &mut AppState, msg: Msg, ) -> Vec { + if msg_requires_available_git(&msg) && !state.git_runtime.is_available() { + return Vec::new(); + } + match msg { Msg::OpenRepo(path) => repo_management::open_repo(id_alloc, state, path), Msg::RestoreSession { @@ -458,6 +548,10 @@ pub(super) fn reduce( util::clear_staged_git_auth_env(); Vec::new() } + Msg::SetGitRuntimeState(runtime) => { + state.git_runtime = runtime; + Vec::new() + } Msg::SetActiveRepo { repo_id } => repo_management::set_active_repo(state, repo_id), Msg::ReorderRepoTabs { repo_id, diff --git a/crates/gitcomet-state/src/store/tests/effects.rs b/crates/gitcomet-state/src/store/tests/effects.rs index 45090f84..09f71d51 100644 --- a/crates/gitcomet-state/src/store/tests/effects.rs +++ b/crates/gitcomet-state/src/store/tests/effects.rs @@ -43,6 +43,66 @@ fn schedule_effect_for_test( ); } +#[test] +fn unavailable_git_effect_emits_synthetic_repo_command_error() { + struct Backend; + impl GitBackend for Backend { + fn open(&self, _path: &Path) -> std::result::Result, Error> { + Err(Error::new(ErrorKind::Unsupported("test backend"))) + } + } + + let executor = super::executor::TaskExecutor::new(1); + let backend: Arc = Arc::new(Backend); + let repos: HashMap> = HashMap::default(); + let (msg_tx, msg_rx) = std::sync::mpsc::channel::(); + + let state = AppState { + git_runtime: gitcomet_core::process::GitRuntimeState { + preference: gitcomet_core::process::GitExecutablePreference::Custom(PathBuf::new()), + availability: gitcomet_core::process::GitExecutableAvailability::Unavailable { + detail: "Custom Git executable is not configured. Choose an executable or switch back to System PATH.".to_string(), + }, + }, + ..AppState::default() + }; + + schedule_effect_with_state_for_test( + &executor, + &executor, + &backend, + &repos, + state, + msg_tx, + Effect::FetchAll { + repo_id: RepoId(7), + prune: true, + auth: None, + }, + ); + + let msg = msg_rx + .recv_timeout(Duration::from_secs(1)) + .expect("expected synthetic unavailable-git message"); + match msg { + Msg::Internal(crate::msg::InternalMsg::RepoCommandFinished { + repo_id, + command, + result, + }) => { + assert_eq!(repo_id, RepoId(7)); + assert_eq!(command, RepoCommandKind::FetchAll); + let err = result.expect_err("expected unavailable-git failure"); + assert!( + err.to_string() + .contains("Custom Git executable is not configured"), + "unexpected error: {err}" + ); + } + other => panic!("unexpected message: {other:?}"), + } +} + #[test] fn clone_repo_effect_clones_local_repo_and_emits_finished_and_open_repo() { if !super::require_git_shell_for_store_tests() { diff --git a/crates/gitcomet-state/tests/session_integration.rs b/crates/gitcomet-state/tests/session_integration.rs index b30d7477..e0a7b3a8 100644 --- a/crates/gitcomet-state/tests/session_integration.rs +++ b/crates/gitcomet-state/tests/session_integration.rs @@ -304,6 +304,7 @@ fn persist_ui_settings_to_path_updates_optional_fields_and_requires_both_window_ history_show_author: Some(false), history_show_date: Some(true), history_show_sha: Some(false), + git_executable_path: None, }, &session_file, ) diff --git a/crates/gitcomet-ui-gpui/src/app.rs b/crates/gitcomet-ui-gpui/src/app.rs index 9149cc94..b1280ad0 100644 --- a/crates/gitcomet-ui-gpui/src/app.rs +++ b/crates/gitcomet-ui-gpui/src/app.rs @@ -404,6 +404,9 @@ fn install_app_actions(cx: &mut App, backend: Arc) { cx.on_action(move |_: &OpenRepository, cx| { let backend = Arc::clone(&repo_backend); cx.defer(move |cx| { + if active_normal_gitcomet_window_blocks_non_repository_actions(cx) { + return; + } prompt_open_repository(cx, backend); }); }); diff --git a/crates/gitcomet-ui-gpui/src/view/mod.rs b/crates/gitcomet-ui-gpui/src/view/mod.rs index dbda4bd8..1c9b9d4d 100644 --- a/crates/gitcomet-ui-gpui/src/view/mod.rs +++ b/crates/gitcomet-ui-gpui/src/view/mod.rs @@ -7,6 +7,7 @@ use gitcomet_core::domain::{ UpstreamDivergence, }; use gitcomet_core::file_diff::FileDiffRow; +use gitcomet_core::process::refresh_git_runtime; use gitcomet_core::services::{PullMode, RemoteUrlKind, ResetMode}; use gitcomet_state::model::{ AppNotificationKind, AppState, AuthPromptKind, CloneOpState, CloneOpStatus, DiagnosticKind, @@ -452,32 +453,52 @@ impl GitCometView { let history_show_author = ui_session.history_show_author.unwrap_or(true); let history_show_date = ui_session.history_show_date.unwrap_or(true); let history_show_sha = ui_session.history_show_sha.unwrap_or(false); + let saved_open_repos = ui_session.open_repos.clone(); + let saved_active_repo = ui_session.active_repo.clone(); let mut startup_repo_bootstrap_pending = false; + let mut deferred_repo_bootstrap = None; // Only auto-restore/open on startup if the store hasn't already been preloaded. // This avoids re-opening repos (and changing RepoIds) when the UI is attached to an // already-initialized store (notably in `gpui::test` setup). - let store_preloaded = !store.snapshot().repos.is_empty(); + let initial_store_state = store.snapshot(); + let store_preloaded = !initial_store_state.repos.is_empty(); + let git_runtime_available = initial_store_state.git_runtime.is_available(); let should_auto_restore = !crate::startup_probe::disable_auto_restore() && view_mode != GitCometViewMode::FocusedMergetool && cfg!(not(test)) && !store_preloaded; if should_auto_restore { - if !ui_session.open_repos.is_empty() { - store.dispatch(Msg::RestoreSession { - open_repos: ui_session.open_repos, - active_repo: ui_session.active_repo, - }); - startup_repo_bootstrap_pending = true; + if !saved_open_repos.is_empty() { + if git_runtime_available { + store.dispatch(Msg::RestoreSession { + open_repos: saved_open_repos, + active_repo: saved_active_repo, + }); + startup_repo_bootstrap_pending = true; + } else { + deferred_repo_bootstrap = Some(DeferredRepoBootstrap::RestoreSession { + open_repos: saved_open_repos, + active_repo: saved_active_repo, + }); + } } } else if store_preloaded { if let Some(path) = initial_path.as_ref() { - store.dispatch(Msg::OpenRepo(path.clone())); + if git_runtime_available { + store.dispatch(Msg::OpenRepo(path.clone())); + } else { + deferred_repo_bootstrap = Some(DeferredRepoBootstrap::OpenRepo(path.clone())); + } } } else if let Some(path) = initial_path.as_ref() { - store.dispatch(Msg::OpenRepo(path.clone())); - startup_repo_bootstrap_pending = true; + if git_runtime_available { + store.dispatch(Msg::OpenRepo(path.clone())); + startup_repo_bootstrap_pending = true; + } else { + deferred_repo_bootstrap = Some(DeferredRepoBootstrap::OpenRepo(path.clone())); + } } let initial_state = store.snapshot(); @@ -602,6 +623,14 @@ impl GitCometView { if !window.is_window_active() { return; } + let runtime = refresh_git_runtime(); + if runtime != this.state.git_runtime { + this.store + .dispatch(Msg::SetGitRuntimeState(runtime.clone())); + } + if !runtime.is_available() { + return; + } if let Some(repo) = this.active_repo() && matches!(repo.open, Loadable::Ready(_)) { @@ -720,6 +749,7 @@ impl GitCometView { toast_host, popover_host, focused_mergetool_bootstrap, + deferred_repo_bootstrap, startup_repo_bootstrap_pending, splash_backdrop_image: splash::load_splash_backdrop_image(), last_window_size: size(px(0.0), px(0.0)), @@ -1186,6 +1216,10 @@ impl GitCometView { } fn drive_focused_mergetool_bootstrap(&mut self) { + if !self.state.git_runtime.is_available() { + return; + } + let Some(bootstrap) = self.focused_mergetool_bootstrap.as_ref() else { return; }; @@ -1382,6 +1416,35 @@ impl GitCometView { pub(in crate::view) fn change_tracking_view_for_test(&self) -> ChangeTrackingView { self.change_tracking_view } + + fn resume_after_git_runtime_recovery(&mut self) { + if let Some(bootstrap) = self.deferred_repo_bootstrap.take() { + match bootstrap { + DeferredRepoBootstrap::RestoreSession { + open_repos, + active_repo, + } => { + self.startup_repo_bootstrap_pending = true; + self.store.dispatch(Msg::RestoreSession { + open_repos, + active_repo, + }); + } + DeferredRepoBootstrap::OpenRepo(path) => { + self.startup_repo_bootstrap_pending = true; + self.store.dispatch(Msg::OpenRepo(path)); + } + } + return; + } + + if !self.state.repos.is_empty() { + let repo_ids: Vec<_> = self.state.repos.iter().map(|repo| repo.id).collect(); + for repo_id in repo_ids { + self.store.dispatch(Msg::ReloadRepo { repo_id }); + } + } + } } impl Render for GitCometView { diff --git a/crates/gitcomet-ui-gpui/src/view/mod_helpers.rs b/crates/gitcomet-ui-gpui/src/view/mod_helpers.rs index f39a7f81..2e140688 100644 --- a/crates/gitcomet-ui-gpui/src/view/mod_helpers.rs +++ b/crates/gitcomet-ui-gpui/src/view/mod_helpers.rs @@ -2328,6 +2328,15 @@ pub(super) enum FocusedMergetoolBootstrapAction { Complete, } +#[derive(Clone, Debug, Eq, PartialEq)] +pub(super) enum DeferredRepoBootstrap { + RestoreSession { + open_repos: Vec, + active_repo: Option, + }, + OpenRepo(std::path::PathBuf), +} + pub(super) fn normalize_bootstrap_repo_path(path: std::path::PathBuf) -> std::path::PathBuf { let path = if path.is_relative() { std::env::current_dir() @@ -2574,6 +2583,7 @@ pub struct GitCometView { pub(super) toast_host: Entity, pub(super) popover_host: Entity, pub(super) focused_mergetool_bootstrap: Option, + pub(super) deferred_repo_bootstrap: Option, pub(super) startup_repo_bootstrap_pending: bool, pub(super) splash_backdrop_image: Arc, diff --git a/crates/gitcomet-ui-gpui/src/view/panels/tests/file_diff.rs b/crates/gitcomet-ui-gpui/src/view/panels/tests/file_diff.rs index c1352eb7..816c98a8 100644 --- a/crates/gitcomet-ui-gpui/src/view/panels/tests/file_diff.rs +++ b/crates/gitcomet-ui-gpui/src/view/panels/tests/file_diff.rs @@ -4310,6 +4310,19 @@ fn yaml_file_diff_keeps_consistent_highlighting_for_added_paths_and_keys( Some((styled.text.as_ref(), styled)) } + fn force_file_diff_fallback_mode(pane: &mut MainPaneView) { + pane.file_diff_syntax_generation = pane.file_diff_syntax_generation.wrapping_add(1); + for view_mode in [ + PreparedSyntaxViewMode::FileDiffSplitLeft, + PreparedSyntaxViewMode::FileDiffSplitRight, + ] { + if let Some(key) = pane.file_diff_prepared_syntax_key(view_mode) { + pane.prepared_syntax_documents.remove(&key); + } + } + pane.clear_diff_text_style_caches(); + } + fn quoted_scalar_style( styled: &super::CachedDiffStyledText, text: &str, @@ -4751,10 +4764,54 @@ fn yaml_file_diff_keeps_consistent_highlighting_for_added_paths_and_keys( wait_for_main_pane_condition( cx, &view, - "YAML file-diff fallback cache build before prepared syntax becomes ready", + "YAML file-diff rows ready for fallback highlighting checks", |pane| { pane.file_diff_cache_inflight.is_none() && pane.file_diff_cache_rev == 0 + && pane.file_diff_cache_path == Some(workdir.join(&path)) + && pane.file_diff_cache_language == Some(rows::DiffSyntaxLanguage::Yaml) + && pane + .file_diff_cache_rows + .iter() + .any(|row| row.new_line == Some(36)) + && pane + .file_diff_inline_cache + .iter() + .any(|line| line.new_line == Some(36)) + }, + |pane| { + format!( + "rev={} inflight={:?} cache_path={:?} language={:?} left_doc={:?} right_doc={:?} rows={} inline_rows={}", + pane.file_diff_cache_rev, + pane.file_diff_cache_inflight, + pane.file_diff_cache_path.clone(), + pane.file_diff_cache_language, + pane.file_diff_split_prepared_syntax_document(DiffTextRegion::SplitLeft), + pane.file_diff_split_prepared_syntax_document(DiffTextRegion::SplitRight), + pane.file_diff_cache_rows.len(), + pane.file_diff_inline_cache.len(), + ) + }, + ); + + cx.update(|_window, app| { + view.update(app, |this, cx| { + this.main_pane.update(cx, |pane, cx| { + // Other YAML tests can warm the shared prepared-syntax cache before this + // test runs. Clear the local prepared documents and invalidate any in-flight + // background parse so the next draw deterministically exercises fallback mode. + force_file_diff_fallback_mode(pane); + cx.notify(); + }); + }); + }); + + wait_for_main_pane_condition( + cx, + &view, + "YAML file-diff fallback mode forced for highlight checks", + |pane| { + pane.file_diff_cache_rev == 0 && pane.file_diff_cache_path == Some(workdir.join(&path)) && pane.file_diff_cache_language == Some(rows::DiffSyntaxLanguage::Yaml) && pane diff --git a/crates/gitcomet-ui-gpui/src/view/settings_window.rs b/crates/gitcomet-ui-gpui/src/view/settings_window.rs index b3eeb83e..4a6a430f 100644 --- a/crates/gitcomet-ui-gpui/src/view/settings_window.rs +++ b/crates/gitcomet-ui-gpui/src/view/settings_window.rs @@ -1,5 +1,7 @@ use super::*; -use gitcomet_core::process::configure_background_command; +use gitcomet_core::process::{ + GitExecutablePreference, GitRuntimeState, install_git_executable_path, refresh_git_runtime, +}; use gpui::{Stateful, TitlebarOptions, WindowBounds, WindowDecorations, WindowOptions}; use std::sync::Arc; @@ -50,6 +52,21 @@ enum SettingsView { OpenSourceLicenses, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum GitExecutableMode { + SystemPath, + Custom, +} + +impl GitExecutableMode { + fn from_preference(preference: &GitExecutablePreference) -> Self { + match preference { + GitExecutablePreference::SystemPath => Self::SystemPath, + GitExecutablePreference::Custom(_) => Self::Custom, + } + } +} + #[derive(Clone, Debug)] struct SettingsRuntimeInfo { git: GitRuntimeInfo, @@ -59,6 +76,7 @@ struct SettingsRuntimeInfo { #[derive(Clone, Debug)] struct GitRuntimeInfo { + runtime: GitRuntimeState, version_display: SharedString, compatibility: GitCompatibility, detail: Option, @@ -69,6 +87,7 @@ enum GitCompatibility { Supported, TooOld, Unknown, + Unavailable, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -99,9 +118,13 @@ pub(crate) struct SettingsWindowView { current_view: SettingsView, open_source_licenses_scroll: UniformListScrollHandle, runtime_info: SettingsRuntimeInfo, + git_executable_mode: GitExecutableMode, + git_custom_path_draft: String, + git_executable_input: Entity, expanded_section: Option, hover_resize_edge: Option, title_drag_state: chrome::TitleBarDragState, + _git_executable_input_subscription: gpui::Subscription, _appearance_subscription: gpui::Subscription, } @@ -311,6 +334,15 @@ impl SettingsWindowView { .and_then(ChangeTrackingView::from_key) .unwrap_or_default(); let theme = theme_mode.resolve_theme(window.appearance()); + let runtime_info = SettingsRuntimeInfo::detect(); + let git_executable_mode = + GitExecutableMode::from_preference(&runtime_info.git.runtime.preference); + let git_custom_path_draft = match &runtime_info.git.runtime.preference { + GitExecutablePreference::Custom(path) if !path.as_os_str().is_empty() => { + path.display().to_string() + } + _ => String::new(), + }; let appearance_subscription = { let view = cx.weak_entity(); @@ -331,6 +363,35 @@ impl SettingsWindowView { }) }; + let git_executable_input = cx.new(|cx| { + components::TextInput::new( + components::TextInputOptions { + placeholder: "/path/to/git".into(), + multiline: false, + read_only: false, + chromeless: false, + soft_wrap: false, + }, + window, + cx, + ) + }); + git_executable_input.update(cx, |input, cx| { + input.set_text(git_custom_path_draft.clone(), cx); + }); + let git_executable_input_subscription = + cx.observe(&git_executable_input, |this, input, cx| { + let enter_pressed = input.update(cx, |input, _| input.take_enter_pressed()); + let next = input.read(cx).text().to_string(); + if this.git_custom_path_draft != next { + this.git_custom_path_draft = next; + cx.notify(); + } + if enter_pressed && this.git_executable_mode == GitExecutableMode::Custom { + this.apply_git_executable_settings(cx); + } + }); + Self { theme_mode, theme, @@ -352,10 +413,14 @@ impl SettingsWindowView { change_tracking_view, current_view: SettingsView::Root, open_source_licenses_scroll: UniformListScrollHandle::default(), - runtime_info: SettingsRuntimeInfo::detect(), + runtime_info, + git_executable_mode, + git_custom_path_draft, + git_executable_input, expanded_section: None, hover_resize_edge: None, title_drag_state: chrome::TitleBarDragState::default(), + _git_executable_input_subscription: git_executable_input_subscription, _appearance_subscription: appearance_subscription, } } @@ -389,6 +454,7 @@ impl SettingsWindowView { history_show_author: None, history_show_date: None, history_show_sha: None, + git_executable_path: Some(self.selected_git_executable_path()), }; cx.background_spawn(async move { @@ -439,6 +505,58 @@ impl SettingsWindowView { .detach(); } + fn selected_git_executable_path(&self) -> Option { + match self.git_executable_mode { + GitExecutableMode::SystemPath => None, + GitExecutableMode::Custom => { + let trimmed = self.git_custom_path_draft.trim(); + Some(if trimmed.is_empty() { + std::path::PathBuf::new() + } else { + std::path::PathBuf::from(trimmed) + }) + } + } + } + + fn sync_git_runtime_state(&mut self, runtime: GitRuntimeState, cx: &mut gpui::Context) { + self.git_executable_mode = GitExecutableMode::from_preference(&runtime.preference); + if let GitExecutablePreference::Custom(path) = &runtime.preference { + let next_draft = if path.as_os_str().is_empty() { + String::new() + } else { + path.display().to_string() + }; + if self.git_custom_path_draft != next_draft { + self.git_custom_path_draft = next_draft.clone(); + self.git_executable_input + .update(cx, |input, cx| input.set_text(next_draft, cx)); + } + } + + self.runtime_info = SettingsRuntimeInfo::from_runtime(runtime.clone()); + self.persist_preferences(cx); + self.update_main_windows(cx, move |view, _window, _cx| { + view.store + .dispatch(Msg::SetGitRuntimeState(runtime.clone())); + }); + cx.notify(); + } + + fn apply_git_executable_settings(&mut self, cx: &mut gpui::Context) { + let runtime = install_git_executable_path(self.selected_git_executable_path()); + self.sync_git_runtime_state(runtime, cx); + } + + fn set_git_executable_mode(&mut self, mode: GitExecutableMode, cx: &mut gpui::Context) { + if self.git_executable_mode == mode { + return; + } + + self.git_executable_mode = mode; + self.apply_git_executable_settings(cx); + } + fn font_option_detail(&self, family: &str) -> Option { match family { crate::font_preferences::UI_SYSTEM_FONT_FAMILY => { @@ -1417,6 +1535,9 @@ impl Render for SettingsWindowView { ) }); + self.git_executable_input + .update(cx, |input, cx| input.set_theme(theme, cx)); + let content = match self.current_view { SettingsView::Root => { let theme_row = self @@ -1766,42 +1887,175 @@ impl Render for SettingsWindowView { theme.colors.warning, "Git version unknown".into(), ), + GitCompatibility::Unavailable => ( + "icons/warning.svg", + theme.colors.danger, + "Unavailable".into(), + ), }; - let mut environment_card = self - .card("settings_window_environment", "Environment", theme) - .child( + let system_git_row = self + .option_row( + "settings_window_git_executable_system", + "System PATH", + Some( + "Use the first `git` executable available in the current PATH.".into(), + ), + self.git_executable_mode == GitExecutableMode::SystemPath, + theme, + ) + .on_click(cx.listener(|this, _e: &ClickEvent, _window, cx| { + this.set_git_executable_mode(GitExecutableMode::SystemPath, cx); + })); + + let custom_git_row = self + .option_row( + "settings_window_git_executable_custom", + "Custom executable", + Some( + "Use a specific Git binary, such as a newer standalone installation." + .into(), + ), + self.git_executable_mode == GitExecutableMode::Custom, + theme, + ) + .on_click(cx.listener(|this, _e: &ClickEvent, _window, cx| { + this.set_git_executable_mode(GitExecutableMode::Custom, cx); + })); + + let mut git_executable_card = self + .card("settings_window_git_executable", "Git executable", theme) + .child(system_git_row) + .child(custom_git_row); + + if self.git_executable_mode == GitExecutableMode::Custom { + let browse_button = + components::Button::new("settings_window_git_executable_browse", "Browse") + .style(components::ButtonStyle::Outlined) + .on_click(theme, cx, |_this, _e, window, cx| { + let view = cx.weak_entity(); + let rx = cx.prompt_for_paths(gpui::PathPromptOptions { + files: true, + directories: false, + multiple: false, + prompt: Some("Select Git executable".into()), + }); + + window + .spawn(cx, async move |cx| { + let result = rx.await; + let paths = match result { + Ok(Ok(Some(paths))) => paths, + Ok(Ok(None)) => return, + Ok(Err(_)) | Err(_) => return, + }; + let Some(path) = paths.into_iter().next() else { + return; + }; + let _ = view.update(cx, |this, cx| { + let next = path.display().to_string(); + this.git_custom_path_draft = next.clone(); + this.git_executable_input + .update(cx, |input, cx| input.set_text(next, cx)); + this.apply_git_executable_settings(cx); + }); + }) + .detach(); + }); + + let use_path_button = + components::Button::new("settings_window_git_executable_apply", "Use Path") + .style(components::ButtonStyle::Filled) + .on_click(theme, cx, |this, _e, _window, cx| { + this.apply_git_executable_settings(cx); + }); + + git_executable_card = git_executable_card + .child( + div() + .px_2() + .pt_1() + .text_xs() + .text_color(theme.colors.text_muted) + .child("Custom Git executable"), + ) + .child( + div() + .px_2() + .pb_1() + .w_full() + .min_w(px(0.0)) + .flex() + .items_center() + .gap_2() + .child( + div() + .flex_1() + .min_w(px(0.0)) + .child(self.git_executable_input.clone()), + ) + .child(browse_button) + .child(use_path_button), + ) + .child( + div() + .px_2() + .pb_1() + .text_xs() + .text_color(theme.colors.text_muted) + .child( + "Press Enter after editing the path to apply it immediately.", + ), + ); + } + + git_executable_card = git_executable_card.child( + div() + .id("settings_window_git_runtime") + .w_full() + .px_2() + .py_1() + .flex() + .items_center() + .justify_between() + .rounded(px(theme.radii.row)) + .child(div().text_sm().child("Detected runtime")) + .child( + div() + .flex() + .items_center() + .gap_2() + .child(svg_icon(git_icon_path, git_icon_color, px(14.0))) + .child( + div() + .text_sm() + .font_family(UI_MONOSPACE_FONT_FAMILY) + .text_color(theme.colors.text_muted) + .child(self.runtime_info.git.version_display.clone()), + ) + .child( + div() + .text_xs() + .text_color(git_icon_color) + .child(git_status_text), + ), + ), + ); + + if let Some(detail) = self.runtime_info.git.detail.clone() { + git_executable_card = git_executable_card.child( div() - .id("settings_window_git") - .w_full() + .id("settings_window_git_runtime_detail") .px_2() - .py_1() - .flex() - .items_center() - .justify_between() - .rounded(px(theme.radii.row)) - .child(div().text_sm().child("Git")) - .child( - div() - .flex() - .items_center() - .gap_2() - .child(svg_icon(git_icon_path, git_icon_color, px(14.0))) - .child( - div() - .text_sm() - .font_family(UI_MONOSPACE_FONT_FAMILY) - .text_color(theme.colors.text_muted) - .child(self.runtime_info.git.version_display.clone()), - ) - .child( - div() - .text_xs() - .text_color(git_icon_color) - .child(git_status_text), - ), - ), - ) + .pb_1() + .text_xs() + .text_color(theme.colors.text_muted) + .child(detail), + ); + } + + let environment_card = self + .card("settings_window_environment", "Environment", theme) .child(self.info_row( "settings_window_build", "Build", @@ -1815,17 +2069,6 @@ impl Render for SettingsWindowView { theme, )); - if let Some(detail) = self.runtime_info.git.detail.clone() { - environment_card = environment_card.child( - div() - .px_2() - .pt_1() - .text_xs() - .text_color(theme.colors.text_muted) - .child(detail), - ); - } - let links_card = self .card("settings_window_links", "Links", theme) .child( @@ -1870,6 +2113,7 @@ impl Render for SettingsWindowView { .p_3() .child(general_card) .child(change_tracking_card) + .child(git_executable_card) .child(environment_card) .child(links_card) } @@ -2096,8 +2340,12 @@ impl Render for SettingsWindowView { impl SettingsRuntimeInfo { fn detect() -> Self { + Self::from_runtime(refresh_git_runtime()) + } + + fn from_runtime(runtime: GitRuntimeState) -> Self { Self { - git: detect_git_runtime_info(), + git: git_runtime_info_from_state(runtime), app_version_display: format!("GitComet v{}", env!("CARGO_PKG_VERSION")).into(), operating_system: format!( "{} ({}, {})", @@ -2110,105 +2358,39 @@ impl SettingsRuntimeInfo { } } -fn detect_git_runtime_info() -> GitRuntimeInfo { +fn git_runtime_info_from_state(runtime: GitRuntimeState) -> GitRuntimeInfo { let compatibility_message = format!("GitComet has been tested only with Git {MIN_GIT_MAJOR}.{MIN_GIT_MINOR} or newer."); - - let mut command = std::process::Command::new("git"); - configure_background_command(&mut command); - match command.arg("--version").output() { - Ok(output) if output.status.success() => { - let version_output = if !output.stdout.is_empty() { - bytes_to_text_preserving_utf8(&output.stdout) - .trim() - .to_string() - } else { - bytes_to_text_preserving_utf8(&output.stderr) - .trim() - .to_string() - }; - - if version_output.is_empty() { - return GitRuntimeInfo { - version_display: "Unavailable".into(), - compatibility: GitCompatibility::Unknown, - detail: Some(compatibility_message.into()), - }; - } - - let compatibility = match parse_git_version(&version_output) { - Some(version) if is_supported_git_version(version) => GitCompatibility::Supported, - Some(_) => GitCompatibility::TooOld, - None => GitCompatibility::Unknown, - }; - - GitRuntimeInfo { - version_display: version_output.into(), - compatibility, - detail: match compatibility { - GitCompatibility::Supported => None, - GitCompatibility::TooOld | GitCompatibility::Unknown => { - Some(compatibility_message.into()) - } - }, - } - } - Ok(output) => { - let stderr = bytes_to_text_preserving_utf8(&output.stderr) - .trim() - .to_string(); - let display = if stderr.is_empty() { - format!("Unavailable (exit code: {})", output.status) - } else { - format!("Unavailable ({stderr})") - }; - GitRuntimeInfo { - version_display: display.into(), - compatibility: GitCompatibility::Unknown, - detail: Some(compatibility_message.into()), - } - } - Err(err) => GitRuntimeInfo { - version_display: format!("Unavailable ({err})").into(), - compatibility: GitCompatibility::Unknown, - detail: Some(compatibility_message.into()), - }, - } -} - -fn bytes_to_text_preserving_utf8(bytes: &[u8]) -> String { - use std::fmt::Write as _; - - let mut out = String::with_capacity(bytes.len()); - let mut cursor = 0usize; - while cursor < bytes.len() { - match std::str::from_utf8(&bytes[cursor..]) { - Ok(valid) => { - out.push_str(valid); - break; - } - Err(err) => { - let valid_len = err.valid_up_to(); - if valid_len > 0 { - let valid = &bytes[cursor..cursor + valid_len]; - out.push_str( - std::str::from_utf8(valid) - .expect("slice identified by valid_up_to must be valid UTF-8"), - ); - cursor += valid_len; - } - - let invalid_len = err.error_len().unwrap_or(1); - let invalid_end = cursor.saturating_add(invalid_len).min(bytes.len()); - for byte in &bytes[cursor..invalid_end] { - let _ = write!(out, "\\x{byte:02x}"); - } - cursor = invalid_end; - } + let compatibility = if !runtime.is_available() { + GitCompatibility::Unavailable + } else { + match runtime.version_output().and_then(parse_git_version) { + Some(version) if is_supported_git_version(version) => GitCompatibility::Supported, + Some(_) => GitCompatibility::TooOld, + None => GitCompatibility::Unknown, } + }; + + let version_display = runtime + .version_output() + .unwrap_or("Unavailable") + .to_string() + .into(); + + let detail = match compatibility { + GitCompatibility::Supported => None, + GitCompatibility::TooOld | GitCompatibility::Unknown => Some(compatibility_message.into()), + GitCompatibility::Unavailable => runtime + .unavailable_detail() + .map(|detail| SharedString::from(detail.to_string())), + }; + + GitRuntimeInfo { + runtime, + version_display, + compatibility, + detail, } - - out } fn parse_git_version(raw: &str) -> Option { @@ -2253,10 +2435,13 @@ mod tests { use super::*; use crate::test_support::lock_visual_test; use gitcomet_core::error::{Error, ErrorKind}; + use gitcomet_core::process::{ + GitExecutableAvailability, GitExecutablePreference, GitRuntimeState, + }; use gitcomet_core::services::{GitBackend, GitRepository, Result}; use gpui::{Modifiers, ScrollDelta, ScrollWheelEvent}; use std::ops::Deref; - use std::path::Path; + use std::path::{Path, PathBuf}; struct TestBackend; @@ -2269,10 +2454,37 @@ mod tests { } #[test] - fn bytes_to_text_preserving_utf8_escapes_invalid_bytes() { + fn git_executable_mode_tracks_runtime_preference() { + assert_eq!( + GitExecutableMode::from_preference(&GitExecutablePreference::SystemPath), + GitExecutableMode::SystemPath + ); + assert_eq!( + GitExecutableMode::from_preference(&GitExecutablePreference::Custom(PathBuf::from( + "/opt/git/bin/git" + ),)), + GitExecutableMode::Custom + ); + } + + #[test] + fn git_runtime_info_from_state_surfaces_unavailable_detail() { + let runtime = GitRuntimeState { + preference: GitExecutablePreference::Custom(PathBuf::new()), + availability: GitExecutableAvailability::Unavailable { + detail: "Custom Git executable is not configured. Choose an executable or switch back to System PATH.".to_string(), + }, + }; + + let info = git_runtime_info_from_state(runtime.clone()); + assert_eq!(info.runtime, runtime); + assert_eq!(info.compatibility, GitCompatibility::Unavailable); + assert_eq!(info.version_display.as_ref(), "Unavailable"); assert_eq!( - bytes_to_text_preserving_utf8(b"ok\xff\xfeend"), - "ok\\xff\\xfeend" + info.detail.as_ref().map(|detail| detail.as_ref()), + Some( + "Custom Git executable is not configured. Choose an executable or switch back to System PATH." + ) ); } diff --git a/crates/gitcomet-ui-gpui/src/view/splash.rs b/crates/gitcomet-ui-gpui/src/view/splash.rs index 176809c7..39e81ca7 100644 --- a/crates/gitcomet-ui-gpui/src/view/splash.rs +++ b/crates/gitcomet-ui-gpui/src/view/splash.rs @@ -63,8 +63,28 @@ impl GitCometView { !self.state.repos.is_empty() } + fn git_runtime_unavailable(&self) -> bool { + !self.state.git_runtime.is_available() + } + + fn git_runtime_unavailable_detail(&self) -> SharedString { + self.state + .git_runtime + .unavailable_detail() + .unwrap_or("GitComet could not find a usable Git executable.") + .to_string() + .into() + } + + fn should_show_git_unavailable_overlay(&self) -> bool { + renders_full_chrome(self.view_mode) + && self.has_repo_tabs() + && self.git_runtime_unavailable() + } + pub(crate) fn blocks_non_repository_actions(&self) -> bool { repository_entry_interstitial_active(self.view_mode, self.has_repo_tabs()) + || matches!(self.view_mode, GitCometViewMode::Normal) && self.git_runtime_unavailable() } pub(crate) fn is_splash_screen_active(&self) -> bool { @@ -154,6 +174,7 @@ impl GitCometView { div() .id(id) + .debug_selector(move || id.to_string()) .tab_index(0) .h(px(36.0)) .px(px(16.0)) @@ -223,6 +244,161 @@ impl GitCometView { .into_any_element() } + fn git_unavailable_open_settings_button( + &self, + cx: &mut gpui::Context, + ) -> gpui::Stateful { + let primary_bg = gpui::rgba(0x5ac1feff); + let primary_hover = gpui::rgba(0x72c7ffff); + let primary_active = gpui::rgba(0x48b6eeff); + let primary_text = gpui::rgba(0x04172bff); + + Self::splash_cta_button( + "git_unavailable_open_settings", + "Open Settings", + "icons/cog.svg", + SplashCtaButtonColors { + icon: primary_text, + text: primary_text, + background: SplashInteractiveColors { + base: primary_bg, + hover: primary_hover, + active: primary_active, + }, + border: SplashInteractiveColors { + base: primary_bg, + hover: primary_hover, + active: primary_active, + }, + }, + ) + .on_click(cx.listener(|this, _e, _window, cx| { + this.open_repo_panel = false; + cx.defer(crate::view::open_settings_window); + cx.notify(); + })) + } + + fn git_unavailable_panel_content( + &self, + theme: AppTheme, + cx: &mut gpui::Context, + ) -> AnyElement { + let detail_bg = with_alpha( + theme.colors.window_bg, + if theme.is_dark { 0.36 } else { 0.82 }, + ); + let detail_border = + with_alpha(theme.colors.border, if theme.is_dark { 0.96 } else { 0.82 }); + + div() + .id("git_unavailable_card") + .flex() + .flex_col() + .items_center() + .gap_3() + .child(Self::interstitial_logo(theme, px(84.0))) + .child( + div() + .text_lg() + .font_weight(FontWeight::BOLD) + .text_center() + .child("Git executable unavailable"), + ) + .child( + div() + .max_w(px(440.0)) + .text_center() + .text_sm() + .line_height(px(22.0)) + .text_color(theme.colors.text_muted) + .child( + "GitComet cannot open, refresh, or run repository actions until a Git executable is configured.", + ), + ) + .child( + div() + .id("git_unavailable_detail") + .w_full() + .max_w(px(460.0)) + .rounded(px(theme.radii.panel)) + .border_1() + .border_color(detail_border) + .bg(detail_bg) + .px_3() + .py_2() + .text_xs() + .line_height(px(18.0)) + .text_color(theme.colors.text_muted) + .child(self.git_runtime_unavailable_detail()), + ) + .child( + div() + .pt_1() + .child(self.git_unavailable_open_settings_button(cx)), + ) + .into_any_element() + } + + fn git_unavailable_splash(&mut self, cx: &mut gpui::Context) -> AnyElement { + let theme = self.theme; + self.interstitial_shell( + "git_unavailable_screen", + self.git_unavailable_panel_content(theme, cx), + theme, + ) + } + + fn git_unavailable_overlay(&mut self, cx: &mut gpui::Context) -> AnyElement { + let theme = self.theme; + let border_glow = with_alpha(theme.colors.border, if theme.is_dark { 0.86 } else { 0.74 }); + + div() + .id("git_unavailable_overlay") + .debug_selector(|| "git_unavailable_overlay".to_string()) + .absolute() + .top_0() + .left_0() + .size_full() + .overflow_hidden() + .bg(with_alpha( + theme.colors.window_bg, + if theme.is_dark { 0.76 } else { 0.82 }, + )) + .child(self.interstitial_backdrop()) + .child( + div() + .relative() + .size_full() + .px_3() + .py_4() + .flex() + .items_center() + .justify_center() + .child( + div() + .w_full() + .max_w(px(560.0)) + .bg(with_alpha( + theme.colors.surface_bg, + if theme.is_dark { 0.96 } else { 0.98 }, + )) + .border_1() + .border_color(border_glow) + .rounded(px(theme.radii.panel)) + .shadow(vec![gpui::BoxShadow { + color: gpui::rgba(0x00000052).into(), + offset: point(px(0.0), px(22.0)), + blur_radius: px(52.0), + spread_radius: px(0.0), + }]) + .p_4() + .child(self.git_unavailable_panel_content(theme, cx)), + ), + ) + .into_any_element() + } + pub(super) fn startup_repository_loading_screen(&mut self) -> AnyElement { let theme = self.theme; @@ -266,6 +442,10 @@ impl GitCometView { } pub(super) fn splash_screen(&mut self, cx: &mut gpui::Context) -> AnyElement { + if self.git_runtime_unavailable() { + return self.git_unavailable_splash(cx); + } + let hero_text = gpui::rgba(0xf6f7fbff); let hero_muted = gpui::rgba(0xa8b1c6ff); let hero_proof = gpui::rgba(0xffffffbd); @@ -611,7 +791,7 @@ impl GitCometView { } if renders_full_chrome(self.view_mode) { - return div() + let content = div() .flex() .flex_col() .flex_1() @@ -722,6 +902,20 @@ impl GitCometView { ), ) .into_any_element(); + + if self.should_show_git_unavailable_overlay() { + return div() + .relative() + .flex() + .flex_col() + .flex_1() + .min_h(px(0.0)) + .child(content) + .child(self.git_unavailable_overlay(cx)) + .into_any_element(); + } + + return content; } div() diff --git a/crates/gitcomet-ui-gpui/src/view/state_apply.rs b/crates/gitcomet-ui-gpui/src/view/state_apply.rs index 071e9b0c..5fbb40a4 100644 --- a/crates/gitcomet-ui-gpui/src/view/state_apply.rs +++ b/crates/gitcomet-ui-gpui/src/view/state_apply.rs @@ -6,6 +6,8 @@ impl GitCometView { next: Arc, cx: &mut gpui::Context, ) -> bool { + let git_runtime_changed = self.state.git_runtime != next.git_runtime; + let prev_git_runtime_available = self.state.git_runtime.is_available(); let prev_had_repos = !self.state.repos.is_empty(); let prev_banner_error = self.state.banner_error.clone(); let prev_auth_prompt = self.state.auth_prompt.clone(); @@ -144,6 +146,9 @@ impl GitCometView { } self.state = next; + if !prev_git_runtime_available && self.state.git_runtime.is_available() { + self.resume_after_git_runtime_recovery(); + } for msg in follow_up_msgs { self.store.dispatch(msg); } @@ -173,7 +178,9 @@ impl GitCometView { .collect(), ); - prev_banner_error != next_banner_error || prev_auth_prompt != self.state.auth_prompt + git_runtime_changed + || prev_banner_error != next_banner_error + || prev_auth_prompt != self.state.auth_prompt } } diff --git a/crates/gitcomet-ui-gpui/src/view/tests.rs b/crates/gitcomet-ui-gpui/src/view/tests.rs index 4217c15e..59ea5943 100644 --- a/crates/gitcomet-ui-gpui/src/view/tests.rs +++ b/crates/gitcomet-ui-gpui/src/view/tests.rs @@ -4,6 +4,7 @@ use gitcomet_core::domain::{ Upstream, Worktree, }; use gitcomet_core::error::{Error, ErrorKind}; +use gitcomet_core::process::{GitExecutableAvailability, GitExecutablePreference, GitRuntimeState}; use gitcomet_core::services::{GitBackend, GitRepository, Result}; use gitcomet_state::store::AppStore; use std::path::Path; @@ -45,6 +46,24 @@ fn wait_until(description: &str, ready: impl Fn() -> bool) { } } +fn available_git_runtime_state() -> GitRuntimeState { + GitRuntimeState { + preference: GitExecutablePreference::SystemPath, + availability: GitExecutableAvailability::Available { + version_output: "git version 2.51.0".to_string(), + }, + } +} + +fn unavailable_git_runtime_state() -> GitRuntimeState { + GitRuntimeState { + preference: GitExecutablePreference::Custom(PathBuf::new()), + availability: GitExecutableAvailability::Unavailable { + detail: "Custom Git executable is not configured. Choose an executable or switch back to System PATH.".to_string(), + }, + } +} + #[test] fn toast_total_lifetime_includes_fade_in_and_out() { let ttl = Duration::from_secs(6); @@ -1684,6 +1703,125 @@ fn splash_screen_renders_when_no_repositories_are_open(cx: &mut gpui::TestAppCon assert!(splash_active, "expected splash screen to be active"); } +#[gpui::test] +fn git_unavailable_splash_renders_open_settings_call_to_action(cx: &mut gpui::TestAppContext) { + let (store, events) = AppStore::new(Arc::new(TestBackend)); + let (view, cx) = + cx.add_window_view(|window, cx| GitCometView::new(store, events, None, window, cx)); + + let next = Arc::new(AppState { + git_runtime: unavailable_git_runtime_state(), + ..AppState::default() + }); + + cx.update(|window, app| { + view.update(app, |this, cx| { + this.disable_poller_for_tests(); + this.apply_state_snapshot(Arc::clone(&next), cx); + }); + let _ = window.draw(app); + }); + + cx.debug_bounds("git_unavailable_screen") + .expect("expected git unavailable splash screen"); + cx.debug_bounds("git_unavailable_open_settings") + .expect("expected open settings call to action"); + assert!( + cx.debug_bounds("splash_open_repo_action").is_none(), + "expected repository entry actions to be hidden while Git is unavailable" + ); + + cx.update(|_window, app| { + assert!(view.read(app).is_splash_screen_active()); + assert!(view.read(app).blocks_non_repository_actions()); + }); +} + +#[gpui::test] +fn git_unavailable_overlay_blocks_open_repositories(cx: &mut gpui::TestAppContext) { + let (store, events) = AppStore::new(Arc::new(TestBackend)); + let (view, cx) = + cx.add_window_view(|window, cx| GitCometView::new(store, events, None, window, cx)); + + let mut next = AppState { + git_runtime: unavailable_git_runtime_state(), + active_repo: Some(RepoId(1)), + ..AppState::default() + }; + next.repos.push(open_repo_state_with_workdir( + "/tmp/git-unavailable-overlay-test", + )); + let next = Arc::new(next); + + cx.update(|window, app| { + view.update(app, |this, cx| { + this.disable_poller_for_tests(); + this.apply_state_snapshot(Arc::clone(&next), cx); + }); + let _ = window.draw(app); + }); + + cx.debug_bounds("git_unavailable_overlay") + .expect("expected blocking git unavailable overlay"); + + cx.update(|_window, app| { + assert!(!view.read(app).is_splash_screen_active()); + assert!(view.read(app).blocks_non_repository_actions()); + }); +} + +#[gpui::test] +fn git_unavailable_overlay_clears_after_runtime_recovery(cx: &mut gpui::TestAppContext) { + let (store, events) = AppStore::new(Arc::new(TestBackend)); + let (view, cx) = + cx.add_window_view(|window, cx| GitCometView::new(store, events, None, window, cx)); + + let mut unavailable = AppState { + git_runtime: unavailable_git_runtime_state(), + active_repo: Some(RepoId(1)), + ..AppState::default() + }; + unavailable.repos.push(open_repo_state_with_workdir( + "/tmp/git-unavailable-recovery-test", + )); + let unavailable = Arc::new(unavailable); + + let mut recovered = AppState { + git_runtime: available_git_runtime_state(), + active_repo: Some(RepoId(1)), + ..AppState::default() + }; + recovered.repos.push(open_repo_state_with_workdir( + "/tmp/git-unavailable-recovery-test", + )); + let recovered = Arc::new(recovered); + + cx.update(|window, app| { + view.update(app, |this, cx| { + this.disable_poller_for_tests(); + this.apply_state_snapshot(Arc::clone(&unavailable), cx); + }); + let _ = window.draw(app); + }); + cx.debug_bounds("git_unavailable_overlay") + .expect("expected overlay before runtime recovery"); + + cx.update(|window, app| { + view.update(app, |this, cx| { + this.apply_state_snapshot(Arc::clone(&recovered), cx); + }); + let _ = window.draw(app); + }); + + assert!( + cx.debug_bounds("git_unavailable_overlay").is_none(), + "expected overlay to disappear after runtime recovery" + ); + cx.update(|_window, app| { + assert!(!view.read(app).blocks_non_repository_actions()); + }); +} + #[gpui::test] fn splash_backdrop_renders_native_layers(cx: &mut gpui::TestAppContext) { let (store, events) = AppStore::new(Arc::new(TestBackend)); diff --git a/crates/gitcomet-ui-gpui/src/view/tooltip.rs b/crates/gitcomet-ui-gpui/src/view/tooltip.rs index 1d8e5e49..bc833393 100644 --- a/crates/gitcomet-ui-gpui/src/view/tooltip.rs +++ b/crates/gitcomet-ui-gpui/src/view/tooltip.rs @@ -53,6 +53,7 @@ impl GitCometView { history_show_author: Some(history_show_author), history_show_date: Some(history_show_date), history_show_sha: Some(history_show_sha), + git_executable_path: None, }; Some(settings) diff --git a/crates/gitcomet/Cargo.toml b/crates/gitcomet/Cargo.toml index 6dfe0e66..332afd94 100644 --- a/crates/gitcomet/Cargo.toml +++ b/crates/gitcomet/Cargo.toml @@ -30,6 +30,7 @@ clap = { workspace = true } gitcomet-core = { workspace = true } gitcomet-git = { workspace = true } gitcomet-git-gix = { workspace = true, optional = true } +gitcomet-state = { workspace = true } gitcomet-ui = { workspace = true, optional = true } gitcomet-ui-gpui = { workspace = true, optional = true } mimalloc = { workspace = true } diff --git a/crates/gitcomet/src/cli/git_config.rs b/crates/gitcomet/src/cli/git_config.rs index 96e05c2c..6aad85c8 100644 --- a/crates/gitcomet/src/cli/git_config.rs +++ b/crates/gitcomet/src/cli/git_config.rs @@ -1,10 +1,8 @@ use super::*; -use gitcomet_core::process::configure_background_command; +use gitcomet_core::process::git_command as process_git_command; fn git_command() -> std::process::Command { - let mut command = std::process::Command::new("git"); - configure_background_command(&mut command); - command + process_git_command() } fn trim_git_stdout_bytes(bytes: &[u8]) -> &[u8] { diff --git a/crates/gitcomet/src/difftool_mode.rs b/crates/gitcomet/src/difftool_mode.rs index f9c2c4bd..672681c9 100644 --- a/crates/gitcomet/src/difftool_mode.rs +++ b/crates/gitcomet/src/difftool_mode.rs @@ -1,9 +1,8 @@ use crate::cli::{DifftoolConfig, DifftoolInputKind, classify_difftool_input, exit_code}; -use gitcomet_core::process::configure_background_command; +use gitcomet_core::process::git_command; use rustc_hash::FxHashSet as HashSet; use std::fs; use std::path::{Path, PathBuf}; -use std::process::Command; use tempfile::{Builder, TempDir}; /// Format a `"Failed to {op} {path}: {err}"` message concisely. @@ -44,8 +43,7 @@ pub struct DifftoolRunResult { pub fn run_difftool(config: &DifftoolConfig) -> Result { let prepared_inputs = prepare_diff_inputs(config)?; - let mut cmd = Command::new("git"); - configure_background_command(&mut cmd); + let mut cmd = git_command(); cmd.arg("diff").arg("--no-index").arg("--no-ext-diff"); // When launched from `git difftool`, Git sets `GIT_EXTERNAL_DIFF` to its // helper. Remove it so this nested `git diff --no-index` cannot recurse. diff --git a/crates/gitcomet/src/main.rs b/crates/gitcomet/src/main.rs index 7de9a5f8..b57a5659 100644 --- a/crates/gitcomet/src/main.rs +++ b/crates/gitcomet/src/main.rs @@ -16,6 +16,7 @@ mod mergetool_mode; mod setup_mode; use cli::{AppMode, exit_code}; +use gitcomet_core::process::install_git_executable_path; use mimalloc::MiMalloc; pub(crate) fn hex_encode(bytes: &[u8]) -> String { @@ -141,6 +142,8 @@ fn should_launch_focused_diff_gui( } fn main() { + install_configured_git_executable_preference(); + let mode = match cli::parse_app_mode() { Ok(mode) => mode, Err(msg) => { @@ -288,6 +291,11 @@ fn main() { } } +fn install_configured_git_executable_preference() { + let session = gitcomet_state::session::load(); + let _ = install_git_executable_path(session.git_executable_path); +} + #[cfg(all(target_os = "macos", feature = "ui-gpui-runtime"))] const MACOS_BUNDLE_RELAUNCH_ENV: &str = "GITCOMET_SKIP_APP_BUNDLE_RELAUNCH"; #[cfg(all(target_os = "macos", feature = "ui-gpui-runtime"))] diff --git a/crates/gitcomet/src/setup_mode.rs b/crates/gitcomet/src/setup_mode.rs index 05f1ad31..95e36400 100644 --- a/crates/gitcomet/src/setup_mode.rs +++ b/crates/gitcomet/src/setup_mode.rs @@ -5,7 +5,7 @@ //! Uninstall removes those entries while preserving unrelated tool settings. use gitcomet_core::path_utils::strip_windows_verbatim_prefix; -use gitcomet_core::process::configure_background_command; +use gitcomet_core::process::git_command as process_git_command; use rustc_hash::FxHashMap as HashMap; use std::path::{Path, PathBuf}; @@ -116,9 +116,7 @@ fn quoted_env_var(name: &str) -> String { } fn git_command() -> std::process::Command { - let mut command = std::process::Command::new("git"); - configure_background_command(&mut command); - command + process_git_command() } /// Build the list of git config entries for difftool/mergetool setup. From 3d3b1059e286b76e96523ac33ce3bc4444dd894c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sampo=20Kivist=C3=B6?= Date: Sat, 11 Apr 2026 14:51:05 +0300 Subject: [PATCH 2/8] fix expensive layout re-calculation on spinner animation --- crates/gitcomet-core/src/process.rs | 4 +- crates/gitcomet-ui-gpui/src/view/mod.rs | 58 ++++++++++--- .../src/view/panels/action_bar.rs | 5 ++ .../gitcomet-ui-gpui/src/view/panels/mod.rs | 2 +- .../gitcomet-ui-gpui/src/view/rows/sidebar.rs | 14 +++ crates/gitcomet-ui-gpui/src/view/splash.rs | 14 ++- crates/gitcomet-ui-gpui/src/view/tests.rs | 87 +++++++++++++++++++ 7 files changed, 164 insertions(+), 20 deletions(-) diff --git a/crates/gitcomet-core/src/process.rs b/crates/gitcomet-core/src/process.rs index 08dbee5b..7fa99f49 100644 --- a/crates/gitcomet-core/src/process.rs +++ b/crates/gitcomet-core/src/process.rs @@ -6,15 +6,13 @@ use std::process::Command; use std::sync::Mutex; use std::sync::{OnceLock, RwLock}; -#[derive(Clone, Debug, Eq, PartialEq)] -#[derive(Default)] +#[derive(Clone, Debug, Eq, PartialEq, Default)] pub enum GitExecutablePreference { #[default] SystemPath, Custom(PathBuf), } - impl GitExecutablePreference { pub fn from_optional_path(path: Option) -> Self { match path { diff --git a/crates/gitcomet-ui-gpui/src/view/mod.rs b/crates/gitcomet-ui-gpui/src/view/mod.rs index 5bf886ca..c22b9c4c 100644 --- a/crates/gitcomet-ui-gpui/src/view/mod.rs +++ b/crates/gitcomet-ui-gpui/src/view/mod.rs @@ -18,12 +18,12 @@ use gitcomet_state::session; use gitcomet_state::store::AppStore; use gpui::prelude::*; use gpui::{ - Animation, AnimationExt, AnyElement, App, Bounds, ClickEvent, Corner, CursorStyle, Decorations, - Element, ElementId, Entity, FocusHandle, FontWeight, GlobalElementId, InspectorElementId, - IsZero, LayoutId, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Point, - Render, ResizeEdge, ScrollHandle, ShapedLine, SharedString, Size, Style, TextRun, Tiling, - UniformListScrollHandle, WeakEntity, Window, WindowControlArea, anchored, div, fill, point, px, - relative, size, uniform_list, + Animation, AnimationExt, AnyElement, AnyView, App, Bounds, ClickEvent, Corner, CursorStyle, + Decorations, Element, ElementId, Entity, FocusHandle, FontWeight, GlobalElementId, + InspectorElementId, IsZero, LayoutId, MouseButton, MouseDownEvent, MouseMoveEvent, + MouseUpEvent, Pixels, Point, Render, ResizeEdge, ScrollHandle, ShapedLine, SharedString, Size, + Style, StyleRefinement, TextRun, Tiling, UniformListScrollHandle, WeakEntity, Window, + WindowControlArea, anchored, div, fill, point, px, relative, size, uniform_list, }; use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet}; #[cfg(test)] @@ -81,7 +81,8 @@ use caches::{ HistoryStashIdsCache, HistoryWorktreeSummaryCache, }; use chrome::{ - CLIENT_SIDE_DECORATION_INSET, TitleBarView, cursor_style_for_resize_edge, resize_edge, + CLIENT_SIDE_DECORATION_INSET, TITLE_BAR_HEIGHT, TitleBarView, cursor_style_for_resize_edge, + resize_edge, }; use conflict_resolver::{ConflictPickSide, ConflictResolverViewMode}; #[cfg(test)] @@ -111,7 +112,7 @@ pub use mod_helpers::{ FocusedMergetoolLabels, FocusedMergetoolViewConfig, GitCometView, GitCometViewConfig, GitCometViewMode, InitialRepositoryLaunchMode, StartupCrashReport, }; -use panels::{ActionBarView, PopoverHost, RepoTabsBarView}; +use panels::{ACTION_BAR_HEIGHT, ActionBarView, PopoverHost, RepoTabsBarView}; use panes::{DetailsPaneInit, DetailsPaneView, HistoryView, MainPaneView, SidebarPaneView}; pub(crate) use settings_window::open_settings_window; use toast_host::ToastHost; @@ -158,6 +159,36 @@ const TOAST_FADE_IN_MS: u64 = 180; const TOAST_FADE_OUT_MS: u64 = 220; const TOAST_SLIDE_PX: f32 = 12.0; +// Only use these wrappers for views that remain mounted while their parent is mounted. +// Parent-controlled mount/unmount boundaries, like collapsible panes, must rebuild their child. +fn stable_cached_view(view: Entity, style: StyleRefinement) -> AnyView { + let view = AnyView::from(view); + // GPUI's cached mount path skips some test-only debug bounds and paint tracking. + if cfg!(test) { view } else { view.cached(style) } +} + +fn stable_cached_fill_view(view: Entity) -> AnyView { + stable_cached_view(view, StyleRefinement::default().size_full()) +} + +fn stable_cached_fixed_height_view(view: Entity, height: Pixels) -> AnyView { + stable_cached_view( + view, + StyleRefinement::default().w_full().h(height).flex_none(), + ) +} + +fn stable_cached_overlay_view(view: Entity) -> AnyView { + stable_cached_view( + view, + StyleRefinement::default() + .absolute() + .top_0() + .left_0() + .size_full(), + ) +} + pub(in crate::view) fn pane_resize_handles_width( sidebar_collapsed: bool, details_collapsed: bool, @@ -1590,7 +1621,10 @@ impl Render for GitCometView { .text_color(theme.colors.text); if show_custom_window_chrome { - body = body.child(self.title_bar.clone()); + body = body.child(stable_cached_fixed_height_view( + self.title_bar.clone(), + TITLE_BAR_HEIGHT, + )); } body = body.child(center_content); @@ -1948,11 +1982,11 @@ impl Render for GitCometView { root = root.child(window_frame(theme, decorations, body.into_any_element())); - root = root.child(self.toast_host.clone()); + root = root.child(stable_cached_overlay_view(self.toast_host.clone())); - root = root.child(self.popover_host.clone()); + root = root.child(stable_cached_overlay_view(self.popover_host.clone())); - root = root.child(self.tooltip_host.clone()); + root = root.child(stable_cached_overlay_view(self.tooltip_host.clone())); if crate::startup_probe::is_enabled() { root = root.on_children_prepainted(|_children_bounds, window, _cx| { diff --git a/crates/gitcomet-ui-gpui/src/view/panels/action_bar.rs b/crates/gitcomet-ui-gpui/src/view/panels/action_bar.rs index d63bb1de..53753a15 100644 --- a/crates/gitcomet-ui-gpui/src/view/panels/action_bar.rs +++ b/crates/gitcomet-ui-gpui/src/view/panels/action_bar.rs @@ -4,6 +4,8 @@ use rustc_hash::FxHasher; use std::hash::{Hash, Hasher}; use std::sync::Arc; +pub(in super::super) const ACTION_BAR_HEIGHT: Pixels = px(components::CONTROL_HEIGHT_PX + 8.0); + fn head_branch_has_tracking_upstream( head_branch: &Loadable, branches: &Loadable>>, @@ -626,6 +628,9 @@ impl Render for ActionBarView { })); div() + .w_full() + .h(ACTION_BAR_HEIGHT) + .flex_none() .flex() .items_center() .justify_between() diff --git a/crates/gitcomet-ui-gpui/src/view/panels/mod.rs b/crates/gitcomet-ui-gpui/src/view/panels/mod.rs index 9599842f..3f1434d1 100644 --- a/crates/gitcomet-ui-gpui/src/view/panels/mod.rs +++ b/crates/gitcomet-ui-gpui/src/view/panels/mod.rs @@ -267,7 +267,7 @@ mod main; mod popover; mod repo_tabs_bar; -pub(super) use action_bar::ActionBarView; +pub(super) use action_bar::{ACTION_BAR_HEIGHT, ActionBarView}; pub(super) use popover::PopoverHost; pub(super) use repo_tabs_bar::RepoTabsBarView; #[allow(unused_imports)] diff --git a/crates/gitcomet-ui-gpui/src/view/rows/sidebar.rs b/crates/gitcomet-ui-gpui/src/view/rows/sidebar.rs index 3044bab0..1823bd5e 100644 --- a/crates/gitcomet-ui-gpui/src/view/rows/sidebar.rs +++ b/crates/gitcomet-ui-gpui/src/view/rows/sidebar.rs @@ -2704,10 +2704,22 @@ mod tests { #[gpui::test] fn branch_reveal_routes_through_main_pane_and_selects_commit(cx: &mut gpui::TestAppContext) { + let _visual_guard = crate::test_support::lock_visual_test(); let (store, events) = AppStore::new(Arc::new(BlockingBackend)); let store_for_assert = store.clone(); let (view, cx) = cx.add_window_view(|window, cx| GitCometView::new(store, events, None, window, cx)); + cx.update(|_window, app| { + view.update(app, |this, _cx| this.disable_poller_for_tests()); + }); + + let sync_view_from_store = |cx: &mut gpui::VisualTestContext| { + cx.update(|window, app| { + view.update(app, |this, cx| this.sync_store_snapshot_for_tests(cx)); + window.refresh(); + let _ = window.draw(app); + }); + }; let repo_id = RepoId(1); let target = commit_id("main-tip"); @@ -2720,6 +2732,7 @@ mod tests { snapshot.active_repo == Some(repo_id) && snapshot.repos.iter().any(|repo| repo.id == repo_id) }); + sync_view_from_store(cx); store_for_assert.dispatch(Msg::Internal(InternalMsg::HeadBranchLoaded { repo_id, @@ -2760,6 +2773,7 @@ mod tests { && matches!(repo.log, Loadable::Ready(_)) && repo.diff_state.diff_target.is_some() }); + sync_view_from_store(cx); wait_until(cx, "history view active repo", |cx| { cx.update(|_window, app| { diff --git a/crates/gitcomet-ui-gpui/src/view/splash.rs b/crates/gitcomet-ui-gpui/src/view/splash.rs index 39e81ca7..68ccd11b 100644 --- a/crates/gitcomet-ui-gpui/src/view/splash.rs +++ b/crates/gitcomet-ui-gpui/src/view/splash.rs @@ -796,9 +796,15 @@ impl GitCometView { .flex_col() .flex_1() .min_h(px(0.0)) - .child(self.repo_tabs_bar.clone()) + .child(stable_cached_fixed_height_view( + self.repo_tabs_bar.clone(), + components::Tab::container_height(), + )) .child(self.open_repo_panel(cx)) - .child(self.action_bar.clone()) + .child(stable_cached_fixed_height_view( + self.action_bar.clone(), + ACTION_BAR_HEIGHT, + )) .child( div() .flex() @@ -851,7 +857,7 @@ impl GitCometView { .flex_1() .min_w(px(0.0)) .min_h(px(0.0)) - .child(self.main_pane.clone()), + .child(stable_cached_fill_view(self.main_pane.clone())), ) .child(self.pane_resize_handle( theme, @@ -928,7 +934,7 @@ impl GitCometView { .flex_1() .min_w(px(0.0)) .min_h(px(0.0)) - .child(self.main_pane.clone()), + .child(stable_cached_fill_view(self.main_pane.clone())), ) .into_any_element() } diff --git a/crates/gitcomet-ui-gpui/src/view/tests.rs b/crates/gitcomet-ui-gpui/src/view/tests.rs index 55acc59d..2733d604 100644 --- a/crates/gitcomet-ui-gpui/src/view/tests.rs +++ b/crates/gitcomet-ui-gpui/src/view/tests.rs @@ -1646,6 +1646,7 @@ fn ease_out_cubic_is_monotonic_in_unit_interval() { #[gpui::test] fn sidebar_expand_after_collapse_does_not_reenter_root_update(cx: &mut gpui::TestAppContext) { + let _visual_guard = crate::test_support::lock_visual_test(); let (store, events) = AppStore::new(Arc::new(TestBackend)); let (view, cx) = cx.add_window_view(|window, cx| GitCometView::new(store, events, None, window, cx)); @@ -1676,8 +1677,86 @@ fn sidebar_expand_after_collapse_does_not_reenter_root_update(cx: &mut gpui::Tes }); } +#[gpui::test] +fn details_expand_after_collapse_does_not_reenter_root_update(cx: &mut gpui::TestAppContext) { + let _visual_guard = crate::test_support::lock_visual_test(); + let (store, events) = AppStore::new(Arc::new(TestBackend)); + let (view, cx) = + cx.add_window_view(|window, cx| GitCometView::new(store, events, None, window, cx)); + cx.update(|_window, app| { + view.update(app, |this, _cx| this.disable_poller_for_tests()); + }); + + cx.update(|window, app| { + let _ = window.draw(app); + view.update(app, |this, cx| this.set_details_collapsed(true, cx)); + }); + pump_for( + cx, + Duration::from_millis(PANE_COLLAPSE_ANIM_MS.saturating_add(180)), + ); + + cx.update(|window, app| { + let _ = window.draw(app); + view.update(app, |this, cx| this.set_details_collapsed(false, cx)); + }); + pump_for( + cx, + Duration::from_millis(PANE_COLLAPSE_ANIM_MS.saturating_add(180)), + ); + + cx.update(|_window, app| { + assert!(!view.read(app).details_collapsed); + }); +} + +#[test] +fn full_chrome_layout_only_caches_always_mounted_subviews() { + let splash_source = include_str!("splash.rs"); + let normalized: String = splash_source + .chars() + .filter(|c| !c.is_whitespace()) + .collect(); + + assert!( + normalized.contains( + "stable_cached_fixed_height_view(self.repo_tabs_bar.clone(),components::Tab::container_height(" + ), + "expected repo tabs bar to stay behind the stable cache boundary" + ); + assert!( + normalized + .contains("stable_cached_fixed_height_view(self.action_bar.clone(),ACTION_BAR_HEIGHT"), + "expected action bar to stay behind the stable cache boundary" + ); + assert!( + normalized + .matches("stable_cached_fill_view(self.main_pane.clone())") + .count() + >= 2, + "expected both full-chrome main pane mount sites to stay cached" + ); + assert!( + normalized.contains("d.child(self.sidebar_pane.clone())"), + "expected the collapsible sidebar pane to mount directly" + ); + assert!( + normalized.contains(".child(self.details_pane.clone())"), + "expected the collapsible details pane to mount directly" + ); + assert!( + !normalized.contains("stable_cached_fill_view(self.sidebar_pane.clone())"), + "sidebar pane must stay outside the stable cache boundary" + ); + assert!( + !normalized.contains("stable_cached_fill_view(self.details_pane.clone())"), + "details pane must stay outside the stable cache boundary" + ); +} + #[gpui::test] fn splash_screen_renders_when_no_repositories_are_open(cx: &mut gpui::TestAppContext) { + let _visual_guard = crate::test_support::lock_visual_test(); let (store, events) = AppStore::new(Arc::new(TestBackend)); let (view, cx) = cx.add_window_view(|window, cx| GitCometView::new(store, events, None, window, cx)); @@ -1708,6 +1787,7 @@ fn splash_screen_renders_when_no_repositories_are_open(cx: &mut gpui::TestAppCon #[gpui::test] fn git_unavailable_splash_renders_open_settings_call_to_action(cx: &mut gpui::TestAppContext) { + let _visual_guard = crate::test_support::lock_visual_test(); let (store, events) = AppStore::new(Arc::new(TestBackend)); let (view, cx) = cx.add_window_view(|window, cx| GitCometView::new(store, events, None, window, cx)); @@ -1742,6 +1822,7 @@ fn git_unavailable_splash_renders_open_settings_call_to_action(cx: &mut gpui::Te #[gpui::test] fn git_unavailable_overlay_blocks_open_repositories(cx: &mut gpui::TestAppContext) { + let _visual_guard = crate::test_support::lock_visual_test(); let (store, events) = AppStore::new(Arc::new(TestBackend)); let (view, cx) = cx.add_window_view(|window, cx| GitCometView::new(store, events, None, window, cx)); @@ -1775,6 +1856,7 @@ fn git_unavailable_overlay_blocks_open_repositories(cx: &mut gpui::TestAppContex #[gpui::test] fn git_unavailable_overlay_clears_after_runtime_recovery(cx: &mut gpui::TestAppContext) { + let _visual_guard = crate::test_support::lock_visual_test(); let (store, events) = AppStore::new(Arc::new(TestBackend)); let (view, cx) = cx.add_window_view(|window, cx| GitCometView::new(store, events, None, window, cx)); @@ -1827,6 +1909,7 @@ fn git_unavailable_overlay_clears_after_runtime_recovery(cx: &mut gpui::TestAppC #[gpui::test] fn splash_backdrop_renders_native_layers(cx: &mut gpui::TestAppContext) { + let _visual_guard = crate::test_support::lock_visual_test(); let (store, events) = AppStore::new(Arc::new(TestBackend)); let (view, cx) = cx.add_window_view(|window, cx| GitCometView::new(store, events, None, window, cx)); @@ -1866,6 +1949,7 @@ fn splash_backdrop_renders_native_layers(cx: &mut gpui::TestAppContext) { #[gpui::test] fn splash_screen_buttons_publish_expected_tooltips(cx: &mut gpui::TestAppContext) { + let _visual_guard = crate::test_support::lock_visual_test(); let (store, events) = AppStore::new(Arc::new(TestBackend)); let (view, cx) = cx.add_window_view(|window, cx| GitCometView::new(store, events, None, window, cx)); @@ -1908,6 +1992,7 @@ fn splash_screen_buttons_publish_expected_tooltips(cx: &mut gpui::TestAppContext #[gpui::test] fn closing_last_repository_tab_returns_to_splash_screen(cx: &mut gpui::TestAppContext) { + let _visual_guard = crate::test_support::lock_visual_test(); let (store, events) = AppStore::new(Arc::new(TestBackend)); let store_for_assert = store.clone(); let (view, cx) = @@ -1980,6 +2065,7 @@ fn closing_last_repository_tab_returns_to_splash_screen(cx: &mut gpui::TestAppCo #[gpui::test] fn splash_screen_clears_stale_close_repository_tooltip(cx: &mut gpui::TestAppContext) { + let _visual_guard = crate::test_support::lock_visual_test(); let (store, events) = AppStore::new(Arc::new(TestBackend)); let store_for_assert = store.clone(); let (view, cx) = @@ -2066,6 +2152,7 @@ fn auth_prompt_banner_colors_use_accent_palette() { fn apply_state_snapshot_routes_command_errors_into_store_backed_banner( cx: &mut gpui::TestAppContext, ) { + let _visual_guard = crate::test_support::lock_visual_test(); let (store, events) = AppStore::new(Arc::new(TestBackend)); let store_for_assert = store.clone(); let (view, cx) = From 8160bce78fb8c2fa84567eef771aa7dbae921c77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sampo=20Kivist=C3=B6?= Date: Sat, 11 Apr 2026 21:08:28 +0300 Subject: [PATCH 3/8] fix merge error --- crates/gitcomet-ui-gpui/src/view/rows/sidebar.rs | 7 +++---- crates/gitcomet-ui-gpui/src/view/tests.rs | 6 ------ 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/crates/gitcomet-ui-gpui/src/view/rows/sidebar.rs b/crates/gitcomet-ui-gpui/src/view/rows/sidebar.rs index e5fc48d8..5c437fe5 100644 --- a/crates/gitcomet-ui-gpui/src/view/rows/sidebar.rs +++ b/crates/gitcomet-ui-gpui/src/view/rows/sidebar.rs @@ -2722,13 +2722,12 @@ mod tests { let store_for_assert = store.clone(); let (view, cx) = cx.add_window_view(|window, cx| GitCometView::new(store, events, None, window, cx)); - cx.update(|_window, app| { - view.update(app, |this, _cx| this.disable_poller_for_tests()); - }); let sync_view_from_store = |cx: &mut gpui::VisualTestContext| { cx.update(|window, app| { - view.update(app, |this, cx| this.sync_store_snapshot_for_tests(cx)); + view.update(app, |this, cx| { + crate::view::test_support::sync_store_snapshot(this, cx) + }); window.refresh(); let _ = window.draw(app); }); diff --git a/crates/gitcomet-ui-gpui/src/view/tests.rs b/crates/gitcomet-ui-gpui/src/view/tests.rs index 31ed735e..8d0149a0 100644 --- a/crates/gitcomet-ui-gpui/src/view/tests.rs +++ b/crates/gitcomet-ui-gpui/src/view/tests.rs @@ -1679,9 +1679,6 @@ fn details_expand_after_collapse_does_not_reenter_root_update(cx: &mut gpui::Tes let (store, events) = AppStore::new(Arc::new(TestBackend)); let (view, cx) = cx.add_window_view(|window, cx| GitCometView::new(store, events, None, window, cx)); - cx.update(|_window, app| { - view.update(app, |this, _cx| this.disable_poller_for_tests()); - }); cx.update(|window, app| { let _ = window.draw(app); @@ -1794,7 +1791,6 @@ fn git_unavailable_splash_renders_open_settings_call_to_action(cx: &mut gpui::Te cx.update(|window, app| { view.update(app, |this, cx| { - this.disable_poller_for_tests(); this.apply_state_snapshot(Arc::clone(&next), cx); }); let _ = window.draw(app); @@ -1834,7 +1830,6 @@ fn git_unavailable_overlay_blocks_open_repositories(cx: &mut gpui::TestAppContex cx.update(|window, app| { view.update(app, |this, cx| { - this.disable_poller_for_tests(); this.apply_state_snapshot(Arc::clone(&next), cx); }); let _ = window.draw(app); @@ -1878,7 +1873,6 @@ fn git_unavailable_overlay_clears_after_runtime_recovery(cx: &mut gpui::TestAppC cx.update(|window, app| { view.update(app, |this, cx| { - this.disable_poller_for_tests(); this.apply_state_snapshot(Arc::clone(&unavailable), cx); }); let _ = window.draw(app); From 2a385ee09eb6d4c9a50bc081772334141235950b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sampo=20Kivist=C3=B6?= Date: Sun, 12 Apr 2026 12:35:54 +0300 Subject: [PATCH 4/8] wip --- .github/workflows/deployment-ci.yml | 3 + Cargo.lock | 106 +- crates/gitcomet-core/src/services.rs | 6 + crates/gitcomet-git-gix/src/repo/mod.rs | 10 + crates/gitcomet-git-gix/src/repo/status.rs | 307 ++++- crates/gitcomet-state/src/model.rs | 229 +++- crates/gitcomet-state/src/msg/effect.rs | 6 + crates/gitcomet-state/src/msg/message.rs | 8 + .../gitcomet-state/src/msg/message_debug.rs | 10 + .../src/msg/repo_external_change.rs | 57 +- crates/gitcomet-state/src/store/effects.rs | 18 + .../src/store/effects/repo_load.rs | 64 + crates/gitcomet-state/src/store/reducer.rs | 6 + .../src/store/reducer/effects.rs | 138 ++- .../src/store/reducer/external_and_history.rs | 65 +- .../gitcomet-state/src/store/reducer/util.rs | 72 +- .../gitcomet-state/src/store/repo_monitor.rs | 63 +- crates/gitcomet-state/src/store/tests.rs | 22 + .../src/store/tests/external_and_history.rs | 112 +- .../src/store/tests/repo_management.rs | 41 +- crates/gitcomet-ui-gpui/src/view/caches.rs | 3 +- crates/gitcomet-ui-gpui/src/view/mod.rs | 4 +- .../gitcomet-ui-gpui/src/view/mod_helpers.rs | 162 ++- .../src/view/panels/action_bar.rs | 11 +- .../src/view/panels/layout.rs | 137 ++- .../src/view/panels/main/diff_view.rs | 40 +- .../src/view/panels/main/diff_view_helpers.rs | 37 +- .../src/view/panels/main/status_nav.rs | 42 +- .../src/view/panels/popover/context_menu.rs | 12 +- .../popover/context_menu/status_file.rs | 57 +- .../src/view/panels/popover/fingerprint.rs | 4 +- .../src/view/panels/repo_tabs_bar.rs | 23 +- .../src/view/panes/details.rs | 24 +- .../src/view/panes/history.rs | 34 +- .../src/view/panes/main/actions_impl.rs | 9 +- .../src/view/panes/main/core_impl.rs | 2 +- .../src/view/panes/main/diff_search.rs | 10 +- .../src/view/panes/main/preview.rs | 53 +- .../src/view/rows/benchmarks/diff_fixtures.rs | 27 +- .../src/view/rows/benchmarks/git_ops.rs | 2 +- .../src/view/rows/benchmarks/real_repo.rs | 15 +- .../src/view/rows/benchmarks/repo_history.rs | 75 +- .../view/rows/benchmarks/runtime_fixtures.rs | 86 +- .../view/rows/benchmarks/scroll_fixtures.rs | 15 +- .../view/rows/benchmarks/search_fixtures.rs | 16 + .../view/rows/benchmarks/status_fixtures.rs | 30 +- .../src/view/rows/benchmarks/support.rs | 61 +- .../src/view/rows/benchmarks/tests.rs | 35 +- .../src/view/rows/benchmarks/text_fixtures.rs | 51 +- .../gitcomet-ui-gpui/src/view/rows/status.rs | 40 +- crates/win32-window-utils/Cargo.lock | 143 +-- crates/win32-window-utils/Cargo.toml | 2 +- crates/win32-window-utils/src/lib.rs | 64 +- scripts/cargo-flatten-dupes.sh | 1073 +++++++++++++++++ 54 files changed, 2739 insertions(+), 1003 deletions(-) create mode 100755 scripts/cargo-flatten-dupes.sh diff --git a/.github/workflows/deployment-ci.yml b/.github/workflows/deployment-ci.yml index 98f9687e..105de026 100644 --- a/.github/workflows/deployment-ci.yml +++ b/.github/workflows/deployment-ci.yml @@ -10,6 +10,7 @@ on: - ".github/workflows/deploy-apt-repo.yml" - ".github/workflows/release-manual-main.yml" - "scripts/update-aur.sh" + - "scripts/cargo-flatten-dupes.sh" - "scripts/package-macos.sh" - "scripts/macos-cargo-config.sh" - "scripts/generate-homebrew-formula.sh" @@ -25,6 +26,7 @@ on: - ".github/workflows/deploy-apt-repo.yml" - ".github/workflows/release-manual-main.yml" - "scripts/update-aur.sh" + - "scripts/cargo-flatten-dupes.sh" - "scripts/package-macos.sh" - "scripts/macos-cargo-config.sh" - "scripts/generate-homebrew-formula.sh" @@ -49,6 +51,7 @@ jobs: - name: Validate shell scripts syntax run: | + bash -n scripts/cargo-flatten-dupes.sh bash -n scripts/update-aur.sh bash -n scripts/package-macos.sh bash -n scripts/notarize-macos.sh diff --git a/Cargo.lock b/Cargo.lock index 13e29eec..5d8fabea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2302,7 +2302,7 @@ dependencies = [ name = "gitcomet-win32-window-utils" version = "0.1.7" dependencies = [ - "windows 0.61.3", + "windows-sys 0.61.2", ] [[package]] @@ -3240,7 +3240,7 @@ dependencies = [ "log", "presser", "thiserror 2.0.18", - "windows 0.62.2", + "windows", ] [[package]] @@ -3357,9 +3357,9 @@ dependencies = [ "wayland-protocols-wlr", "web-time", "wgpu", - "windows 0.62.2", - "windows-core 0.62.2", - "windows-numerics 0.3.1", + "windows", + "windows-core", + "windows-numerics", "windows-registry 0.6.1", "x11-clipboard", "x11rb", @@ -3777,7 +3777,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.2", + "windows-core", ] [[package]] @@ -8076,8 +8076,8 @@ dependencies = [ "web-sys", "wgpu-naga-bridge", "wgpu-types", - "windows 0.62.2", - "windows-core 0.62.2", + "windows", + "windows-core", ] [[package]] @@ -8145,38 +8145,16 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" -[[package]] -name = "windows" -version = "0.61.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" -dependencies = [ - "windows-collections 0.2.0", - "windows-core 0.61.2", - "windows-future 0.2.1", - "windows-link 0.1.3", - "windows-numerics 0.2.0", -] - [[package]] name = "windows" version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" dependencies = [ - "windows-collections 0.3.2", - "windows-core 0.62.2", - "windows-future 0.3.2", - "windows-numerics 0.3.1", -] - -[[package]] -name = "windows-collections" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" -dependencies = [ - "windows-core 0.61.2", + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", ] [[package]] @@ -8185,20 +8163,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" dependencies = [ - "windows-core 0.62.2", -] - -[[package]] -name = "windows-core" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link 0.1.3", - "windows-result 0.3.4", - "windows-strings 0.4.2", + "windows-core", ] [[package]] @@ -8214,26 +8179,15 @@ dependencies = [ "windows-strings 0.5.1", ] -[[package]] -name = "windows-future" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" -dependencies = [ - "windows-core 0.61.2", - "windows-link 0.1.3", - "windows-threading 0.1.0", -] - [[package]] name = "windows-future" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" dependencies = [ - "windows-core 0.62.2", + "windows-core", "windows-link 0.2.1", - "windows-threading 0.2.1", + "windows-threading", ] [[package]] @@ -8270,23 +8224,13 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-numerics" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" -dependencies = [ - "windows-core 0.61.2", - "windows-link 0.1.3", -] - [[package]] name = "windows-numerics" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" dependencies = [ - "windows-core 0.62.2", + "windows-core", "windows-link 0.2.1", ] @@ -8339,15 +8283,6 @@ dependencies = [ "windows-link 0.1.3", ] -[[package]] -name = "windows-strings" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" -dependencies = [ - "windows-link 0.1.3", -] - [[package]] name = "windows-strings" version = "0.5.1" @@ -8474,15 +8409,6 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] -[[package]] -name = "windows-threading" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" -dependencies = [ - "windows-link 0.1.3", -] - [[package]] name = "windows-threading" version = "0.2.1" diff --git a/crates/gitcomet-core/src/services.rs b/crates/gitcomet-core/src/services.rs index ed98abe5..dfa7793b 100644 --- a/crates/gitcomet-core/src/services.rs +++ b/crates/gitcomet-core/src/services.rs @@ -157,6 +157,12 @@ pub trait GitRepository: Send + Sync { } fn list_remotes(&self) -> Result>; fn list_remote_branches(&self) -> Result>; + fn worktree_status(&self) -> Result> { + self.status().map(|status| status.unstaged) + } + fn staged_status(&self) -> Result> { + self.status().map(|status| status.staged) + } fn status(&self) -> Result; fn upstream_divergence(&self) -> Result> { Ok(None) diff --git a/crates/gitcomet-git-gix/src/repo/mod.rs b/crates/gitcomet-git-gix/src/repo/mod.rs index b1ca2774..45378193 100644 --- a/crates/gitcomet-git-gix/src/repo/mod.rs +++ b/crates/gitcomet-git-gix/src/repo/mod.rs @@ -198,6 +198,16 @@ impl GitRepository for GixRepo { self.list_remote_branches_impl() } + fn worktree_status(&self) -> Result> { + let _scope = git_ops_trace::scope(GitOpTraceKind::Status); + self.worktree_status_impl() + } + + fn staged_status(&self) -> Result> { + let _scope = git_ops_trace::scope(GitOpTraceKind::Status); + self.staged_status_impl() + } + fn status(&self) -> Result { let _scope = git_ops_trace::scope(GitOpTraceKind::Status); self.status_impl() diff --git a/crates/gitcomet-git-gix/src/repo/status.rs b/crates/gitcomet-git-gix/src/repo/status.rs index 428e660a..9cec619e 100644 --- a/crates/gitcomet-git-gix/src/repo/status.rs +++ b/crates/gitcomet-git-gix/src/repo/status.rs @@ -152,80 +152,111 @@ impl GixRepo { } } - let mut staged = staged; - - // Some platforms may omit certain unmerged shapes (notably stage-1-only - // both-deleted conflicts) from gix status output. Supplement conflict - // entries from the index's unmerged stages only when the repository is - // in an in-progress operation or gix already surfaced conflicts. - if should_supplement_unmerged_conflicts(repo.state().is_some(), has_conflicted_unstaged) { - for (path, conflict_kind) in gix_unmerged_conflicts(&repo)? { - if let Some(entry) = unstaged.iter_mut().find(|entry| entry.path == path) { - entry.kind = FileStatusKind::Conflicted; - entry.conflict = Some(conflict_kind); - } else { - unstaged.push(FileStatus { - path, - kind: FileStatusKind::Conflicted, - conflict: Some(conflict_kind), - }); - } - } + finalize_status( + &self.spec.workdir, + &repo, + may_have_gitlinks, + staged, + unstaged, + has_conflicted_unstaged, + ) + } + + pub(super) fn worktree_status_impl(&self) -> Result> { + let repo = self._repo.to_thread_local(); + let may_have_gitlinks = self.may_have_gitlink_status_supplement(&repo); + let mut unstaged = Vec::new(); + let direct = collect_index_worktree_status_direct(&repo, &mut unstaged, may_have_gitlinks)?; + + if should_supplement_unmerged_conflicts( + repo.state().is_some(), + direct.has_conflicted_unstaged, + ) { + apply_unmerged_conflicts(&repo, &mut unstaged)?; } - // Only shell out for gitlink/submodule status when the repo is likely - // to contain submodules or gitlinks. This avoids a full `git status` - // subprocess on every refresh for the common case. if may_have_gitlinks { supplement_gitlink_status_from_porcelain( &self.spec.workdir, - &mut staged, + &mut Vec::new(), &mut unstaged, )?; } - fn kind_priority(kind: FileStatusKind) -> u8 { - match kind { - FileStatusKind::Conflicted => 5, - FileStatusKind::Renamed => 4, - FileStatusKind::Deleted => 3, - FileStatusKind::Added => 2, - FileStatusKind::Modified => 1, - FileStatusKind::Untracked => 0, - } - } + sort_and_dedup_status_entries(&mut unstaged); + Ok(unstaged) + } - fn sort_and_dedup(entries: &mut Vec) { - entries.sort_unstable_by(|a, b| { - a.path - .cmp(&b.path) - .then_with(|| kind_priority(b.kind).cmp(&kind_priority(a.kind))) - }); - entries.dedup_by(|a, b| a.path == b.path); + pub(super) fn staged_status_impl(&self) -> Result> { + let repo = self._repo.to_thread_local(); + let head_oid = super::history::gix_head_id_or_none(&repo)?; + let index_stamp = repo_file_stamp(repo.index_path().as_path()); + + if let Some(cached) = self.cached_staged_status(head_oid, &index_stamp) { + return Ok(cached); } - sort_and_dedup(&mut staged); - sort_and_dedup(&mut unstaged); + let Some(head_oid) = head_oid else { + return self.status_impl().map(|status| status.staged); + }; - // gix may report unmerged entries (conflicts) as both Index/Worktree and Tree/Index - // changes, which causes the same path to show up in both sections in the UI. Mirror - // `git status` behavior by showing conflicted paths only once. - let conflicted: HashSet = unstaged - .iter() - .filter(|e| e.kind == FileStatusKind::Conflicted) - .map(|e| e.path.clone()) - .collect(); - if !conflicted.is_empty() { - staged.retain(|e| !conflicted.contains(&e.path)); + // `tree_index_status()` diffs a tree against the index, so resolve HEAD to HEAD^{tree} + // while continuing to cache by commit id. + let head_tree_id = tree_id_for_commit(&repo, &head_oid)?; + let mut staged = collect_staged_status_from_tree_index(&repo, &head_tree_id)?; + if self.may_have_gitlink_status_supplement(&repo) { + supplement_gitlink_status_from_porcelain( + &self.spec.workdir, + &mut staged, + &mut Vec::new(), + )?; } - - Ok(RepoStatus { staged, unstaged }) + sort_and_dedup_status_entries(&mut staged); + remove_conflicted_paths_from_staged( + &mut staged, + gix_unmerged_conflicts(&repo)? + .into_iter() + .map(|(path, _)| path), + ); + self.store_staged_status_cache(Some(head_oid), index_stamp, &staged); + Ok(staged) } pub(super) fn upstream_divergence_impl(&self) -> Result> { let repo = self.reopen_repo()?; head_upstream_divergence(&repo) } + + fn cached_staged_status( + &self, + head_oid: Option, + index_stamp: &RepoFileStamp, + ) -> Option> { + let guard = self + .tree_index_cache + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + guard + .as_ref() + .filter(|cached| cached.head_oid == head_oid && &cached.index_stamp == index_stamp) + .map(|cached| cached.staged.clone()) + } + + fn store_staged_status_cache( + &self, + head_oid: Option, + index_stamp: RepoFileStamp, + staged: &[FileStatus], + ) { + *self + .tree_index_cache + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(TreeIndexCacheEntry { + head_oid, + index_stamp, + staged: staged.to_vec(), + }); + } } fn should_supplement_unmerged_conflicts( @@ -235,6 +266,118 @@ fn should_supplement_unmerged_conflicts( repo_has_in_progress_state || has_conflicted_unstaged } +fn finalize_status( + workdir: &Path, + repo: &gix::Repository, + may_have_gitlinks: bool, + mut staged: Vec, + mut unstaged: Vec, + has_conflicted_unstaged: bool, +) -> Result { + // Some platforms may omit certain unmerged shapes (notably stage-1-only both-deleted + // conflicts) from gix status output. Supplement conflict entries from the index's unmerged + // stages only when the repository is in an in-progress operation or gix already surfaced + // conflicts. + if should_supplement_unmerged_conflicts(repo.state().is_some(), has_conflicted_unstaged) { + apply_unmerged_conflicts(repo, &mut unstaged)?; + } + + // Only shell out for gitlink/submodule status when the repo is likely to contain submodules + // or gitlinks. This avoids a full `git status` subprocess on every refresh for the common + // case. + if may_have_gitlinks { + supplement_gitlink_status_from_porcelain(workdir, &mut staged, &mut unstaged)?; + } + + sort_and_dedup_status_entries(&mut staged); + sort_and_dedup_status_entries(&mut unstaged); + remove_conflicted_paths_from_staged( + &mut staged, + unstaged + .iter() + .filter(|entry| entry.kind == FileStatusKind::Conflicted) + .map(|entry| entry.path.clone()), + ); + + Ok(RepoStatus { staged, unstaged }) +} + +fn apply_unmerged_conflicts(repo: &gix::Repository, unstaged: &mut Vec) -> Result<()> { + for (path, conflict_kind) in gix_unmerged_conflicts(repo)? { + if let Some(entry) = unstaged.iter_mut().find(|entry| entry.path == path) { + entry.kind = FileStatusKind::Conflicted; + entry.conflict = Some(conflict_kind); + } else { + unstaged.push(FileStatus { + path, + kind: FileStatusKind::Conflicted, + conflict: Some(conflict_kind), + }); + } + } + Ok(()) +} + +fn tree_id_for_commit(repo: &gix::Repository, commit_id: &gix::ObjectId) -> Result { + repo.find_commit(*commit_id) + .map_err(|e| Error::new(ErrorKind::Backend(format!("gix commit lookup: {e}"))))? + .tree_id() + .map(|id| id.detach()) + .map_err(|e| Error::new(ErrorKind::Backend(format!("gix commit tree id: {e}")))) +} + +fn collect_staged_status_from_tree_index( + repo: &gix::Repository, + head_oid: &gix::ObjectId, +) -> Result> { + let index = repo + .index_or_empty() + .map_err(|e| Error::new(ErrorKind::Backend(format!("gix index: {e}"))))?; + let mut staged = Vec::new(); + repo.tree_index_status( + head_oid, + &index, + None, + gix::status::tree_index::TrackRenames::AsConfigured, + |change, _, _| { + collect_tree_index_change(change, &mut staged)?; + Ok::<_, Error>(std::ops::ControlFlow::Continue(())) + }, + ) + .map_err(|e| Error::new(ErrorKind::Backend(format!("gix tree/index status: {e}"))))?; + Ok(staged) +} + +fn kind_priority(kind: FileStatusKind) -> u8 { + match kind { + FileStatusKind::Conflicted => 5, + FileStatusKind::Renamed => 4, + FileStatusKind::Deleted => 3, + FileStatusKind::Added => 2, + FileStatusKind::Modified => 1, + FileStatusKind::Untracked => 0, + } +} + +fn sort_and_dedup_status_entries(entries: &mut Vec) { + entries.sort_unstable_by(|a, b| { + a.path + .cmp(&b.path) + .then_with(|| kind_priority(b.kind).cmp(&kind_priority(a.kind))) + }); + entries.dedup_by(|a, b| a.path == b.path); +} + +fn remove_conflicted_paths_from_staged( + staged: &mut Vec, + conflicted: impl IntoIterator, +) { + let conflicted: HashSet = conflicted.into_iter().collect(); + if !conflicted.is_empty() { + staged.retain(|entry| !conflicted.contains(&entry.path)); + } +} + fn repo_file_stamp(path: &Path) -> RepoFileStamp { match std::fs::metadata(path) { Ok(metadata) => RepoFileStamp { @@ -948,11 +1091,13 @@ mod tests { use super::{ apply_porcelain_v2_gitlink_status_record, collect_unmerged_conflicts, conflict_kind_from_stage_mask, map_directory_entry_status, - should_supplement_unmerged_conflicts, + should_supplement_unmerged_conflicts, tree_id_for_commit, }; use gitcomet_core::domain::{FileConflictKind, FileStatusKind}; use rustc_hash::FxHashMap as HashMap; + use std::fs; use std::path::PathBuf; + use std::process::Command; #[test] fn conflict_kind_from_stage_mask_covers_all_shapes() { @@ -1160,4 +1305,56 @@ mod tests { assert_eq!(unstaged[0].path.as_os_str().as_bytes(), b"submodule-\xff"); assert_eq!(unstaged[0].kind, FileStatusKind::Modified); } + + fn git_success(workdir: &std::path::Path, args: &[&str]) { + let output = Command::new("git") + .arg("-C") + .arg(workdir) + .args(args) + .output() + .expect("spawn git"); + assert!( + output.status.success(), + "git {:?} failed\nstdout:\n{}\nstderr:\n{}", + args, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + + #[test] + fn staged_status_impl_resolves_head_to_tree_before_tree_index_diff() { + let tmp = tempfile::tempdir().expect("tempdir"); + let workdir = tmp.path(); + git_success(workdir, &["init"]); + git_success(workdir, &["config", "user.name", "Test User"]); + git_success(workdir, &["config", "user.email", "test@example.com"]); + + fs::write(workdir.join("tracked.txt"), "base\n").expect("write tracked file"); + git_success(workdir, &["add", "tracked.txt"]); + git_success(workdir, &["commit", "-m", "initial"]); + + fs::write(workdir.join("tracked.txt"), "base\nchanged\n").expect("rewrite tracked file"); + git_success(workdir, &["add", "tracked.txt"]); + + let thread_safe_repo = gix::open(workdir).expect("open repo").into_sync(); + let gix_repo = super::super::GixRepo::new(workdir.to_path_buf(), thread_safe_repo); + + let head_commit_id = super::super::history::gix_head_id_or_none( + &gix_repo.reopen_repo().expect("reopen repo"), + ) + .expect("head lookup") + .expect("head commit"); + let head_tree_id = tree_id_for_commit( + &gix_repo.reopen_repo().expect("reopen repo"), + &head_commit_id, + ) + .expect("head tree"); + assert_ne!(head_commit_id, head_tree_id); + + let staged = gix_repo.staged_status_impl().expect("staged status"); + assert_eq!(staged.len(), 1); + assert_eq!(staged[0].path, PathBuf::from("tracked.txt")); + assert_eq!(staged[0].kind, FileStatusKind::Modified); + } } diff --git a/crates/gitcomet-state/src/model.rs b/crates/gitcomet-state/src/model.rs index cf204aee..8a73a810 100644 --- a/crates/gitcomet-state/src/model.rs +++ b/crates/gitcomet-state/src/model.rs @@ -42,19 +42,22 @@ impl RepoLoadsInFlight { pub const TAGS: u32 = 1 << 3; pub const REMOTES: u32 = 1 << 4; pub const REMOTE_BRANCHES: u32 = 1 << 5; - pub const STATUS: u32 = 1 << 6; - pub const STASHES: u32 = 1 << 7; - pub const REFLOG: u32 = 1 << 8; - pub const REBASE_STATE: u32 = 1 << 9; - pub const LOG: u32 = 1 << 10; - pub const MERGE_COMMIT_MESSAGE: u32 = 1 << 11; - pub const REMOTE_TAGS: u32 = 1 << 12; - pub const WORKTREES: u32 = 1 << 13; + pub const STATUS: u32 = Self::WORKTREE_STATUS; + pub const WORKTREE_STATUS: u32 = 1 << 6; + pub const STAGED_STATUS: u32 = 1 << 7; + pub const STASHES: u32 = 1 << 8; + pub const REFLOG: u32 = 1 << 9; + pub const REBASE_STATE: u32 = 1 << 10; + pub const LOG: u32 = 1 << 11; + pub const MERGE_COMMIT_MESSAGE: u32 = 1 << 12; + pub const REMOTE_TAGS: u32 = 1 << 13; + pub const WORKTREES: u32 = 1 << 14; const PRIMARY_REFRESH_FLAGS: u32 = Self::HEAD_BRANCH | Self::UPSTREAM_DIVERGENCE | Self::REBASE_STATE | Self::MERGE_COMMIT_MESSAGE - | Self::STATUS + | Self::WORKTREE_STATUS + | Self::STAGED_STATUS | Self::LOG; pub fn is_in_flight(&self, flag: u32) -> bool { @@ -448,6 +451,7 @@ impl Default for ConflictState { } const BRANCH_SIDEBAR_REV_MIX: u64 = 0x9e37_79b9_7f4a_7c15; +const STATUS_CACHE_REV_MIX: u64 = 0x517c_c1b7_2722_0a95; #[inline] fn mix_branch_sidebar_revs(values: [u64; 7]) -> u64 { @@ -459,6 +463,16 @@ fn mix_branch_sidebar_revs(values: [u64; 7]) -> u64 { acc } +#[inline] +fn mix_status_cache_revs(values: [u64; 2]) -> u64 { + let mut acc = STATUS_CACHE_REV_MIX; + for value in values { + acc ^= value.wrapping_mul(STATUS_CACHE_REV_MIX); + acc = acc.rotate_left(9).wrapping_add(STATUS_CACHE_REV_MIX); + } + acc +} + #[derive(Clone, Debug)] pub struct RepoState { pub id: RepoId, @@ -489,10 +503,15 @@ pub struct RepoState { pub remotes_rev: u64, pub remote_branches: Loadable>>, pub remote_branches_rev: u64, + pub worktree_status: Loadable>>, + pub worktree_status_rev: u64, + pub staged_status: Loadable>>, + pub staged_status_rev: u64, pub status: Loadable>, pub status_rev: u64, - /// Cached flag: true when `status` is `Ready` and at least one unstaged - /// entry has `FileStatusKind::Conflicted`. Recomputed in `set_status`. + /// Cached flag: true when the current unstaged/worktree lane contains at + /// least one `FileStatusKind::Conflicted` entry. Recomputed in + /// `set_worktree_status` and `set_status`. pub has_unstaged_conflicts: bool, pub log: Loadable>, pub log_loading_more: bool, @@ -557,6 +576,10 @@ impl RepoState { remotes_rev: 0, remote_branches: Loadable::NotLoaded, remote_branches_rev: 0, + worktree_status: Loadable::NotLoaded, + worktree_status_rev: 0, + staged_status: Loadable::NotLoaded, + staged_status_rev: 0, status: Loadable::NotLoaded, status_rev: 0, has_unstaged_conflicts: false, @@ -718,18 +741,136 @@ impl RepoState { self.sidebar_data_request = request; } - pub(crate) fn set_status(&mut self, status: Loadable>) { - if self.status == status { + pub(crate) fn set_worktree_status(&mut self, status: Loadable>) { + let status = loadable_into_arc(status); + if self.worktree_status == status { return; } + self.has_unstaged_conflicts = matches!( + &status, + Loadable::Ready(entries) + if entries.iter().any(|entry| entry.kind == FileStatusKind::Conflicted) + ); + self.worktree_status = status; + self.worktree_status_rev = self.worktree_status_rev.wrapping_add(1); + } + + pub(crate) fn set_staged_status(&mut self, status: Loadable>) { + let status = loadable_into_arc(status); + if self.staged_status == status { + return; + } + self.staged_status = status; + self.staged_status_rev = self.staged_status_rev.wrapping_add(1); + } + + pub(crate) fn set_status(&mut self, status: Loadable>) { + let next_worktree = match &status { + Loadable::NotLoaded => Loadable::NotLoaded, + Loadable::Loading => Loadable::Loading, + Loadable::Error(err) => Loadable::Error(err.clone()), + Loadable::Ready(status) => Loadable::Ready(Arc::new(status.unstaged.clone())), + }; + let next_staged = match &status { + Loadable::NotLoaded => Loadable::NotLoaded, + Loadable::Loading => Loadable::Loading, + Loadable::Error(err) => Loadable::Error(err.clone()), + Loadable::Ready(status) => Loadable::Ready(Arc::new(status.staged.clone())), + }; + if self.worktree_status != next_worktree { + self.worktree_status = next_worktree; + self.worktree_status_rev = self.worktree_status_rev.wrapping_add(1); + } self.has_unstaged_conflicts = matches!( &status, Loadable::Ready(s) if s.unstaged.iter().any(|e| e.kind == FileStatusKind::Conflicted) ); + if self.staged_status != next_staged { + self.staged_status = next_staged; + self.staged_status_rev = self.staged_status_rev.wrapping_add(1); + } + if self.status == status { + return; + } self.status = status; self.status_rev = self.status_rev.wrapping_add(1); } + pub fn worktree_status_entries(&self) -> Option<&[FileStatus]> { + match &self.worktree_status { + Loadable::Ready(entries) => Some(entries.as_slice()), + _ => match &self.status { + Loadable::Ready(status) => Some(status.unstaged.as_slice()), + _ => None, + }, + } + } + + pub fn staged_status_entries(&self) -> Option<&[FileStatus]> { + match &self.staged_status { + Loadable::Ready(entries) => Some(entries.as_slice()), + _ => match &self.status { + Loadable::Ready(status) => Some(status.staged.as_slice()), + _ => None, + }, + } + } + + pub fn status_entries_for_area(&self, area: DiffArea) -> Option<&[FileStatus]> { + match area { + DiffArea::Unstaged => self.worktree_status_entries(), + DiffArea::Staged => self.staged_status_entries(), + } + } + + pub fn status_entry_for_path( + &self, + area: DiffArea, + path: &std::path::Path, + ) -> Option<&FileStatus> { + self.status_entries_for_area(area)? + .iter() + .find(|entry| entry.path == path) + } + + pub fn worktree_status_cache_rev(&self) -> u64 { + if self.worktree_status_rev != 0 || !matches!(self.worktree_status, Loadable::NotLoaded) { + self.worktree_status_rev + } else { + self.status_rev + } + } + + pub fn staged_status_cache_rev(&self) -> u64 { + if self.staged_status_rev != 0 || !matches!(self.staged_status, Loadable::NotLoaded) { + self.staged_status_rev + } else { + self.status_rev + } + } + + pub fn status_cache_rev(&self) -> u64 { + let worktree = self.worktree_status_cache_rev(); + let staged = self.staged_status_cache_rev(); + if worktree == 0 && staged == 0 { + 0 + } else { + mix_status_cache_revs([worktree, staged]) + } + } + + pub fn worktree_status_is_loading(&self) -> bool { + matches!(self.worktree_status, Loadable::Loading) + || (matches!(self.worktree_status, Loadable::NotLoaded) + && matches!(self.status, Loadable::Loading)) + } + + pub fn staged_status_is_loading(&self) -> bool { + matches!(self.staged_status, Loadable::Loading) + || (matches!(self.staged_status, Loadable::NotLoaded) + && matches!(self.status, Loadable::Loading)) + } + pub(crate) fn set_log(&mut self, log: Loadable>) { if self.history_state.log == log && self.log == log { return; @@ -979,6 +1120,14 @@ mod tests { ) } + fn file_status(path: &str, kind: FileStatusKind) -> FileStatus { + FileStatus { + path: PathBuf::from(path), + kind, + conflict: None, + } + } + #[test] fn request_primary_refresh_batch_marks_all_primary_loads_when_idle() { let mut loads = RepoLoadsInFlight::default(); @@ -1033,6 +1182,60 @@ mod tests { assert_eq!(repo.status_rev, before + 2); } + #[test] + fn split_status_setters_do_not_bump_legacy_status_rev() { + let mut repo = new_repo(); + repo.set_status(Loadable::Loading); + let status_rev = repo.status_rev; + + repo.set_worktree_status(Loadable::Ready(vec![file_status( + "src/lib.rs", + FileStatusKind::Modified, + )])); + assert_eq!(repo.status_rev, status_rev); + + repo.set_staged_status(Loadable::Ready(vec![file_status( + "src/lib.rs", + FileStatusKind::Added, + )])); + assert_eq!(repo.status_rev, status_rev); + } + + #[test] + fn status_entry_for_path_prefers_split_lane_entries() { + let mut repo = new_repo(); + repo.status = Loadable::Ready(Arc::new(RepoStatus { + unstaged: vec![file_status("legacy.rs", FileStatusKind::Modified)], + staged: vec![file_status("legacy-stage.rs", FileStatusKind::Added)], + })); + repo.status_rev = 1; + repo.set_worktree_status(Loadable::Ready(vec![file_status( + "split.rs", + FileStatusKind::Deleted, + )])); + + let entry = repo + .status_entry_for_path(DiffArea::Unstaged, std::path::Path::new("split.rs")) + .expect("split lane entry"); + assert_eq!(entry.kind, FileStatusKind::Deleted); + assert!( + repo.status_entry_for_path(DiffArea::Unstaged, std::path::Path::new("legacy.rs")) + .is_none() + ); + } + + #[test] + fn status_cache_rev_changes_with_split_lane_revisions() { + let mut repo = new_repo(); + let initial = repo.status_cache_rev(); + repo.set_worktree_status(Loadable::Loading); + let after_worktree = repo.status_cache_rev(); + assert_ne!(after_worktree, initial); + + repo.set_staged_status(Loadable::Loading); + assert_ne!(repo.status_cache_rev(), after_worktree); + } + #[test] fn set_log_bumps_log_rev() { let mut repo = new_repo(); diff --git a/crates/gitcomet-state/src/msg/effect.rs b/crates/gitcomet-state/src/msg/effect.rs index 904856b8..89984257 100644 --- a/crates/gitcomet-state/src/msg/effect.rs +++ b/crates/gitcomet-state/src/msg/effect.rs @@ -25,6 +25,12 @@ pub enum Effect { LoadRemoteBranches { repo_id: RepoId, }, + LoadWorktreeStatus { + repo_id: RepoId, + }, + LoadStagedStatus { + repo_id: RepoId, + }, LoadStatus { repo_id: RepoId, }, diff --git a/crates/gitcomet-state/src/msg/message.rs b/crates/gitcomet-state/src/msg/message.rs index 0969fabb..26f9abb1 100644 --- a/crates/gitcomet-state/src/msg/message.rs +++ b/crates/gitcomet-state/src/msg/message.rs @@ -517,6 +517,14 @@ pub enum InternalMsg { repo_id: RepoId, result: Result, Error>, }, + WorktreeStatusLoaded { + repo_id: RepoId, + result: Result, Error>, + }, + StagedStatusLoaded { + repo_id: RepoId, + result: Result, Error>, + }, StatusLoaded { repo_id: RepoId, result: Result, diff --git a/crates/gitcomet-state/src/msg/message_debug.rs b/crates/gitcomet-state/src/msg/message_debug.rs index f1e01f78..fbb9f0b4 100644 --- a/crates/gitcomet-state/src/msg/message_debug.rs +++ b/crates/gitcomet-state/src/msg/message_debug.rs @@ -55,6 +55,16 @@ impl std::fmt::Debug for InternalMsg { .field("repo_id", repo_id) .field("result", result) .finish(), + InternalMsg::WorktreeStatusLoaded { repo_id, result } => f + .debug_struct("WorktreeStatusLoaded") + .field("repo_id", repo_id) + .field("result", result) + .finish(), + InternalMsg::StagedStatusLoaded { repo_id, result } => f + .debug_struct("StagedStatusLoaded") + .field("repo_id", repo_id) + .field("result", result) + .finish(), InternalMsg::StatusLoaded { repo_id, result } => f .debug_struct("StatusLoaded") .field("repo_id", repo_id) diff --git a/crates/gitcomet-state/src/msg/repo_external_change.rs b/crates/gitcomet-state/src/msg/repo_external_change.rs index 13fe4b1d..47e80cc2 100644 --- a/crates/gitcomet-state/src/msg/repo_external_change.rs +++ b/crates/gitcomet-state/src/msg/repo_external_change.rs @@ -1,6 +1,53 @@ -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum RepoExternalChange { - Worktree, - GitState, - Both, +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct RepoExternalChange { + pub worktree: bool, + pub index: bool, + pub git_state: bool, +} + +impl RepoExternalChange { + #[allow(non_upper_case_globals)] + pub const Worktree: Self = Self::worktree(); + #[allow(non_upper_case_globals)] + pub const Index: Self = Self::index(); + #[allow(non_upper_case_globals)] + pub const GitState: Self = Self::git_state(); + #[allow(non_upper_case_globals)] + pub const Both: Self = Self::all(); + + pub const fn worktree() -> Self { + Self { + worktree: true, + index: false, + git_state: false, + } + } + + pub const fn index() -> Self { + Self { + worktree: false, + index: true, + git_state: false, + } + } + + pub const fn git_state() -> Self { + Self { + worktree: false, + index: false, + git_state: true, + } + } + + pub const fn all() -> Self { + Self { + worktree: true, + index: true, + git_state: true, + } + } + + pub const fn is_empty(self) -> bool { + !self.worktree && !self.index && !self.git_state + } } diff --git a/crates/gitcomet-state/src/store/effects.rs b/crates/gitcomet-state/src/store/effects.rs index d684a284..3662be02 100644 --- a/crates/gitcomet-state/src/store/effects.rs +++ b/crates/gitcomet-state/src/store/effects.rs @@ -93,6 +93,18 @@ fn send_unavailable_git_effect_result( result: Err(git_unavailable_error(runtime)), }, )), + Effect::LoadWorktreeStatus { repo_id } => send(Msg::Internal( + crate::msg::InternalMsg::WorktreeStatusLoaded { + repo_id, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::LoadStagedStatus { repo_id } => { + send(Msg::Internal(crate::msg::InternalMsg::StagedStatusLoaded { + repo_id, + result: Err(git_unavailable_error(runtime)), + })) + } Effect::LoadStatus { repo_id } => { send(Msg::Internal(crate::msg::InternalMsg::StatusLoaded { repo_id, @@ -748,6 +760,12 @@ pub(super) fn schedule_effect( Effect::LoadRemoteBranches { repo_id } => { repo_load::schedule_load_remote_branches(executor, repos, msg_tx, repo_id); } + Effect::LoadWorktreeStatus { repo_id } => { + repo_load::schedule_load_worktree_status(executor, repos, msg_tx, repo_id); + } + Effect::LoadStagedStatus { repo_id } => { + repo_load::schedule_load_staged_status(executor, repos, msg_tx, repo_id); + } Effect::LoadStatus { repo_id } => { repo_load::schedule_load_status(executor, repos, msg_tx, repo_id) } diff --git a/crates/gitcomet-state/src/store/effects/repo_load.rs b/crates/gitcomet-state/src/store/effects/repo_load.rs index d4f45818..1f751004 100644 --- a/crates/gitcomet-state/src/store/effects/repo_load.rs +++ b/crates/gitcomet-state/src/store/effects/repo_load.rs @@ -199,6 +199,70 @@ pub(super) fn schedule_load_status( ); } +pub(super) fn schedule_load_worktree_status( + executor: &TaskExecutor, + repos: &RepoMap, + msg_tx: mpsc::Sender, + repo_id: RepoId, +) { + spawn_with_repo_or_else( + executor, + repos, + repo_id, + msg_tx, + move |repo, msg_tx| { + send_or_log( + &msg_tx, + Msg::Internal(crate::msg::InternalMsg::WorktreeStatusLoaded { + repo_id, + result: repo.worktree_status(), + }), + ); + }, + move |msg_tx| { + send_or_log( + &msg_tx, + Msg::Internal(crate::msg::InternalMsg::WorktreeStatusLoaded { + repo_id, + result: Err(missing_repo_error(repo_id)), + }), + ); + }, + ); +} + +pub(super) fn schedule_load_staged_status( + executor: &TaskExecutor, + repos: &RepoMap, + msg_tx: mpsc::Sender, + repo_id: RepoId, +) { + spawn_with_repo_or_else( + executor, + repos, + repo_id, + msg_tx, + move |repo, msg_tx| { + send_or_log( + &msg_tx, + Msg::Internal(crate::msg::InternalMsg::StagedStatusLoaded { + repo_id, + result: repo.staged_status(), + }), + ); + }, + move |msg_tx| { + send_or_log( + &msg_tx, + Msg::Internal(crate::msg::InternalMsg::StagedStatusLoaded { + repo_id, + result: Err(missing_repo_error(repo_id)), + }), + ); + }, + ); +} + pub(super) fn schedule_load_head_branch( executor: &TaskExecutor, repos: &RepoMap, diff --git a/crates/gitcomet-state/src/store/reducer.rs b/crates/gitcomet-state/src/store/reducer.rs index 85736191..537c9e11 100644 --- a/crates/gitcomet-state/src/store/reducer.rs +++ b/crates/gitcomet-state/src/store/reducer.rs @@ -1042,6 +1042,12 @@ pub(super) fn reduce( Msg::Internal(crate::msg::InternalMsg::RemoteBranchesLoaded { repo_id, result }) => { effects::remote_branches_loaded(state, repo_id, result) } + Msg::Internal(crate::msg::InternalMsg::WorktreeStatusLoaded { repo_id, result }) => { + effects::worktree_status_loaded(state, repo_id, result) + } + Msg::Internal(crate::msg::InternalMsg::StagedStatusLoaded { repo_id, result }) => { + effects::staged_status_loaded(state, repo_id, result) + } Msg::Internal(crate::msg::InternalMsg::StatusLoaded { repo_id, result }) => { effects::status_loaded(state, repo_id, result) } diff --git a/crates/gitcomet-state/src/store/reducer/effects.rs b/crates/gitcomet-state/src/store/reducer/effects.rs index 19f36906..cfae6d87 100644 --- a/crates/gitcomet-state/src/store/reducer/effects.rs +++ b/crates/gitcomet-state/src/store/reducer/effects.rs @@ -113,14 +113,11 @@ fn build_conflict_session( file: &crate::model::ConflictFile, ) -> Option { // Look up the conflict kind from the repo's status entries. - let conflict_kind = match &repo_state.status { - Loadable::Ready(status) => status - .unstaged - .iter() - .find(|e| e.path == file.path && e.kind == FileStatusKind::Conflicted) - .and_then(|e| e.conflict), - _ => None, - }?; + let conflict_kind = repo_state + .worktree_status_entries()? + .iter() + .find(|e| e.path == file.path && e.kind == FileStatusKind::Conflicted) + .and_then(|e| e.conflict)?; let base = ConflictPayload::from_stage_parts(file.base_bytes.clone(), file.base.clone()); let ours = ConflictPayload::from_stage_parts(file.ours_bytes.clone(), file.ours.clone()); @@ -526,34 +523,125 @@ pub(super) fn status_loaded( true } }; - // Replaying an unchanged status payload can self-sustain refresh loops when file-system - // events are produced by the status read itself (for example `.git/index` churn). - if repo_state.loads_in_flight.finish(RepoLoadsInFlight::STATUS) { - if should_replay_pending { - effects.push(Effect::LoadStatus { repo_id }); - } else { - // `finish` marks STATUS back as in-flight when there was pending work. We are - // intentionally dropping that replay request, so clear the in-flight bit too. - let _ = repo_state.loads_in_flight.finish(RepoLoadsInFlight::STATUS); + finish_status_lane_replay( + repo_state, + RepoLoadsInFlight::WORKTREE_STATUS, + repo_id, + should_replay_pending, + Effect::LoadWorktreeStatus { repo_id }, + &mut effects, + ); + finish_status_lane_replay( + repo_state, + RepoLoadsInFlight::STAGED_STATUS, + repo_id, + should_replay_pending, + Effect::LoadStagedStatus { repo_id }, + &mut effects, + ); + } + effects +} + +pub(super) fn worktree_status_loaded( + state: &mut AppState, + repo_id: RepoId, + result: std::result::Result, Error>, +) -> Vec { + let mut effects = Vec::new(); + if let Some(repo_state) = state.repos.iter_mut().find(|r| r.id == repo_id) { + let should_replay_pending = match result { + Ok(next) => { + let status_unchanged = matches!(&repo_state.worktree_status, Loadable::Ready(prev) if prev.as_slice() == next.as_slice()); + if !status_unchanged { + repo_state.set_worktree_status(Loadable::Ready(next)); + } + clear_resolved_conflict_context(repo_state); + !status_unchanged } - } + Err(e) => { + push_diagnostic(repo_state, DiagnosticKind::Error, e.to_string()); + repo_state.set_worktree_status(Loadable::Error(e.to_string())); + true + } + }; + finish_status_lane_replay( + repo_state, + RepoLoadsInFlight::WORKTREE_STATUS, + repo_id, + should_replay_pending, + Effect::LoadWorktreeStatus { repo_id }, + &mut effects, + ); } effects } +pub(super) fn staged_status_loaded( + state: &mut AppState, + repo_id: RepoId, + result: std::result::Result, Error>, +) -> Vec { + let mut effects = Vec::new(); + if let Some(repo_state) = state.repos.iter_mut().find(|r| r.id == repo_id) { + let should_replay_pending = match result { + Ok(next) => { + let status_unchanged = matches!(&repo_state.staged_status, Loadable::Ready(prev) if prev.as_slice() == next.as_slice()); + if !status_unchanged { + repo_state.set_staged_status(Loadable::Ready(next)); + } + !status_unchanged + } + Err(e) => { + push_diagnostic(repo_state, DiagnosticKind::Error, e.to_string()); + repo_state.set_staged_status(Loadable::Error(e.to_string())); + true + } + }; + finish_status_lane_replay( + repo_state, + RepoLoadsInFlight::STAGED_STATUS, + repo_id, + should_replay_pending, + Effect::LoadStagedStatus { repo_id }, + &mut effects, + ); + } + effects +} + +fn finish_status_lane_replay( + repo_state: &mut crate::model::RepoState, + flag: u32, + _repo_id: RepoId, + should_replay_pending: bool, + replay_effect: Effect, + effects: &mut Vec, +) { + // Replaying an unchanged status payload can self-sustain refresh loops when file-system + // events are produced by the status read itself (for example `.git/index` churn). + if repo_state.loads_in_flight.finish(flag) { + if should_replay_pending { + effects.push(replay_effect); + } else { + // `finish` marks the flag back as in-flight when there was pending work. We are + // intentionally dropping that replay request, so clear the in-flight bit too. + let _ = repo_state.loads_in_flight.finish(flag); + } + } +} + /// Clear conflict-file/session state when the tracked conflict path is no longer /// present as an unresolved conflict in status. fn clear_resolved_conflict_context(repo_state: &mut crate::model::RepoState) { let Some(conflict_path) = repo_state.conflict_state.conflict_file_path.as_ref() else { return; }; - let still_conflicted = match &repo_state.status { - Loadable::Ready(status) => status - .unstaged + let still_conflicted = repo_state.worktree_status_entries().is_none_or(|status| { + status .iter() - .any(|entry| entry.path == *conflict_path && entry.kind == FileStatusKind::Conflicted), - _ => true, - }; + .any(|entry| entry.path == *conflict_path && entry.kind == FileStatusKind::Conflicted) + }); if still_conflicted { return; } @@ -1692,7 +1780,7 @@ mod tests { assert_eq!(effects.len(), 1); assert!(matches!( effects[0], - Effect::LoadStatus { repo_id: rid } if rid == repo_id + Effect::LoadWorktreeStatus { repo_id: rid } if rid == repo_id )); { let repo = repo_mut(&mut state, repo_id); diff --git a/crates/gitcomet-state/src/store/reducer/external_and_history.rs b/crates/gitcomet-state/src/store/reducer/external_and_history.rs index c298826e..b4410a31 100644 --- a/crates/gitcomet-state/src/store/reducer/external_and_history.rs +++ b/crates/gitcomet-state/src/store/reducer/external_and_history.rs @@ -6,7 +6,7 @@ use super::util::{ use crate::model::{AppState, DiagnosticKind, Loadable, RepoLoadsInFlight}; use crate::msg::{Effect, RepoExternalChange}; use crate::session; -use gitcomet_core::domain::{DiffTarget, LogCursor, LogPage, LogScope}; +use gitcomet_core::domain::{DiffArea, DiffTarget, LogCursor, LogPage, LogScope}; use gitcomet_core::error::Error; use std::sync::Arc; @@ -93,36 +93,51 @@ pub(super) fn repo_externally_changed( }; // Coalesce refreshes while a refresh is already in flight. - let (mut effects, should_reload_diff) = match change { - RepoExternalChange::Worktree => { - if repo_state - .loads_in_flight - .request(RepoLoadsInFlight::STATUS) - { - (vec![Effect::LoadStatus { repo_id }], true) - } else { - (Vec::new(), false) - } + let mut effects = if change.git_state { + let mut effects = refresh_primary_effects(repo_state); + if repo_state + .loads_in_flight + .request(RepoLoadsInFlight::BRANCHES) + { + effects.push(Effect::LoadBranches { repo_id }); + } + if repo_state + .loads_in_flight + .request(RepoLoadsInFlight::REMOTE_BRANCHES) + { + effects.push(Effect::LoadRemoteBranches { repo_id }); } - RepoExternalChange::GitState | RepoExternalChange::Both => { - let mut effects = refresh_primary_effects(repo_state); - if repo_state + effects + } else { + let mut effects = Vec::new(); + if (change.worktree || change.index) + && repo_state .loads_in_flight - .request(RepoLoadsInFlight::BRANCHES) - { - effects.push(Effect::LoadBranches { repo_id }); - } - if repo_state + .request(RepoLoadsInFlight::WORKTREE_STATUS) + { + effects.push(Effect::LoadWorktreeStatus { repo_id }); + } + if change.index + && repo_state .loads_in_flight - .request(RepoLoadsInFlight::REMOTE_BRANCHES) - { - effects.push(Effect::LoadRemoteBranches { repo_id }); - } - let should_reload_diff = !effects.is_empty(); - (effects, should_reload_diff) + .request(RepoLoadsInFlight::STAGED_STATUS) + { + effects.push(Effect::LoadStagedStatus { repo_id }); } + effects }; + let should_reload_diff = repo_state + .diff_state + .diff_target + .as_ref() + .is_some_and(|target| match target { + DiffTarget::WorkingTree { area, .. } => { + change.git_state || change.index || (*area == DiffArea::Unstaged && change.worktree) + } + DiffTarget::Commit { .. } => false, + }); + if should_reload_diff && let Some(target) = repo_state.diff_state.diff_target.clone() && matches!(target, DiffTarget::WorkingTree { .. }) diff --git a/crates/gitcomet-state/src/store/reducer/util.rs b/crates/gitcomet-state/src/store/reducer/util.rs index 5721f67c..820bb9a6 100644 --- a/crates/gitcomet-state/src/store/reducer/util.rs +++ b/crates/gitcomet-state/src/store/reducer/util.rs @@ -20,8 +20,8 @@ use std::time::SystemTime; pub(super) const DEFAULT_LOG_PAGE_SIZE: usize = 200; const CONFLICT_RELOAD_EFFECT_COUNT: usize = 1; const DIFF_RELOAD_MAX_EFFECTS: usize = 3; -const PRIMARY_REFRESH_MAX_EFFECTS: usize = 5; -const FULL_REFRESH_MAX_EFFECTS: usize = 10; +const PRIMARY_REFRESH_MAX_EFFECTS: usize = 6; +const FULL_REFRESH_MAX_EFFECTS: usize = 11; pub(super) trait EffectAccumulator { fn push_effect(&mut self, effect: Effect); @@ -131,13 +131,9 @@ pub(super) fn diff_target_is_svg(target: &DiffTarget) -> bool { fn diff_target_is_preview_only(repo_state: &RepoState, target: &DiffTarget) -> bool { match target { DiffTarget::WorkingTree { path, area } => { - let Loadable::Ready(status) = &repo_state.status else { + let Some(entries) = repo_state.status_entries_for_area(*area) else { return false; }; - let entries = match area { - DiffArea::Unstaged => status.unstaged.as_slice(), - DiffArea::Staged => status.staged.as_slice(), - }; entries.iter().any(|entry| { entry.path == *path @@ -173,13 +169,7 @@ fn diff_target_preview_text_side( ) -> Option { match target { DiffTarget::WorkingTree { path, area } => { - let Loadable::Ready(status) = &repo_state.status else { - return None; - }; - let entries = match area { - DiffArea::Unstaged => status.unstaged.as_slice(), - DiffArea::Staged => status.staged.as_slice(), - }; + let entries = repo_state.status_entries_for_area(*area)?; entries.iter().find_map(|entry| { (entry.path == *path).then_some(match entry.kind { @@ -291,11 +281,8 @@ pub(super) fn selected_conflict_target<'a>( return None; } - let Loadable::Ready(status) = &repo_state.status else { - return None; - }; - status - .unstaged + repo_state + .worktree_status_entries()? .iter() .find(|entry| entry.path == *path && entry.kind == FileStatusKind::Conflicted) .map(|_| SelectedConflictTarget::Path(path.as_path())) @@ -491,13 +478,14 @@ pub(super) fn append_refresh_primary_effects( effects.push_effect(Effect::LoadHeadBranch { repo_id }); effects.push_effect(Effect::LoadUpstreamDivergence { repo_id }); push_rebase_and_merge_refresh_effect(effects, repo_id); - effects.push_effect(Effect::LoadStatus { repo_id }); + effects.push_effect(Effect::LoadWorktreeStatus { repo_id }); effects.push_effect(Effect::LoadLog { repo_id, scope, limit: DEFAULT_LOG_PAGE_SIZE, cursor: None, }); + effects.push_effect(Effect::LoadStagedStatus { repo_id }); return; } @@ -516,9 +504,9 @@ pub(super) fn append_refresh_primary_effects( append_requested_rebase_and_merge_refresh_effects(repo_state, effects); if repo_state .loads_in_flight - .request(RepoLoadsInFlight::STATUS) + .request(RepoLoadsInFlight::WORKTREE_STATUS) { - effects.push_effect(Effect::LoadStatus { repo_id }); + effects.push_effect(Effect::LoadWorktreeStatus { repo_id }); } if repo_state .loads_in_flight @@ -534,6 +522,12 @@ pub(super) fn append_refresh_primary_effects( cursor: None, }); } + if repo_state + .loads_in_flight + .request(RepoLoadsInFlight::STAGED_STATUS) + { + effects.push_effect(Effect::LoadStagedStatus { repo_id }); + } } pub(super) fn refresh_full_effect_capacity() -> usize { @@ -568,9 +562,9 @@ pub(super) fn append_refresh_full_effects( } if repo_state .loads_in_flight - .request(RepoLoadsInFlight::STATUS) + .request(RepoLoadsInFlight::WORKTREE_STATUS) { - effects.push_effect(Effect::LoadStatus { repo_id }); + effects.push_effect(Effect::LoadWorktreeStatus { repo_id }); } if repo_state.loads_in_flight.request_log( repo_state.history_state.history_scope, @@ -585,6 +579,12 @@ pub(super) fn append_refresh_full_effects( cursor: None, }); } + if repo_state + .loads_in_flight + .request(RepoLoadsInFlight::STAGED_STATUS) + { + effects.push_effect(Effect::LoadStagedStatus { repo_id }); + } if repo_state .loads_in_flight .request(RepoLoadsInFlight::BRANCHES) @@ -1372,9 +1372,14 @@ mod tests { let mut primary = repo_state(1); primary.set_log_loading_more(true); let primary_effects = refresh_primary_effects(&mut primary); - assert_eq!(primary_effects.len(), 5); + assert_eq!(primary_effects.len(), 6); assert!(!primary.log_loading_more); assert!(matches!(primary_effects[0], Effect::LoadHeadBranch { .. })); + assert!( + primary_effects + .iter() + .any(|effect| matches!(effect, Effect::LoadWorktreeStatus { .. })) + ); assert!(matches!( primary_effects[4], Effect::LoadLog { @@ -1382,6 +1387,11 @@ mod tests { .. } )); + assert!( + primary_effects + .iter() + .any(|effect| matches!(effect, Effect::LoadStagedStatus { .. })) + ); assert!( primary_effects .iter() @@ -1391,8 +1401,18 @@ mod tests { let mut full = repo_state(2); full.set_log_loading_more(true); let full_effects = refresh_full_effects(&mut full); - assert_eq!(full_effects.len(), 10); + assert_eq!(full_effects.len(), 11); assert!(!full.log_loading_more); + assert!( + full_effects + .iter() + .any(|effect| matches!(effect, Effect::LoadWorktreeStatus { .. })) + ); + assert!( + full_effects + .iter() + .any(|effect| matches!(effect, Effect::LoadStagedStatus { .. })) + ); assert!( full_effects .iter() diff --git a/crates/gitcomet-state/src/store/repo_monitor.rs b/crates/gitcomet-state/src/store/repo_monitor.rs index 1e6e3121..9c292f9f 100644 --- a/crates/gitcomet-state/src/store/repo_monitor.rs +++ b/crates/gitcomet-state/src/store/repo_monitor.rs @@ -641,7 +641,7 @@ fn repo_monitor_thread( } Ok(MonitorMsg::Event(Err(_))) => { let now = Instant::now(); - if let Some(to_flush) = debouncer.push(RepoExternalChange::Both, now) { + if let Some(to_flush) = debouncer.push(RepoExternalChange::all(), now) { flush(to_flush); } } @@ -686,12 +686,10 @@ fn resolve_git_dir(workdir: &Path) -> Option { } fn merge_change(a: RepoExternalChange, b: RepoExternalChange) -> RepoExternalChange { - use RepoExternalChange::*; - match (a, b) { - (Both, _) | (_, Both) => Both, - (Worktree, GitState) | (GitState, Worktree) => Both, - (Worktree, Worktree) => Worktree, - (GitState, GitState) => GitState, + RepoExternalChange { + worktree: a.worktree || b.worktree, + index: a.index || b.index, + git_state: a.git_state || b.git_state, } } @@ -707,7 +705,7 @@ fn classify_repo_event( // If notify indicates a rescan is needed, assume anything could have changed. if event.need_rescan() { - return Some(RepoExternalChange::Both); + return Some(RepoExternalChange::all()); } // Update ignore rules if the ignore config itself changes. @@ -717,15 +715,16 @@ fn classify_repo_event( .any(|p| is_gitignore_config_path(workdir, git_dir, p)) { *gitignore = GitignoreRules::load(workdir); - return Some(RepoExternalChange::Worktree); + return Some(RepoExternalChange::worktree()); } if event.paths.is_empty() { - return Some(RepoExternalChange::Both); + return Some(RepoExternalChange::all()); } let mut saw_worktree = false; - let mut saw_git = false; + let mut saw_index = false; + let mut saw_git_state = false; let is_dir_hint = path_dir_hint(event); for path in &event.paths { @@ -733,12 +732,10 @@ fn classify_repo_event( continue; } if is_git_related_path(workdir, git_dir, path) { - // Treat `.git/index` updates like worktree changes: they typically reflect staging - // operations and should not trigger branch list refreshes. if is_git_index_path(workdir, git_dir, path) { - saw_worktree = true; + saw_index = true; } else { - saw_git = true; + saw_git_state = true; } } else { if is_ignored_worktree_path_with_hint(workdir, gitignore, path, is_dir_hint) { @@ -746,18 +743,14 @@ fn classify_repo_event( } saw_worktree = true; } - if saw_git && saw_worktree { - return Some(RepoExternalChange::Both); - } } - if saw_git { - Some(RepoExternalChange::GitState) - } else if saw_worktree { - Some(RepoExternalChange::Worktree) - } else { - None - } + let change = RepoExternalChange { + worktree: saw_worktree, + index: saw_index, + git_state: saw_git_state, + }; + (!change.is_empty()).then_some(change) } fn is_git_related_path(workdir: &Path, git_dir: Option<&Path>, path: &Path) -> bool { @@ -975,11 +968,19 @@ mod tests { fn merge_change_coalesces_to_both() { assert_eq!( merge_change(RepoExternalChange::Worktree, RepoExternalChange::GitState), - RepoExternalChange::Both + RepoExternalChange { + worktree: true, + index: false, + git_state: true, + } ); assert_eq!( merge_change(RepoExternalChange::GitState, RepoExternalChange::Worktree), - RepoExternalChange::Both + RepoExternalChange { + worktree: true, + index: false, + git_state: true, + } ); assert_eq!( merge_change(RepoExternalChange::Both, RepoExternalChange::Worktree), @@ -1009,7 +1010,7 @@ mod tests { &mut GitignoreRules::default(), &event ), - Some(RepoExternalChange::Worktree) + Some(RepoExternalChange::Index) ); let event = notify::Event { @@ -1039,7 +1040,11 @@ mod tests { &mut GitignoreRules::default(), &event ), - Some(RepoExternalChange::Both) + Some(RepoExternalChange { + worktree: true, + index: false, + git_state: true, + }) ); } diff --git a/crates/gitcomet-state/src/store/tests.rs b/crates/gitcomet-state/src/store/tests.rs index aed28deb..516eb72e 100644 --- a/crates/gitcomet-state/src/store/tests.rs +++ b/crates/gitcomet-state/src/store/tests.rs @@ -204,6 +204,28 @@ pub(crate) fn staged_auth_test_lock() -> MutexGuard<'static, ()> { .unwrap_or_else(|e| e.into_inner()) } +fn has_worktree_status_effect(effects: &[Effect], repo_id: RepoId) -> bool { + effects.iter().any(|effect| { + matches!( + effect, + Effect::LoadWorktreeStatus { repo_id: candidate } if *candidate == repo_id + ) + }) +} + +fn has_staged_status_effect(effects: &[Effect], repo_id: RepoId) -> bool { + effects.iter().any(|effect| { + matches!( + effect, + Effect::LoadStagedStatus { repo_id: candidate } if *candidate == repo_id + ) + }) +} + +fn has_status_refresh_effects(effects: &[Effect], repo_id: RepoId) -> bool { + has_worktree_status_effect(effects, repo_id) && has_staged_status_effect(effects, repo_id) +} + #[test] fn app_store_clone_dispatches_restore_and_close_paths() { let backend: Arc = Arc::new(FailingBackend); diff --git a/crates/gitcomet-state/src/store/tests/external_and_history.rs b/crates/gitcomet-state/src/store/tests/external_and_history.rs index 3b3c307a..b37b5b30 100644 --- a/crates/gitcomet-state/src/store/tests/external_and_history.rs +++ b/crates/gitcomet-state/src/store/tests/external_and_history.rs @@ -44,9 +44,18 @@ fn external_worktree_change_refreshes_status_and_selected_diff() { &mut repos, &id_alloc, &mut state, - Msg::Internal(crate::msg::InternalMsg::StatusLoaded { + Msg::Internal(crate::msg::InternalMsg::WorktreeStatusLoaded { + repo_id: RepoId(1), + result: Ok(Vec::new()), + }), + ); + reduce( + &mut repos, + &id_alloc, + &mut state, + Msg::Internal(crate::msg::InternalMsg::StagedStatusLoaded { repo_id: RepoId(1), - result: Ok(gitcomet_core::domain::RepoStatus::default()), + result: Ok(Vec::new()), }), ); @@ -61,9 +70,7 @@ fn external_worktree_change_refreshes_status_and_selected_diff() { ); assert!( - effects - .iter() - .any(|e| matches!(e, Effect::LoadStatus { repo_id } if *repo_id == RepoId(1))), + has_worktree_status_effect(&effects, RepoId(1)), "expected status refresh" ); assert!( @@ -189,9 +196,18 @@ fn external_git_state_change_refreshes_history_and_selected_diff() { &mut repos, &id_alloc, &mut state, - Msg::Internal(crate::msg::InternalMsg::StatusLoaded { + Msg::Internal(crate::msg::InternalMsg::WorktreeStatusLoaded { + repo_id: RepoId(1), + result: Ok(Vec::new()), + }), + ); + reduce( + &mut repos, + &id_alloc, + &mut state, + Msg::Internal(crate::msg::InternalMsg::StagedStatusLoaded { repo_id: RepoId(1), - result: Ok(gitcomet_core::domain::RepoStatus::default()), + result: Ok(Vec::new()), }), ); reduce( @@ -244,9 +260,7 @@ fn external_git_state_change_refreshes_history_and_selected_diff() { "expected history refresh" ); assert!( - effects - .iter() - .any(|e| matches!(e, Effect::LoadStatus { repo_id } if *repo_id == RepoId(1))), + has_status_refresh_effects(&effects, RepoId(1)), "expected status refresh" ); assert!( @@ -340,11 +354,7 @@ fn external_git_state_refresh_is_coalesced_and_replayed_once() { .iter() .any(|e| matches!(e, Effect::LoadRebaseAndMergeState { .. })) ); - assert!( - effects1 - .iter() - .any(|e| matches!(e, Effect::LoadStatus { .. })) - ); + assert!(has_status_refresh_effects(&effects1, RepoId(1))); assert!(effects1.iter().any(|e| matches!(e, Effect::LoadLog { .. }))); // Second refresh request while the first one is in flight is coalesced into a single pending @@ -424,14 +434,28 @@ fn external_git_state_refresh_is_coalesced_and_replayed_once() { &mut repos, &id_alloc, &mut state, - Msg::Internal(crate::msg::InternalMsg::StatusLoaded { + Msg::Internal(crate::msg::InternalMsg::WorktreeStatusLoaded { repo_id: RepoId(1), - result: Ok(gitcomet_core::domain::RepoStatus::default()), + result: Ok(Vec::new()), }), ); assert!(matches!( effects.as_slice(), - [Effect::LoadStatus { repo_id: RepoId(1) }] + [Effect::LoadWorktreeStatus { repo_id: RepoId(1) }] + )); + + let effects = reduce( + &mut repos, + &id_alloc, + &mut state, + Msg::Internal(crate::msg::InternalMsg::StagedStatusLoaded { + repo_id: RepoId(1), + result: Ok(Vec::new()), + }), + ); + assert!(matches!( + effects.as_slice(), + [Effect::LoadStagedStatus { repo_id: RepoId(1) }] )); let effects = reduce( @@ -484,9 +508,7 @@ fn external_worktree_refresh_with_unchanged_status_settles_without_replay_loop() }, ); assert!( - effects - .iter() - .any(|e| matches!(e, Effect::LoadStatus { repo_id: rid } if *rid == repo_id)), + has_worktree_status_effect(&effects, repo_id), "expected first worktree event to request status refresh" ); @@ -508,15 +530,15 @@ fn external_worktree_refresh_with_unchanged_status_settles_without_replay_loop() &mut repos, &id_alloc, &mut state, - Msg::Internal(crate::msg::InternalMsg::StatusLoaded { + Msg::Internal(crate::msg::InternalMsg::WorktreeStatusLoaded { repo_id, - result: Ok(RepoStatus::default()), + result: Ok(Vec::new()), }), ); assert!( effects .iter() - .all(|e| !matches!(e, Effect::LoadStatus { repo_id: rid } if *rid == repo_id)), + .all(|e| !matches!(e, Effect::LoadWorktreeStatus { repo_id: rid } if *rid == repo_id)), "unchanged status payload should not replay another status load, got {effects:?}" ); assert!( @@ -534,15 +556,13 @@ fn external_worktree_refresh_with_unchanged_status_settles_without_replay_loop() }, ); assert!( - effects - .iter() - .any(|e| matches!(e, Effect::LoadStatus { repo_id: rid } if *rid == repo_id)), + has_worktree_status_effect(&effects, repo_id), "subsequent real worktree events should still trigger status refresh" ); } #[test] -fn external_worktree_refresh_is_fully_coalesced_while_status_is_in_flight() { +fn external_worktree_refresh_coalesces_status_while_status_is_in_flight() { let mut repos: HashMap> = HashMap::default(); let id_alloc = AtomicU64::new(1); let mut state = AppState::default(); @@ -577,9 +597,7 @@ fn external_worktree_refresh_is_fully_coalesced_while_status_is_in_flight() { }, ); assert!( - effects1 - .iter() - .any(|e| matches!(e, Effect::LoadStatus { repo_id: RepoId(1) })), + has_worktree_status_effect(&effects1, RepoId(1)), "expected first refresh to request status" ); assert!( @@ -603,8 +621,28 @@ fn external_worktree_refresh_is_fully_coalesced_while_status_is_in_flight() { }, ); assert!( - effects2.is_empty(), - "coalesced worktree refresh should not emit duplicate diff/status effects, got {effects2:?}" + !has_worktree_status_effect(&effects2, RepoId(1)), + "coalesced worktree refresh should not emit duplicate status effects, got {effects2:?}" + ); + assert!( + effects2.iter().any(|e| matches!( + e, + Effect::LoadDiff { + repo_id: RepoId(1), + .. + } + )), + "selected diff should still refresh on subsequent worktree changes" + ); + assert!( + effects2.iter().any(|e| matches!( + e, + Effect::LoadDiffFile { + repo_id: RepoId(1), + .. + } + )), + "selected diff file should still refresh on subsequent worktree changes" ); } @@ -636,14 +674,12 @@ fn reload_repo_sets_sections_loading_and_emits_refresh_effects() { assert!(repo_state.remotes.is_loading()); assert!(repo_state.remote_branches.is_loading()); assert!(repo_state.status.is_loading()); + assert!(repo_state.worktree_status_is_loading()); + assert!(repo_state.staged_status_is_loading()); assert!(repo_state.log.is_loading()); assert!(!repo_state.history_state.log_loading_more); assert!(repo_state.merge_commit_message.is_loading()); - assert!( - effects - .iter() - .any(|e| matches!(e, Effect::LoadStatus { repo_id: RepoId(1) })) - ); + assert!(has_status_refresh_effects(&effects, RepoId(1))); } #[test] diff --git a/crates/gitcomet-state/src/store/tests/repo_management.rs b/crates/gitcomet-state/src/store/tests/repo_management.rs index edee2b3d..c05cf046 100644 --- a/crates/gitcomet-state/src/store/tests/repo_management.rs +++ b/crates/gitcomet-state/src/store/tests/repo_management.rs @@ -149,9 +149,7 @@ fn open_repo_focuses_existing_repo_instead_of_opening_duplicate() { ); assert!( - effects - .iter() - .any(|e| matches!(e, Effect::LoadStatus { repo_id } if *repo_id == RepoId(1))), + has_status_refresh_effects(&effects, RepoId(1)), "expected status refresh when focusing an already open repo" ); assert_eq!(state.repos.len(), 2); @@ -215,9 +213,7 @@ fn open_repo_allows_same_basename_in_different_folders() { Msg::OpenRepo(repo_a.clone()), ); assert!( - effects - .iter() - .any(|e| matches!(e, Effect::LoadStatus { repo_id } if *repo_id == RepoId(1))), + has_status_refresh_effects(&effects, RepoId(1)), "expected status refresh when re-focusing repo by path" ); assert_eq!(state.repos.len(), 2); @@ -259,9 +255,7 @@ fn open_repo_refreshes_when_repo_is_already_active() { assert_eq!(state.repos.len(), 1); assert_eq!(state.active_repo, Some(RepoId(1))); assert!( - effects - .iter() - .any(|e| matches!(e, Effect::LoadStatus { repo_id } if *repo_id == RepoId(1))), + has_status_refresh_effects(&effects, RepoId(1)), "expected status refresh when re-opening active repo" ); } @@ -1055,7 +1049,8 @@ fn set_active_repo_waits_for_repo_open_before_refreshing() { assert!( !effects.iter().any(|effect| matches!( effect, - Effect::LoadStatus { .. } + Effect::LoadWorktreeStatus { .. } + | Effect::LoadStagedStatus { .. } | Effect::LoadBranches { .. } | Effect::LoadWorktrees { .. } | Effect::LoadSelectedDiff { .. } @@ -1374,9 +1369,7 @@ fn set_active_repo_refreshes_repo_state_and_selected_diff() { assert_eq!(state.active_repo, Some(repo1)); - let has_status = effects - .iter() - .any(|e| matches!(e, Effect::LoadStatus { repo_id } if *repo_id == repo1)); + let has_status = has_status_refresh_effects(&effects, repo1); let has_log = effects.iter().any(|e| { matches!(e, Effect::LoadLog { repo_id, scope: _, limit: _, cursor: _ } if *repo_id == repo1) }); @@ -1648,11 +1641,7 @@ fn set_active_repo_hot_switch_skips_secondary_refresh_when_metadata_is_ready() { has_worktree_refresh_effect(&effects, repo1), "expected worktrees refresh on activation" ); - assert!( - effects - .iter() - .any(|effect| matches!(effect, Effect::LoadStatus { repo_id } if *repo_id == repo1)) - ); + assert!(has_status_refresh_effects(&effects, repo1)); assert!( effects .iter() @@ -1833,6 +1822,8 @@ fn repo_opened_ok_sets_loading_and_emits_refresh_effects() { assert!(repo_state.remotes.is_loading()); assert!(repo_state.remote_branches.is_loading()); assert!(repo_state.status.is_loading()); + assert!(repo_state.worktree_status_is_loading()); + assert!(repo_state.staged_status_is_loading()); assert!(repo_state.log.is_loading()); assert!(matches!(repo_state.stashes, Loadable::NotLoaded)); assert!(matches!(repo_state.reflog, Loadable::NotLoaded)); @@ -1867,13 +1858,7 @@ fn repo_opened_ok_sets_loading_and_emits_refresh_effects() { ) } )); - assert!(has_effect_for_repo( - &effects, - RepoId(1), - |effect, repo_id| { - matches!(effect, Effect::LoadStatus { repo_id: candidate } if *candidate == repo_id) - } - )); + assert!(has_status_refresh_effects(&effects, RepoId(1))); assert!(has_effect_for_repo( &effects, RepoId(1), @@ -1971,11 +1956,7 @@ fn repo_action_finished_clears_error_and_refreshes() { assert!(state.repos[0].last_error.is_none()); assert!(state.banner_error.is_none()); - assert!( - effects - .iter() - .any(|e| matches!(e, Effect::LoadStatus { repo_id: RepoId(1) })) - ); + assert!(has_status_refresh_effects(&effects, RepoId(1))); } #[test] diff --git a/crates/gitcomet-ui-gpui/src/view/caches.rs b/crates/gitcomet-ui-gpui/src/view/caches.rs index c0a48e7d..51c371dd 100644 --- a/crates/gitcomet-ui-gpui/src/view/caches.rs +++ b/crates/gitcomet-ui-gpui/src/view/caches.rs @@ -685,7 +685,8 @@ pub(super) fn branch_sidebar_cache_store( #[derive(Clone, Debug)] pub(super) struct HistoryWorktreeSummaryCache { pub(super) repo_id: RepoId, - pub(super) status: Arc, + pub(super) worktree_status_rev: u64, + pub(super) staged_status_rev: u64, pub(super) show_row: bool, pub(super) counts: (usize, usize, usize), } diff --git a/crates/gitcomet-ui-gpui/src/view/mod.rs b/crates/gitcomet-ui-gpui/src/view/mod.rs index 64163a7e..8e449feb 100644 --- a/crates/gitcomet-ui-gpui/src/view/mod.rs +++ b/crates/gitcomet-ui-gpui/src/view/mod.rs @@ -2,8 +2,10 @@ use crate::theme::AppTheme; use gitcomet_core::diff::AnnotatedDiffLine; #[cfg(test)] use gitcomet_core::diff::annotate_unified; +#[cfg(test)] +use gitcomet_core::domain::RepoStatus; use gitcomet_core::domain::{ - Branch, Commit, CommitId, DiffArea, DiffTarget, FileStatus, FileStatusKind, RepoStatus, Tag, + Branch, Commit, CommitId, DiffArea, DiffTarget, FileStatus, FileStatusKind, Tag, UpstreamDivergence, }; use gitcomet_core::file_diff::FileDiffRow; diff --git a/crates/gitcomet-ui-gpui/src/view/mod_helpers.rs b/crates/gitcomet-ui-gpui/src/view/mod_helpers.rs index bbabf1d8..9049dac7 100644 --- a/crates/gitcomet-ui-gpui/src/view/mod_helpers.rs +++ b/crates/gitcomet-ui-gpui/src/view/mod_helpers.rs @@ -580,6 +580,102 @@ impl StatusSection { } } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum StatusSectionFilter { + All, + UntrackedOnly, + ExcludeUntracked, +} + +#[derive(Clone, Copy)] +pub(super) struct StatusSectionEntries<'a> { + entries: &'a [FileStatus], + filter: StatusSectionFilter, +} + +impl<'a> StatusSectionEntries<'a> { + pub(super) fn from_repo(repo: &'a RepoState, section: StatusSection) -> Option { + let (entries, filter) = match section { + StatusSection::CombinedUnstaged => { + (repo.worktree_status_entries()?, StatusSectionFilter::All) + } + StatusSection::Untracked => ( + repo.worktree_status_entries()?, + StatusSectionFilter::UntrackedOnly, + ), + StatusSection::Unstaged => ( + repo.worktree_status_entries()?, + StatusSectionFilter::ExcludeUntracked, + ), + StatusSection::Staged => (repo.staged_status_entries()?, StatusSectionFilter::All), + }; + Some(Self { entries, filter }) + } + + pub(super) fn iter(self) -> StatusSectionIter<'a> { + StatusSectionIter { + inner: self.entries.iter(), + filter: self.filter, + } + } + + pub(super) fn len(self) -> usize { + self.iter().count() + } + + pub(super) fn get(self, index: usize) -> Option<&'a FileStatus> { + self.iter().nth(index) + } + + pub(super) fn path_vec(self) -> Vec { + self.iter().map(|entry| entry.path.clone()).collect() + } + + pub(super) fn contains_path(self, path: &std::path::Path) -> bool { + self.iter().any(|entry| entry.path == path) + } +} + +pub(super) struct StatusSectionIter<'a> { + inner: std::slice::Iter<'a, FileStatus>, + filter: StatusSectionFilter, +} + +impl<'a> Iterator for StatusSectionIter<'a> { + type Item = &'a FileStatus; + + fn next(&mut self) -> Option { + self.inner + .find(|entry| status_section_filter_matches(self.filter, entry)) + } +} + +fn status_section_filter_matches(filter: StatusSectionFilter, entry: &FileStatus) -> bool { + match filter { + StatusSectionFilter::All => true, + StatusSectionFilter::UntrackedOnly => entry.kind == FileStatusKind::Untracked, + StatusSectionFilter::ExcludeUntracked => entry.kind != FileStatusKind::Untracked, + } +} + +pub(super) fn status_section_rev(repo: &RepoState, section: StatusSection) -> u64 { + match section { + StatusSection::Staged => repo.staged_status_cache_rev(), + StatusSection::CombinedUnstaged | StatusSection::Untracked | StatusSection::Unstaged => { + repo.worktree_status_cache_rev() + } + } +} + +pub(super) fn status_section_is_loading(repo: &RepoState, section: StatusSection) -> bool { + match section { + StatusSection::Staged => repo.staged_status_is_loading(), + StatusSection::CombinedUnstaged | StatusSection::Untracked | StatusSection::Unstaged => { + repo.worktree_status_is_loading() + } + } +} + #[derive(Clone, Debug, Default)] pub(super) struct StatusMultiSelection { pub(super) untracked: Vec, @@ -634,9 +730,10 @@ impl StatusMultiSelection { } } +#[cfg(test)] pub(super) fn reconcile_status_multi_selection( selection: &mut StatusMultiSelection, - status: &RepoStatus, + status: &gitcomet_core::domain::RepoStatus, ) { let mut untracked_paths: HashSet<&std::path::Path> = HashSet::with_capacity_and_hasher(status.unstaged.len(), Default::default()); @@ -693,6 +790,69 @@ pub(super) fn reconcile_status_multi_selection( } } +pub(super) fn reconcile_status_multi_selection_with_repo( + selection: &mut StatusMultiSelection, + repo: &RepoState, +) { + if let Some(worktree) = repo.worktree_status_entries() { + let mut untracked_paths: HashSet<&std::path::Path> = + HashSet::with_capacity_and_hasher(worktree.len(), Default::default()); + let mut unstaged_paths: HashSet<&std::path::Path> = + HashSet::with_capacity_and_hasher(worktree.len(), Default::default()); + for entry in worktree { + unstaged_paths.insert(entry.path.as_path()); + if entry.kind == FileStatusKind::Untracked { + untracked_paths.insert(entry.path.as_path()); + } + } + + selection + .untracked + .retain(|p| untracked_paths.contains(&p.as_path())); + if selection + .untracked_anchor + .as_ref() + .is_some_and(|a| !untracked_paths.contains(&a.as_path())) + { + selection.untracked_anchor = None; + } + + selection + .unstaged + .retain(|p| unstaged_paths.contains(&p.as_path())); + if selection + .unstaged_anchor + .as_ref() + .is_some_and(|a| !unstaged_paths.contains(&a.as_path())) + { + selection.unstaged_anchor = None; + selection.unstaged_anchor_index = None; + selection.unstaged_anchor_status_rev = None; + } + } + + if let Some(staged) = repo.staged_status_entries() { + let mut staged_paths: HashSet<&std::path::Path> = + HashSet::with_capacity_and_hasher(staged.len(), Default::default()); + for entry in staged { + staged_paths.insert(entry.path.as_path()); + } + + selection + .staged + .retain(|p| staged_paths.contains(&p.as_path())); + if selection + .staged_anchor + .as_ref() + .is_some_and(|a| !staged_paths.contains(&a.as_path())) + { + selection.staged_anchor = None; + selection.staged_anchor_index = None; + selection.staged_anchor_status_rev = None; + } + } +} + #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] pub(super) enum ThreeWayColumn { Base, diff --git a/crates/gitcomet-ui-gpui/src/view/panels/action_bar.rs b/crates/gitcomet-ui-gpui/src/view/panels/action_bar.rs index 53753a15..642bf812 100644 --- a/crates/gitcomet-ui-gpui/src/view/panels/action_bar.rs +++ b/crates/gitcomet-ui-gpui/src/view/panels/action_bar.rs @@ -69,7 +69,7 @@ impl ActionBarView { repo.upstream_divergence_rev.hash(&mut hasher); repo.merge_message_rev.hash(&mut hasher); repo.ops_rev.hash(&mut hasher); - repo.status_rev.hash(&mut hasher); + repo.status_cache_rev().hash(&mut hasher); repo.loads_in_flight.any_in_flight().hash(&mut hasher); } @@ -265,9 +265,12 @@ impl Render for ActionBarView { let can_stash = self .active_repo() - .and_then(|r| match &r.status { - Loadable::Ready(s) => Some(!s.staged.is_empty() || !s.unstaged.is_empty()), - _ => None, + .map(|repo| { + repo.worktree_status_entries() + .is_some_and(|entries| !entries.is_empty()) + || repo + .staged_status_entries() + .is_some_and(|entries| !entries.is_empty()) }) .unwrap_or(false); diff --git a/crates/gitcomet-ui-gpui/src/view/panels/layout.rs b/crates/gitcomet-ui-gpui/src/view/panels/layout.rs index a64fafaf..22342f72 100644 --- a/crates/gitcomet-ui-gpui/src/view/panels/layout.rs +++ b/crates/gitcomet-ui-gpui/src/view/panels/layout.rs @@ -121,7 +121,7 @@ fn explicit_status_section_action_paths( } fn active_status_section_action_path( - status: &RepoStatus, + repo: &RepoState, diff_target: Option<&DiffTarget>, section: StatusSection, ) -> Option { @@ -132,24 +132,13 @@ fn active_status_section_action_path( return None; } - let matches_section = match section { - StatusSection::CombinedUnstaged => status.unstaged.iter().any(|entry| entry.path == *path), - StatusSection::Untracked => status - .unstaged - .iter() - .any(|entry| entry.path == *path && entry.kind == FileStatusKind::Untracked), - StatusSection::Unstaged => status - .unstaged - .iter() - .any(|entry| entry.path == *path && entry.kind != FileStatusKind::Untracked), - StatusSection::Staged => status.staged.iter().any(|entry| entry.path == *path), - }; - - matches_section.then(|| path.clone()) + StatusSectionEntries::from_repo(repo, section) + .is_some_and(|entries| entries.contains_path(path.as_path())) + .then(|| path.clone()) } fn status_section_action_selection( - status: &RepoStatus, + repo: &RepoState, diff_target: Option<&DiffTarget>, selection: Option<&StatusMultiSelection>, section: StatusSection, @@ -164,7 +153,7 @@ fn status_section_action_selection( } } - active_status_section_action_path(status, diff_target, section) + active_status_section_action_path(repo, diff_target, section) .map(|path| StatusSectionActionSelection { paths: vec![path], from_explicit_selection: false, @@ -181,12 +170,9 @@ impl DetailsPaneView { let Some(repo) = self.active_repo().filter(|repo| repo.id == repo_id) else { return StatusSectionActionSelection::default(); }; - let Loadable::Ready(status) = &repo.status else { - return StatusSectionActionSelection::default(); - }; status_section_action_selection( - status, + repo, repo.diff_state.diff_target.as_ref(), self.status_multi_selection.get(&repo_id), section, @@ -406,10 +392,9 @@ impl DetailsPaneView { if repo.commit_in_flight > 0 { return false; } - let staged_count = match &repo.status { - Loadable::Ready(status) => status.staged.len(), - _ => 0, - }; + let staged_count = repo + .staged_status_entries() + .map_or(0, |entries| entries.len()); let is_merge_active = merge_active(Some(repo)); commit_allowed(is_merge_active, staged_count) && !message.trim().is_empty() } @@ -863,31 +848,43 @@ impl DetailsPaneView { .active_repo() .map(|r| r.local_actions_in_flight > 0) .unwrap_or(false); - let (staged_count, unstaged_count) = self + let (staged_count, unstaged_count, untracked_count, split_unstaged_count) = self .active_repo() - .and_then(|r| match &r.status { - Loadable::Ready(s) => Some((s.staged.len(), s.unstaged.len())), - _ => None, + .map(|repo| { + ( + StatusSectionEntries::from_repo(repo, StatusSection::Staged) + .map_or(0, StatusSectionEntries::len), + StatusSectionEntries::from_repo(repo, StatusSection::CombinedUnstaged) + .map_or(0, StatusSectionEntries::len), + StatusSectionEntries::from_repo(repo, StatusSection::Untracked) + .map_or(0, StatusSectionEntries::len), + StatusSectionEntries::from_repo(repo, StatusSection::Unstaged) + .map_or(0, StatusSectionEntries::len), + ) }) - .unwrap_or((0, 0)); - let (untracked_count, split_unstaged_count, untracked_paths, split_unstaged_paths) = self + .unwrap_or((0, 0, 0, 0)); + let (untracked_paths, split_unstaged_paths) = self .active_repo() - .and_then(|r| match &r.status { - Loadable::Ready(s) => { - let mut untracked = Vec::new(); - let mut tracked = Vec::new(); - for entry in &s.unstaged { - if entry.kind == FileStatusKind::Untracked { - untracked.push(entry.path.clone()); - } else { - tracked.push(entry.path.clone()); - } - } - Some((untracked.len(), tracked.len(), untracked, tracked)) - } - _ => None, + .map(|repo| { + ( + StatusSectionEntries::from_repo(repo, StatusSection::Untracked) + .map_or_else(Vec::new, StatusSectionEntries::path_vec), + StatusSectionEntries::from_repo(repo, StatusSection::Unstaged) + .map_or_else(Vec::new, StatusSectionEntries::path_vec), + ) }) - .unwrap_or_else(|| (0, 0, Vec::new(), Vec::new())); + .unwrap_or_else(|| (Vec::new(), Vec::new())); + let (unstaged_loading, untracked_loading, split_unstaged_loading, staged_loading) = self + .active_repo() + .map(|repo| { + ( + status_section_is_loading(repo, StatusSection::CombinedUnstaged), + status_section_is_loading(repo, StatusSection::Untracked), + status_section_is_loading(repo, StatusSection::Unstaged), + status_section_is_loading(repo, StatusSection::Staged), + ) + }) + .unwrap_or((false, false, false, false)); let repo_id = self.active_repo_id(); let selected_combined_unstaged = repo_id @@ -1299,25 +1296,33 @@ impl DetailsPaneView { actions.child(unstage_all).into_any_element() }; - let unstaged_body = if unstaged_count == 0 { + let unstaged_body = if unstaged_loading { + components::empty_state(theme, "Unstaged", "Loading").into_any_element() + } else if unstaged_count == 0 { components::empty_state(theme, "Unstaged", "Clean.").into_any_element() } else { self.status_list(cx, StatusSection::CombinedUnstaged, unstaged_count) }; - let untracked_body = if untracked_count == 0 { + let untracked_body = if untracked_loading { + components::empty_state(theme, "Untracked", "Loading").into_any_element() + } else if untracked_count == 0 { components::empty_state(theme, "Untracked", "No untracked files.").into_any_element() } else { self.status_list(cx, StatusSection::Untracked, untracked_count) }; - let split_unstaged_body = if split_unstaged_count == 0 { + let split_unstaged_body = if split_unstaged_loading { + components::empty_state(theme, "Unstaged", "Loading").into_any_element() + } else if split_unstaged_count == 0 { components::empty_state(theme, "Unstaged", "Clean.").into_any_element() } else { self.status_list(cx, StatusSection::Unstaged, split_unstaged_count) }; - let staged_list = if staged_count == 0 { + let staged_list = if staged_loading { + components::empty_state(theme, "Staged", "Loading").into_any_element() + } else if staged_count == 0 { components::empty_state(theme, "Staged", "No staged changes.").into_any_element() } else { self.status_list(cx, StatusSection::Staged, staged_count) @@ -1951,6 +1956,7 @@ mod tests { use gitcomet_core::domain::RepoSpec; use gitcomet_state::model::{Loadable, RepoId, RepoState}; use std::path::PathBuf; + use std::sync::Arc; fn test_repo() -> RepoState { RepoState::new_opening( @@ -1969,6 +1975,17 @@ mod tests { } } + fn repo_with_status(status: RepoStatus) -> RepoState { + let mut repo = test_repo(); + repo.worktree_status = Loadable::Ready(Arc::new(status.unstaged.clone())); + repo.worktree_status_rev = 1; + repo.staged_status = Loadable::Ready(Arc::new(status.staged.clone())); + repo.staged_status_rev = 1; + repo.status = Loadable::Ready(status.into()); + repo.status_rev = 1; + repo + } + #[test] fn commit_allowed_when_staged_changes_exist() { assert!(commit_allowed(false, 1)); @@ -2047,17 +2064,17 @@ mod tests { #[test] fn status_section_action_selection_falls_back_to_active_combined_unstaged_row() { - let status = RepoStatus { + let repo = repo_with_status(RepoStatus { unstaged: vec![file_status("src/lib.rs", FileStatusKind::Modified)], staged: Vec::new(), - }; + }); let diff_target = DiffTarget::WorkingTree { path: PathBuf::from("src/lib.rs"), area: DiffArea::Unstaged, }; let selection = status_section_action_selection( - &status, + &repo, Some(&diff_target), None, StatusSection::CombinedUnstaged, @@ -2075,26 +2092,26 @@ mod tests { #[test] fn status_section_action_selection_limits_active_row_to_matching_split_section() { - let status = RepoStatus { + let repo = repo_with_status(RepoStatus { unstaged: vec![ file_status("new.txt", FileStatusKind::Untracked), file_status("src/lib.rs", FileStatusKind::Modified), ], staged: Vec::new(), - }; + }); let diff_target = DiffTarget::WorkingTree { path: PathBuf::from("new.txt"), area: DiffArea::Unstaged, }; let untracked = status_section_action_selection( - &status, + &repo, Some(&diff_target), None, StatusSection::Untracked, ); let unstaged = status_section_action_selection( - &status, + &repo, Some(&diff_target), None, StatusSection::Unstaged, @@ -2114,7 +2131,7 @@ mod tests { fn status_section_action_selection_prefers_explicit_selection_over_active_row() { let selected_a = PathBuf::from("src/lib.rs"); let selected_b = PathBuf::from("src/main.rs"); - let status = RepoStatus { + let repo = repo_with_status(RepoStatus { unstaged: vec![ file_status( selected_a.to_string_lossy().as_ref(), @@ -2126,7 +2143,7 @@ mod tests { ), ], staged: Vec::new(), - }; + }); let diff_target = DiffTarget::WorkingTree { path: PathBuf::from("src/other.rs"), area: DiffArea::Unstaged, @@ -2137,7 +2154,7 @@ mod tests { }; let action_selection = status_section_action_selection( - &status, + &repo, Some(&diff_target), Some(&selection), StatusSection::CombinedUnstaged, diff --git a/crates/gitcomet-ui-gpui/src/view/panels/main/diff_view.rs b/crates/gitcomet-ui-gpui/src/view/panels/main/diff_view.rs index f0c66566..4a047e67 100644 --- a/crates/gitcomet-ui-gpui/src/view/panels/main/diff_view.rs +++ b/crates/gitcomet-ui-gpui/src/view/panels/main/diff_view.rs @@ -77,16 +77,10 @@ impl MainPaneView { if *area != DiffArea::Unstaged { return None; } - match &repo.status { - Loadable::Ready(status) => { - let conflict = status - .unstaged - .iter() - .find(|e| e.path == *path && e.kind == FileStatusKind::Conflicted)?; - Some((path.clone(), conflict.conflict)) - } - _ => None, - } + let conflict = repo + .status_entry_for_path(DiffArea::Unstaged, path.as_path()) + .filter(|entry| entry.kind == FileStatusKind::Conflicted)?; + Some((path.clone(), conflict.conflict)) }); let (conflict_target_path, conflict_kind) = conflict_target .map(|(path, kind)| (Some(path), kind)) @@ -2937,18 +2931,16 @@ impl MainPaneView { let path = path.clone(); let area = *area; let change_tracking_view = this.active_change_tracking_view(cx); - let next_path_in_section = match &repo.status { - Loadable::Ready(status) => status_nav::status_navigation_context( - status, - &diff_target, - change_tracking_view, - ) - .and_then(|navigation| navigation.next_or_prev_path()), - _ => None, - }; + let next_path_in_section = status_nav::status_navigation_context_for_repo( + repo, + &diff_target, + change_tracking_view, + ) + .and_then(|navigation| navigation.next_or_prev_path()); + let status_ready = repo.status_entries_for_area(area).is_some(); - match (&repo.status, area) { - (Loadable::Ready(_status), DiffArea::Unstaged) => { + match (status_ready, area) { + (true, DiffArea::Unstaged) => { this.store.dispatch(Msg::StagePath { repo_id, path: path.clone(), @@ -2965,7 +2957,7 @@ impl MainPaneView { this.clear_diff_selection_or_exit(repo_id, cx); } } - (Loadable::Ready(_status), DiffArea::Staged) => { + (true, DiffArea::Staged) => { this.store.dispatch(Msg::UnstagePath { repo_id, path: path.clone(), @@ -2982,13 +2974,13 @@ impl MainPaneView { this.clear_diff_selection_or_exit(repo_id, cx); } } - (_, DiffArea::Unstaged) => { + (false, DiffArea::Unstaged) => { this.store.dispatch(Msg::StagePath { repo_id, path: path.clone(), }); } - (_, DiffArea::Staged) => { + (false, DiffArea::Staged) => { this.store.dispatch(Msg::UnstagePath { repo_id, path: path.clone(), diff --git a/crates/gitcomet-ui-gpui/src/view/panels/main/diff_view_helpers.rs b/crates/gitcomet-ui-gpui/src/view/panels/main/diff_view_helpers.rs index 3a9939e7..c4c2e334 100644 --- a/crates/gitcomet-ui-gpui/src/view/panels/main/diff_view_helpers.rs +++ b/crates/gitcomet-ui-gpui/src/view/panels/main/diff_view_helpers.rs @@ -8,15 +8,9 @@ impl MainPaneView { let (icon, color, text): (Option<&'static str>, gpui::Rgba, SharedString) = match t { DiffTarget::WorkingTree { path, area } => { - let kind = self.active_repo().and_then(|repo| match &repo.status { - Loadable::Ready(status) => { - let list = match area { - DiffArea::Unstaged => &status.unstaged, - DiffArea::Staged => &status.staged, - }; - list.iter().find(|e| e.path == *path).map(|e| e.kind) - } - _ => None, + let kind = self.active_repo().and_then(|repo| { + repo.status_entry_for_path(*area, path.as_path()) + .map(|entry| entry.kind) }); let (icon, color) = match kind.unwrap_or(FileStatusKind::Modified) { @@ -102,18 +96,19 @@ impl MainPaneView { let repo = self.active_repo()?; let change_tracking_view = self.active_change_tracking_view(cx); - let (prev, next) = match &repo.status { - Loadable::Ready(status) => repo - .diff_state - .diff_target - .as_ref() - .and_then(|target| { - status_nav::status_navigation_context(status, target, change_tracking_view) - }) - .map(|navigation| (navigation.prev_ix(), navigation.next_ix())) - .unwrap_or((None, None)), - _ => (None, None), - }; + let (prev, next) = repo + .diff_state + .diff_target + .as_ref() + .and_then(|target| { + status_nav::status_navigation_context_for_repo( + repo, + target, + change_tracking_view, + ) + }) + .map(|navigation| (navigation.prev_ix(), navigation.next_ix())) + .unwrap_or((None, None)); let prev_disabled = prev.is_none(); let next_disabled = next.is_none(); diff --git a/crates/gitcomet-ui-gpui/src/view/panels/main/status_nav.rs b/crates/gitcomet-ui-gpui/src/view/panels/main/status_nav.rs index 4bdbddc7..bb3e977c 100644 --- a/crates/gitcomet-ui-gpui/src/view/panels/main/status_nav.rs +++ b/crates/gitcomet-ui-gpui/src/view/panels/main/status_nav.rs @@ -31,6 +31,7 @@ impl<'a> StatusNavigationContext<'a> { } } +#[cfg(test)] fn status_navigation_section_for_target( status: &gitcomet_core::domain::RepoStatus, change_tracking_view: ChangeTrackingView, @@ -56,6 +57,7 @@ fn status_navigation_section_for_target( } } +#[cfg(test)] fn status_navigation_entries_for_section( status: &gitcomet_core::domain::RepoStatus, section: StatusSection, @@ -76,6 +78,7 @@ fn status_navigation_entries_for_section( } } +#[cfg(test)] pub(super) fn status_navigation_context<'a>( status: &'a gitcomet_core::domain::RepoStatus, diff_target: &DiffTarget, @@ -95,6 +98,39 @@ pub(super) fn status_navigation_context<'a>( }) } +pub(super) fn status_navigation_context_for_repo<'a>( + repo: &'a RepoState, + diff_target: &DiffTarget, + change_tracking_view: ChangeTrackingView, +) -> Option> { + let DiffTarget::WorkingTree { path, area } = diff_target else { + return None; + }; + let section = match area { + DiffArea::Staged => StatusSection::Staged, + DiffArea::Unstaged => match change_tracking_view { + ChangeTrackingView::Combined => StatusSection::CombinedUnstaged, + ChangeTrackingView::SplitUntracked => { + let entry = repo.status_entry_for_path(DiffArea::Unstaged, path.as_path())?; + if entry.kind == gitcomet_core::domain::FileStatusKind::Untracked { + StatusSection::Untracked + } else { + StatusSection::Unstaged + } + } + }, + }; + let entries: Vec<_> = StatusSectionEntries::from_repo(repo, section)? + .iter() + .collect(); + let current_ix = entries.iter().position(|entry| entry.path == *path)?; + Some(StatusNavigationContext { + section, + entries, + current_ix, + }) +} + impl MainPaneView { pub(in crate::view) fn try_select_adjacent_status_file( &mut self, @@ -106,11 +142,9 @@ impl MainPaneView { let change_tracking_view = self.active_change_tracking_view(cx); let Some((section, area, target_ix, target_path, is_conflicted)) = (|| { let repo = self.active_repo()?; - let Loadable::Ready(status) = &repo.status else { - return None; - }; let diff_target = repo.diff_state.diff_target.as_ref()?; - let navigation = status_navigation_context(status, diff_target, change_tracking_view)?; + let navigation = + status_navigation_context_for_repo(repo, diff_target, change_tracking_view)?; let target_ix = navigation.adjacent_ix(direction)?; let entry = navigation.entries.get(target_ix)?; let target_path = entry.path.clone(); diff --git a/crates/gitcomet-ui-gpui/src/view/panels/popover/context_menu.rs b/crates/gitcomet-ui-gpui/src/view/panels/popover/context_menu.rs index 98ce7ac0..4f7de944 100644 --- a/crates/gitcomet-ui-gpui/src/view/panels/popover/context_menu.rs +++ b/crates/gitcomet-ui-gpui/src/view/panels/popover/context_menu.rs @@ -880,14 +880,10 @@ impl PopoverHost { .repos .iter() .find(|r| r.id == repo_id) - .and_then(|r| match &r.status { - Loadable::Ready(status) => status - .unstaged - .iter() - .chain(status.staged.iter()) - .find(|s| s.path == path) - .map(|s| s.kind), - _ => None, + .and_then(|repo| { + repo.status_entry_for_path(DiffArea::Unstaged, path.as_path()) + .or_else(|| repo.status_entry_for_path(DiffArea::Staged, path.as_path())) + .map(|status| status.kind) }) .is_some_and(|kind| matches!(kind, FileStatusKind::Untracked | FileStatusKind::Added)); diff --git a/crates/gitcomet-ui-gpui/src/view/panels/popover/context_menu/status_file.rs b/crates/gitcomet-ui-gpui/src/view/panels/popover/context_menu/status_file.rs index 82912167..1ec9e722 100644 --- a/crates/gitcomet-ui-gpui/src/view/panels/popover/context_menu/status_file.rs +++ b/crates/gitcomet-ui-gpui/src/view/panels/popover/context_menu/status_file.rs @@ -25,39 +25,32 @@ pub(super) fn model( .repos .iter() .find(|r| r.id == repo_id) - .and_then(|r| match &r.status { - Loadable::Ready(status) => { - let unstaged_kind = status - .unstaged - .iter() - .find(|s| &s.path == path) - .map(|s| s.kind); - let staged_kind = status - .staged - .iter() - .find(|s| &s.path == path) - .map(|s| s.kind); + .map(|repo| { + let unstaged_kind = repo + .status_entry_for_path(DiffArea::Unstaged, path.as_path()) + .map(|status| status.kind); + let staged_kind = repo + .status_entry_for_path(DiffArea::Staged, path.as_path()) + .map(|status| status.kind); - Some(( - matches!( - unstaged_kind, - Some(gitcomet_core::domain::FileStatusKind::Conflicted) - ) || matches!( - staged_kind, - Some(gitcomet_core::domain::FileStatusKind::Conflicted) - ), - matches!( - unstaged_kind, - Some(gitcomet_core::domain::FileStatusKind::Conflicted) - ), - unstaged_kind.is_some(), - matches!( - staged_kind, - Some(gitcomet_core::domain::FileStatusKind::Added) - ), - )) - } - _ => None, + ( + matches!( + unstaged_kind, + Some(gitcomet_core::domain::FileStatusKind::Conflicted) + ) || matches!( + staged_kind, + Some(gitcomet_core::domain::FileStatusKind::Conflicted) + ), + matches!( + unstaged_kind, + Some(gitcomet_core::domain::FileStatusKind::Conflicted) + ), + unstaged_kind.is_some(), + matches!( + staged_kind, + Some(gitcomet_core::domain::FileStatusKind::Added) + ), + ) }) .unwrap_or((false, false, false, false)); diff --git a/crates/gitcomet-ui-gpui/src/view/panels/popover/fingerprint.rs b/crates/gitcomet-ui-gpui/src/view/panels/popover/fingerprint.rs index 9c3f92c9..c3b9ed9d 100644 --- a/crates/gitcomet-ui-gpui/src/view/panels/popover/fingerprint.rs +++ b/crates/gitcomet-ui-gpui/src/view/panels/popover/fingerprint.rs @@ -160,7 +160,7 @@ fn hash_repo_for_popover(repo: &RepoState, popover: &PopoverKind, has PopoverKind::StashPrompt => { repo.stashes_rev.hash(hasher); - view_fingerprint::hash_loadable_arc(&repo.status, hasher); + repo.status_cache_rev().hash(hasher); } PopoverKind::StashDropConfirm { .. } | PopoverKind::StashMenu { .. } => { repo.stashes_rev.hash(hasher); @@ -189,7 +189,7 @@ fn hash_repo_for_popover(repo: &RepoState, popover: &PopoverKind, has repo.diff_state.diff_target, Some(DiffTarget::WorkingTree { .. }) ) { - view_fingerprint::hash_loadable_arc(&repo.status, hasher); + repo.status_cache_rev().hash(hasher); } } diff --git a/crates/gitcomet-ui-gpui/src/view/panels/repo_tabs_bar.rs b/crates/gitcomet-ui-gpui/src/view/panels/repo_tabs_bar.rs index aa17c559..83c00721 100644 --- a/crates/gitcomet-ui-gpui/src/view/panels/repo_tabs_bar.rs +++ b/crates/gitcomet-ui-gpui/src/view/panels/repo_tabs_bar.rs @@ -105,6 +105,10 @@ impl RepoTabsBarView { repo.missing_on_disk && !show_spinner } + fn repo_tab_click_message(active_repo: Option, repo_id: RepoId) -> Option { + (active_repo != Some(repo_id)).then_some(Msg::SetActiveRepo { repo_id }) + } + pub(in super::super) fn new( store: Arc, ui_model: Entity, @@ -455,7 +459,10 @@ impl Render for RepoTabsBarView { } })) .on_click(cx.listener(move |this, _e: &ClickEvent, _w, _cx| { - this.store.dispatch(Msg::SetActiveRepo { repo_id }); + if let Some(msg) = Self::repo_tab_click_message(this.active_repo_id(), repo_id) + { + this.store.dispatch(msg); + } })); bar = bar.tab(tab); @@ -566,6 +573,7 @@ mod tests { use super::{RepoTabsBarView, repo_tab_insert_before_for_drag_cursor}; use gitcomet_core::domain::RepoSpec; use gitcomet_state::model::{RepoId, RepoState}; + use gitcomet_state::msg::Msg; use std::path::PathBuf; fn repo_state(path: &str) -> RepoState { @@ -631,4 +639,17 @@ mod tests { None ); } + + #[test] + fn repo_tab_click_message_is_none_for_active_repo() { + assert!(RepoTabsBarView::repo_tab_click_message(Some(RepoId(5)), RepoId(5)).is_none()); + } + + #[test] + fn repo_tab_click_message_activates_inactive_repo() { + assert!(matches!( + RepoTabsBarView::repo_tab_click_message(Some(RepoId(5)), RepoId(6)), + Some(Msg::SetActiveRepo { repo_id: RepoId(6) }) + )); + } } diff --git a/crates/gitcomet-ui-gpui/src/view/panes/details.rs b/crates/gitcomet-ui-gpui/src/view/panes/details.rs index 4f10938b..e313c6f1 100644 --- a/crates/gitcomet-ui-gpui/src/view/panes/details.rs +++ b/crates/gitcomet-ui-gpui/src/view/panes/details.rs @@ -40,7 +40,7 @@ pub(in super::super) struct DetailsPaneView { pub(in super::super) commit_message_programmatic_change: bool, pub(in super::super) status_multi_selection: HashMap, - pub(in super::super) status_multi_selection_last_status: HashMap>, + pub(in super::super) status_multi_selection_last_status: HashMap, pub(in super::super) commit_details_delay: Option, pub(in super::super) commit_details_delay_seq: u64, @@ -160,7 +160,8 @@ impl DetailsPaneView { if let Some(repo_id) = state.active_repo && let Some(repo) = state.repos.iter().find(|r| r.id == repo_id) { - repo.status_rev.hash(&mut hasher); + repo.worktree_status_cache_rev().hash(&mut hasher); + repo.staged_status_cache_rev().hash(&mut hasher); repo.ops_rev.hash(&mut hasher); repo.history_state.selected_commit_rev.hash(&mut hasher); repo.history_state.commit_details_rev.hash(&mut hasher); @@ -464,17 +465,17 @@ impl DetailsPaneView { return false; } - let Loadable::Ready(status) = &repo.status else { - return true; - }; - + let status_key = ( + repo.worktree_status_cache_rev(), + repo.staged_status_cache_rev(), + ); let status_changed = match last_status.get(repo_id) { - Some(prev) => !Arc::ptr_eq(prev, status), + Some(prev) => *prev != status_key, None => true, }; if status_changed { - last_status.insert(*repo_id, Arc::clone(status)); - reconcile_status_multi_selection(selection, status); + last_status.insert(*repo_id, status_key); + reconcile_status_multi_selection_with_repo(selection, repo); } if selection.is_empty() { @@ -735,7 +736,8 @@ mod tests { let initial = DetailsPaneView::notify_fingerprint(&state); - state.repos[1].status_rev = 1; + state.repos[1].worktree_status_rev = 1; + state.repos[1].staged_status_rev = 1; state.repos[1].ops_rev = 1; state.repos[1].history_state.selected_commit_rev = 1; state.repos[1].history_state.commit_details_rev = 1; @@ -754,7 +756,7 @@ mod tests { let initial = DetailsPaneView::notify_fingerprint(&state); - state.repos[0].status_rev = 1; + state.repos[0].worktree_status_rev = 1; let after_status = DetailsPaneView::notify_fingerprint(&state); assert_ne!(after_status, initial); diff --git a/crates/gitcomet-ui-gpui/src/view/panes/history.rs b/crates/gitcomet-ui-gpui/src/view/panes/history.rs index 2c663370..6f261635 100644 --- a/crates/gitcomet-ui-gpui/src/view/panes/history.rs +++ b/crates/gitcomet-ui-gpui/src/view/panes/history.rs @@ -752,7 +752,8 @@ impl HistoryView { repo.tags_rev.hash(&mut hasher); repo.stashes_rev.hash(&mut hasher); repo.history_state.selected_commit_rev.hash(&mut hasher); - repo.status_rev.hash(&mut hasher); + repo.worktree_status_cache_rev().hash(&mut hasher); + repo.staged_status_cache_rev().hash(&mut hasher); } hasher.finish() @@ -1263,7 +1264,8 @@ impl HistoryView { }, Rebuild { repo_id: RepoId, - status: Arc, + worktree_status_rev: u64, + staged_status_rev: u64, show_row: bool, counts: (usize, usize, usize), }, @@ -1273,13 +1275,19 @@ impl HistoryView { let Some(repo) = self.active_repo() else { return Action::Clear; }; - let Loadable::Ready(status) = &repo.status else { + let worktree = repo.worktree_status_entries(); + let staged = repo.staged_status_entries(); + if worktree.is_none() && staged.is_none() { return Action::Clear; - }; + } + + let worktree_status_rev = repo.worktree_status_cache_rev(); + let staged_status_rev = repo.staged_status_cache_rev(); if let Some(cache) = &self.history_worktree_summary_cache && cache.repo_id == repo.id - && Arc::ptr_eq(&cache.status, status) + && cache.worktree_status_rev == worktree_status_rev + && cache.staged_status_rev == staged_status_rev { return Action::CacheOk { show_row: cache.show_row, @@ -1303,9 +1311,10 @@ impl HistoryView { (added, modified, deleted) }; - let unstaged_counts = count_for(&status.unstaged); - let staged_counts = count_for(&status.staged); - let show_row = !status.unstaged.is_empty() || !status.staged.is_empty(); + let unstaged_counts = worktree.map_or((0, 0, 0), count_for); + let staged_counts = staged.map_or((0, 0, 0), count_for); + let show_row = worktree.is_some_and(|entries| !entries.is_empty()) + || staged.is_some_and(|entries| !entries.is_empty()); let counts = ( unstaged_counts.0 + staged_counts.0, unstaged_counts.1 + staged_counts.1, @@ -1314,7 +1323,8 @@ impl HistoryView { Action::Rebuild { repo_id: repo.id, - status: Arc::clone(status), + worktree_status_rev, + staged_status_rev, show_row, counts, } @@ -1328,13 +1338,15 @@ impl HistoryView { Action::CacheOk { show_row, counts } => (show_row, counts), Action::Rebuild { repo_id, - status, + worktree_status_rev, + staged_status_rev, show_row, counts, } => { self.history_worktree_summary_cache = Some(HistoryWorktreeSummaryCache { repo_id, - status, + worktree_status_rev, + staged_status_rev, show_row, counts, }); diff --git a/crates/gitcomet-ui-gpui/src/view/panes/main/actions_impl.rs b/crates/gitcomet-ui-gpui/src/view/panes/main/actions_impl.rs index 3bbdcbc7..e9a83ecf 100644 --- a/crates/gitcomet-ui-gpui/src/view/panes/main/actions_impl.rs +++ b/crates/gitcomet-ui-gpui/src/view/panes/main/actions_impl.rs @@ -803,12 +803,9 @@ impl MainPaneView { return; } - let conflict_entry = match &repo.status { - Loadable::Ready(status) => status.unstaged.iter().find(|e| { - e.path == *path && e.kind == gitcomet_core::domain::FileStatusKind::Conflicted - }), - _ => None, - }; + let conflict_entry = repo + .status_entry_for_path(DiffArea::Unstaged, path.as_path()) + .filter(|entry| entry.kind == gitcomet_core::domain::FileStatusKind::Conflicted); let Some(conflict_entry) = conflict_entry else { self.clear_conflict_resolver_state(); return; diff --git a/crates/gitcomet-ui-gpui/src/view/panes/main/core_impl.rs b/crates/gitcomet-ui-gpui/src/view/panes/main/core_impl.rs index 8c25ed10..7f90b8b6 100644 --- a/crates/gitcomet-ui-gpui/src/view/panes/main/core_impl.rs +++ b/crates/gitcomet-ui-gpui/src/view/panes/main/core_impl.rs @@ -630,7 +630,7 @@ impl MainPaneView { repo.diff_state.diff_target, Some(DiffTarget::WorkingTree { .. }) ) { - repo.status_rev + repo.status_cache_rev() } else { 0 }; diff --git a/crates/gitcomet-ui-gpui/src/view/panes/main/diff_search.rs b/crates/gitcomet-ui-gpui/src/view/panes/main/diff_search.rs index 83b32a24..e733f337 100644 --- a/crates/gitcomet-ui-gpui/src/view/panes/main/diff_search.rs +++ b/crates/gitcomet-ui-gpui/src/view/panes/main/diff_search.rs @@ -371,13 +371,9 @@ impl MainPaneView { if *area != DiffArea::Unstaged { return None; } - let Loadable::Ready(status) = &repo.status else { - return None; - }; - let conflict = status - .unstaged - .iter() - .find(|e| e.path == *path && e.kind == FileStatusKind::Conflicted)?; + let conflict = repo + .status_entry_for_path(DiffArea::Unstaged, path.as_path()) + .filter(|entry| entry.kind == FileStatusKind::Conflicted)?; Some((path.clone(), conflict.conflict)) } diff --git a/crates/gitcomet-ui-gpui/src/view/panes/main/preview.rs b/crates/gitcomet-ui-gpui/src/view/panes/main/preview.rs index 1dd1bb67..5d4e219f 100644 --- a/crates/gitcomet-ui-gpui/src/view/panes/main/preview.rs +++ b/crates/gitcomet-ui-gpui/src/view/panes/main/preview.rs @@ -277,13 +277,9 @@ impl MainPaneView { if *area != DiffArea::Unstaged { return false; } - let Loadable::Ready(status) = &repo.status else { - return false; - }; - let conflict_kind = status - .unstaged - .iter() - .find(|e| e.path == *path && e.kind == FileStatusKind::Conflicted) + let conflict_kind = repo + .status_entry_for_path(DiffArea::Unstaged, path.as_path()) + .filter(|entry| entry.kind == FileStatusKind::Conflicted) .and_then(|e| e.conflict); Self::conflict_resolver_strategy(conflict_kind, false).is_some() }) @@ -465,10 +461,9 @@ impl MainPaneView { } let is_untracked = *area == DiffArea::Unstaged - && matches!(&repo.status, Loadable::Ready(status) if status - .unstaged - .iter() - .any(|e| e.kind == FileStatusKind::Untracked && &e.path == path)); + && repo + .status_entry_for_path(DiffArea::Unstaged, path.as_path()) + .is_some_and(|entry| entry.kind == FileStatusKind::Untracked); if is_untracked { Some( @@ -617,10 +612,6 @@ impl MainPaneView { &self, ) -> Option { let repo = self.active_repo()?; - let status = match &repo.status { - Loadable::Ready(s) => s, - _ => return None, - }; let workdir = repo.spec.workdir.clone(); let DiffTarget::WorkingTree { path, area } = repo.diff_state.diff_target.as_ref()? else { return None; @@ -628,10 +619,9 @@ impl MainPaneView { if *area != DiffArea::Unstaged { return None; } - let is_untracked = status - .unstaged - .iter() - .any(|e| e.kind == FileStatusKind::Untracked && &e.path == path); + let is_untracked = repo + .status_entry_for_path(DiffArea::Unstaged, path.as_path()) + .is_some_and(|entry| entry.kind == FileStatusKind::Untracked); is_untracked.then(|| { if path.is_absolute() { path.clone() @@ -653,14 +643,9 @@ impl MainPaneView { if *area != DiffArea::Staged { return None; } - let status = match &repo.status { - Loadable::Ready(s) => s, - _ => return None, - }; - let is_added = status - .staged - .iter() - .any(|e| e.kind == FileStatusKind::Added && &e.path == path); + let is_added = repo + .status_entry_for_path(DiffArea::Staged, path.as_path()) + .is_some_and(|entry| entry.kind == FileStatusKind::Added); if !is_added { return None; } @@ -703,17 +688,9 @@ impl MainPaneView { match target { DiffTarget::WorkingTree { path, area } => { - let status = match &repo.status { - Loadable::Ready(s) => s, - _ => return None, - }; - let entries = match area { - DiffArea::Unstaged => status.unstaged.as_slice(), - DiffArea::Staged => status.staged.as_slice(), - }; - let is_deleted = entries - .iter() - .any(|e| e.kind == FileStatusKind::Deleted && &e.path == path); + let is_deleted = repo + .status_entry_for_path(*area, path.as_path()) + .is_some_and(|entry| entry.kind == FileStatusKind::Deleted); if !is_deleted { return None; } diff --git a/crates/gitcomet-ui-gpui/src/view/rows/benchmarks/diff_fixtures.rs b/crates/gitcomet-ui-gpui/src/view/rows/benchmarks/diff_fixtures.rs index 1c8ee7c6..0d4253d9 100644 --- a/crates/gitcomet-ui-gpui/src/view/rows/benchmarks/diff_fixtures.rs +++ b/crates/gitcomet-ui-gpui/src/view/rows/benchmarks/diff_fixtures.rs @@ -1793,14 +1793,7 @@ impl RepoTabDragFixture { Self { tab_count, tab_width_px: 120.0, - baseline: AppState { - repos, - active_repo: active, - clone: None, - notifications: Vec::new(), - banner_error: None, - auth_prompt: None, - }, + baseline: bench_app_state(repos, active), } } @@ -1848,6 +1841,24 @@ impl RepoTabDragFixture { (h.finish(), metrics) } + #[cfg(test)] + pub fn hit_test_target_repo_ids(&self) -> Vec { + let repos = &self.baseline.repos; + let steps = self.tab_count * 3; + let total_bar_width = self.tab_count as f32 * self.tab_width_px; + let mut ids = Vec::with_capacity(steps); + + for step in 0..steps { + let frac = (step as f32) / (steps.max(1) as f32); + let cursor_x = frac * total_bar_width; + let tab_ix = (cursor_x / self.tab_width_px) as usize; + let tab_ix = tab_ix.min(self.tab_count.saturating_sub(1)); + ids.push(repos[tab_ix].id); + } + + ids + } + /// Full reducer dispatch — hit-test + reorder_repo_tabs for each step. pub fn run_reorder(&self) -> (u64, RepoTabDragMetrics) { use gitcomet_state::benchmarks::dispatch_sync; diff --git a/crates/gitcomet-ui-gpui/src/view/rows/benchmarks/git_ops.rs b/crates/gitcomet-ui-gpui/src/view/rows/benchmarks/git_ops.rs index bdc8c14d..cf8e52f6 100644 --- a/crates/gitcomet-ui-gpui/src/view/rows/benchmarks/git_ops.rs +++ b/crates/gitcomet-ui-gpui/src/view/rows/benchmarks/git_ops.rs @@ -420,7 +420,7 @@ impl GitOpsFixture { fn execute(&self) -> (u64, GitOpsOutcome) { match &self.scenario { GitOpsScenario::StatusDirty { .. } | GitOpsScenario::StatusClean { .. } => { - let status = self.repo.status().expect("git_ops status benchmark"); + let status = load_split_repo_status(self.repo.as_ref(), "git_ops status benchmark"); let dirty_files = status.staged.len().saturating_add(status.unstaged.len()); ( hash_repo_status(&status), diff --git a/crates/gitcomet-ui-gpui/src/view/rows/benchmarks/real_repo.rs b/crates/gitcomet-ui-gpui/src/view/rows/benchmarks/real_repo.rs index 2f782964..85106e6a 100644 --- a/crates/gitcomet-ui-gpui/src/view/rows/benchmarks/real_repo.rs +++ b/crates/gitcomet-ui-gpui/src/view/rows/benchmarks/real_repo.rs @@ -253,10 +253,8 @@ impl RealRepoFixture { } fn run_monorepo_open_and_history(&self) -> (u64, RealRepoMetrics) { - let status = self - .repo - .status() - .expect("real_repo monorepo status benchmark"); + let status = + load_split_repo_status(self.repo.as_ref(), "real_repo monorepo status benchmark"); let branches = self .repo .list_branches() @@ -377,10 +375,8 @@ impl RealRepoFixture { } fn run_mid_merge_conflict_list_and_open(&self) -> (u64, RealRepoMetrics) { - let status = self - .repo - .status() - .expect("real_repo mid-merge status benchmark"); + let status = + load_split_repo_status(self.repo.as_ref(), "real_repo mid-merge status benchmark"); let conflict_paths = conflict_paths_from_status_or_git(&status, self.repo.spec().workdir.as_path()); let selected_path = self @@ -703,8 +699,7 @@ fn build_real_repo_state( repo.remotes_rev = 1; repo.remote_branches = Loadable::Ready(Arc::new(remote_branches)); repo.remote_branches_rev = 1; - repo.status = Loadable::Ready(Arc::new(status)); - repo.status_rev = 1; + seed_repo_status(&mut repo, status); repo.stashes = Loadable::Ready(Arc::new(Vec::new())); repo.stashes_rev = 1; repo.worktrees = Loadable::Ready(Arc::new(Vec::new())); diff --git a/crates/gitcomet-ui-gpui/src/view/rows/benchmarks/repo_history.rs b/crates/gitcomet-ui-gpui/src/view/rows/benchmarks/repo_history.rs index f445ce67..6d167d7c 100644 --- a/crates/gitcomet-ui-gpui/src/view/rows/benchmarks/repo_history.rs +++ b/crates/gitcomet-ui-gpui/src/view/rows/benchmarks/repo_history.rs @@ -1099,14 +1099,7 @@ impl RepoSwitchFixture { ); Self { - baseline: AppState { - repos: vec![repo], - active_repo: Some(RepoId(1)), - clone: None, - notifications: Vec::new(), - banner_error: None, - auth_prompt: None, - }, + baseline: bench_app_state(vec![repo], Some(RepoId(1))), target_repo_id: RepoId(1), } } @@ -1140,14 +1133,7 @@ impl RepoSwitchFixture { ); Self { - baseline: AppState { - repos: vec![repo1, repo2], - active_repo: Some(RepoId(1)), - clone: None, - notifications: Vec::new(), - banner_error: None, - auth_prompt: None, - }, + baseline: bench_app_state(vec![repo1, repo2], Some(RepoId(1))), target_repo_id: RepoId(2), } } @@ -1181,14 +1167,7 @@ impl RepoSwitchFixture { ); Self { - baseline: AppState { - repos: vec![repo1, repo2], - active_repo: Some(RepoId(1)), - clone: None, - notifications: Vec::new(), - banner_error: None, - auth_prompt: None, - }, + baseline: bench_app_state(vec![repo1, repo2], Some(RepoId(1))), target_repo_id: RepoId(2), } } @@ -1224,14 +1203,7 @@ impl RepoSwitchFixture { } Self { - baseline: AppState { - repos, - active_repo: Some(RepoId(1)), - clone: None, - notifications: Vec::new(), - banner_error: None, - auth_prompt: None, - }, + baseline: bench_app_state(repos, Some(RepoId(1))), target_repo_id: RepoId(u64::try_from(TAB_COUNT).unwrap_or(u64::MAX)), } } @@ -1267,14 +1239,7 @@ impl RepoSwitchFixture { } Self { - baseline: AppState { - repos, - active_repo: Some(RepoId(1)), - clone: None, - notifications: Vec::new(), - banner_error: None, - auth_prompt: None, - }, + baseline: bench_app_state(repos, Some(RepoId(1))), target_repo_id: RepoId(u64::try_from(REPO_COUNT).unwrap_or(u64::MAX)), } } @@ -1313,14 +1278,7 @@ impl RepoSwitchFixture { populate_loaded_diff_state(&mut repo2, "src/lib.rs", 500); Self { - baseline: AppState { - repos: vec![repo1, repo2], - active_repo: Some(RepoId(1)), - clone: None, - notifications: Vec::new(), - banner_error: None, - auth_prompt: None, - }, + baseline: bench_app_state(vec![repo1, repo2], Some(RepoId(1))), target_repo_id: RepoId(2), } } @@ -1359,14 +1317,7 @@ impl RepoSwitchFixture { populate_conflict_state(&mut repo2, "src/conflict_b.rs", 200); Self { - baseline: AppState { - repos: vec![repo1, repo2], - active_repo: Some(RepoId(1)), - clone: None, - notifications: Vec::new(), - banner_error: None, - auth_prompt: None, - }, + baseline: bench_app_state(vec![repo1, repo2], Some(RepoId(1))), target_repo_id: RepoId(2), } } @@ -1413,14 +1364,7 @@ impl RepoSwitchFixture { repo2.merge_message_rev = 1; Self { - baseline: AppState { - repos: vec![repo1, repo2], - active_repo: Some(RepoId(1)), - clone: None, - notifications: Vec::new(), - banner_error: None, - auth_prompt: None, - }, + baseline: bench_app_state(vec![repo1, repo2], Some(RepoId(1))), target_repo_id: RepoId(2), } } @@ -1463,7 +1407,8 @@ pub struct HistoryGraphFixture { fn repo_switch_repo_is_hydrated(repo: &RepoState) -> bool { matches!(repo.open, Loadable::Ready(())) - && matches!(repo.status, Loadable::Ready(_)) + && repo.worktree_status_entries().is_some() + && repo.staged_status_entries().is_some() && matches!(repo.log, Loadable::Ready(_)) && matches!(repo.history_state.log, Loadable::Ready(_)) && matches!(repo.branches, Loadable::Ready(_)) diff --git a/crates/gitcomet-ui-gpui/src/view/rows/benchmarks/runtime_fixtures.rs b/crates/gitcomet-ui-gpui/src/view/rows/benchmarks/runtime_fixtures.rs index 33a33666..783f2ad0 100644 --- a/crates/gitcomet-ui-gpui/src/view/rows/benchmarks/runtime_fixtures.rs +++ b/crates/gitcomet-ui-gpui/src/view/rows/benchmarks/runtime_fixtures.rs @@ -152,13 +152,10 @@ impl FsEventFixture { metrics.mutation_files = 1; // 2. Run git status. - let start = std::time::Instant::now(); - let status = self - .repo - .status() - .expect("fs_event single_file_save status"); - metrics.status_ms = start.elapsed().as_secs_f64() * 1_000.0; - metrics.status_calls = 1; + let (status, status_calls, status_ms) = + measure_split_repo_status(self.repo.as_ref(), "fs_event single_file_save"); + metrics.status_ms = status_ms; + metrics.status_calls = status_calls; let dirty = status.staged.len().saturating_add(status.unstaged.len()); metrics.dirty_files_detected = u64::try_from(dirty).unwrap_or(u64::MAX); @@ -189,13 +186,10 @@ impl FsEventFixture { metrics.mutation_files = u64::try_from(checkout_files).unwrap_or(u64::MAX); // 2. Run git status. - let start = std::time::Instant::now(); - let status = self - .repo - .status() - .expect("fs_event git_checkout_batch status"); - metrics.status_ms = start.elapsed().as_secs_f64() * 1_000.0; - metrics.status_calls = 1; + let (status, status_calls, status_ms) = + measure_split_repo_status(self.repo.as_ref(), "fs_event git_checkout_batch"); + metrics.status_ms = status_ms; + metrics.status_calls = status_calls; let dirty = status.staged.len().saturating_add(status.unstaged.len()); metrics.dirty_files_detected = u64::try_from(dirty).unwrap_or(u64::MAX); @@ -229,13 +223,10 @@ impl FsEventFixture { metrics.coalesced_saves = metrics.mutation_files; // 2. Single coalesced status call (debounce model). - let start = std::time::Instant::now(); - let status = self - .repo - .status() - .expect("fs_event rapid_saves_debounce status"); - metrics.status_ms = start.elapsed().as_secs_f64() * 1_000.0; - metrics.status_calls = 1; + let (status, status_calls, status_ms) = + measure_split_repo_status(self.repo.as_ref(), "fs_event rapid_saves_debounce"); + metrics.status_ms = status_ms; + metrics.status_calls = status_calls; let dirty = status.staged.len().saturating_add(status.unstaged.len()); metrics.dirty_files_detected = u64::try_from(dirty).unwrap_or(u64::MAX); @@ -271,13 +262,12 @@ impl FsEventFixture { metrics.mutation_files = u64::try_from(churn_files).unwrap_or(u64::MAX); // 3. Status should find 0 dirty files — the FS events were false positives. - let start = std::time::Instant::now(); - let status = self - .repo - .status() - .expect("fs_event false_positive_under_churn status"); - metrics.status_ms = start.elapsed().as_secs_f64() * 1_000.0; - metrics.status_calls = 1; + let (status, status_calls, status_ms) = measure_split_repo_status( + self.repo.as_ref(), + "fs_event false_positive_under_churn", + ); + metrics.status_ms = status_ms; + metrics.status_calls = status_calls; let dirty = status.staged.len().saturating_add(status.unstaged.len()); metrics.dirty_files_detected = u64::try_from(dirty).unwrap_or(u64::MAX); @@ -702,10 +692,10 @@ impl IdleResourceFixture { let mut status_calls = 0u64; let mut status_ms = 0.0f64; for repo in &self.repos { - let started_at = Instant::now(); - let status = repo.status().expect("idle_resource repo refresh status"); - status_ms += started_at.elapsed().as_secs_f64() * 1_000.0; - status_calls = status_calls.saturating_add(1); + let (status, repo_calls, repo_status_ms) = + measure_split_repo_status(repo.as_ref(), "idle_resource repo refresh"); + status_ms += repo_status_ms; + status_calls = status_calls.saturating_add(repo_calls); hash_repo_status(&status).hash(&mut h); } (h.finish(), status_calls, status_ms) @@ -724,12 +714,31 @@ fn idle_sample_steps(window: Duration, interval: Duration) -> usize { usize::try_from(steps.max(1)).unwrap_or(usize::MAX) } +#[cfg(target_os = "linux")] +#[cfg(any(test, feature = "benchmarks"))] +pub(crate) fn parse_first_u64_ascii_token(bytes: &[u8]) -> Option { + std::str::from_utf8(bytes) + .ok()? + .split_ascii_whitespace() + .next()? + .parse::() + .ok() +} + +#[cfg(target_os = "linux")] +#[cfg(any(test, feature = "benchmarks"))] +pub(crate) fn parse_vmrss_kib(bytes: &[u8]) -> Option { + std::str::from_utf8(bytes).ok()?.lines().find_map(|line| { + let value = line.strip_prefix("VmRSS:")?.split_whitespace().next()?; + value.parse::().ok() + }) +} + #[cfg(target_os = "linux")] #[cfg(any(test, feature = "benchmarks"))] fn current_cpu_runtime_ns() -> Option { - let schedstat = fs::read_to_string("/proc/self/schedstat").ok()?; - let runtime_ns = schedstat.split_whitespace().next()?; - runtime_ns.parse::().ok() + let schedstat = fs::read("/proc/self/schedstat").ok()?; + parse_first_u64_ascii_token(&schedstat) } #[cfg(not(target_os = "linux"))] @@ -741,11 +750,8 @@ fn current_cpu_runtime_ns() -> Option { #[cfg(target_os = "linux")] #[cfg(any(test, feature = "benchmarks"))] fn current_rss_kib() -> Option { - let status = fs::read_to_string("/proc/self/status").ok()?; - status.lines().find_map(|line| { - let value = line.strip_prefix("VmRSS:")?.split_whitespace().next()?; - value.parse::().ok() - }) + let status = fs::read("/proc/self/status").ok()?; + parse_vmrss_kib(&status) } #[cfg(not(target_os = "linux"))] diff --git a/crates/gitcomet-ui-gpui/src/view/rows/benchmarks/scroll_fixtures.rs b/crates/gitcomet-ui-gpui/src/view/rows/benchmarks/scroll_fixtures.rs index 2601bbc8..32a5816e 100644 --- a/crates/gitcomet-ui-gpui/src/view/rows/benchmarks/scroll_fixtures.rs +++ b/crates/gitcomet-ui-gpui/src/view/rows/benchmarks/scroll_fixtures.rs @@ -852,11 +852,7 @@ impl KeyboardStageUnstageToggleFixture { let commits = build_synthetic_commits(100); let mut repo = build_synthetic_repo_state(20, 40, 2, 0, 0, 0, &commits); - repo.status = Loadable::Ready(Arc::new(RepoStatus { - unstaged: entries.clone(), - staged: entries, - })); - repo.status_rev = 1; + seed_repo_status_entries(&mut repo, entries.clone(), entries); repo.open = Loadable::Ready(()); repo.diff_state.diff_target = Some(DiffTarget::WorkingTree { path: paths[0].clone(), @@ -865,14 +861,7 @@ impl KeyboardStageUnstageToggleFixture { repo.diff_state.diff_state_rev = 1; Self { - baseline: AppState { - repos: vec![repo], - active_repo: Some(RepoId(1)), - clone: None, - notifications: Vec::new(), - banner_error: None, - auth_prompt: None, - }, + baseline: bench_app_state(vec![repo], Some(RepoId(1))), paths, toggle_events: toggle_events.max(1), frame_budget_ns: frame_budget_ns.max(1), diff --git a/crates/gitcomet-ui-gpui/src/view/rows/benchmarks/search_fixtures.rs b/crates/gitcomet-ui-gpui/src/view/rows/benchmarks/search_fixtures.rs index 2d16f494..775e28a4 100644 --- a/crates/gitcomet-ui-gpui/src/view/rows/benchmarks/search_fixtures.rs +++ b/crates/gitcomet-ui-gpui/src/view/rows/benchmarks/search_fixtures.rs @@ -365,6 +365,17 @@ impl CommitSearchFilterFixture { } seen.len() } + + #[cfg(test)] + pub fn distinct_message_trigrams(&self) -> usize { + let mut seen = std::collections::HashSet::new(); + for summary in &self.summaries_lower { + for trigram in summary.as_bytes().windows(3) { + seen.insert(<[u8; 3]>::try_from(trigram).expect("3-byte trigram")); + } + } + seen.len() + } } /// Metrics emitted as sidecar JSON for in-diff text search benchmarks. @@ -1024,6 +1035,11 @@ impl FileFuzzyFindFixture { ) } + #[cfg(test)] + pub fn run_find_without_ordered_pair_prefilter(&self, query: &str) -> u64 { + self.run_find(query) + } + fn scan_matches(&self, query: &str) -> FileFuzzyFindRunResult { let Some(query) = AsciiCaseInsensitiveSubsequenceNeedle::new(query.trim()) else { return FileFuzzyFindRunResult { diff --git a/crates/gitcomet-ui-gpui/src/view/rows/benchmarks/status_fixtures.rs b/crates/gitcomet-ui-gpui/src/view/rows/benchmarks/status_fixtures.rs index 275f93df..d3e20b95 100644 --- a/crates/gitcomet-ui-gpui/src/view/rows/benchmarks/status_fixtures.rs +++ b/crates/gitcomet-ui-gpui/src/view/rows/benchmarks/status_fixtures.rs @@ -305,22 +305,11 @@ impl StatusSelectDiffOpenFixture { let commits = build_synthetic_commits(100); let mut repo = build_synthetic_repo_state(20, 40, 2, 0, 0, 0, &commits); - repo.status = Loadable::Ready(Arc::new(RepoStatus { - unstaged: entries, - staged: Vec::new(), - })); - repo.status_rev = 1; + seed_repo_status_entries(&mut repo, entries, Vec::new()); repo.open = Loadable::Ready(()); Self { - baseline: AppState { - repos: vec![repo], - active_repo: Some(RepoId(1)), - clone: None, - notifications: Vec::new(), - banner_error: None, - auth_prompt: None, - }, + baseline: bench_app_state(vec![repo], Some(RepoId(1))), diff_target: DiffTarget::WorkingTree { path: target_path, area: DiffArea::Unstaged, @@ -334,22 +323,11 @@ impl StatusSelectDiffOpenFixture { let commits = build_synthetic_commits(100); let mut repo = build_synthetic_repo_state(20, 40, 2, 0, 0, 0, &commits); - repo.status = Loadable::Ready(Arc::new(RepoStatus { - staged: entries, - unstaged: Vec::new(), - })); - repo.status_rev = 1; + seed_repo_status_entries(&mut repo, Vec::new(), entries); repo.open = Loadable::Ready(()); Self { - baseline: AppState { - repos: vec![repo], - active_repo: Some(RepoId(1)), - clone: None, - notifications: Vec::new(), - banner_error: None, - auth_prompt: None, - }, + baseline: bench_app_state(vec![repo], Some(RepoId(1))), diff_target: DiffTarget::WorkingTree { path: target_path, area: DiffArea::Staged, diff --git a/crates/gitcomet-ui-gpui/src/view/rows/benchmarks/support.rs b/crates/gitcomet-ui-gpui/src/view/rows/benchmarks/support.rs index bdfbd4b9..82bed05a 100644 --- a/crates/gitcomet-ui-gpui/src/view/rows/benchmarks/support.rs +++ b/crates/gitcomet-ui-gpui/src/view/rows/benchmarks/support.rs @@ -198,6 +198,64 @@ pub(crate) fn build_synthetic_repo_state( repo } +pub(crate) fn bench_app_state(repos: Vec, active_repo: Option) -> AppState { + AppState { + repos, + active_repo, + ..AppState::default() + } +} + +pub(crate) fn seed_repo_status_entries( + repo: &mut RepoState, + unstaged: Vec, + staged: Vec, +) { + repo.has_unstaged_conflicts = unstaged + .iter() + .any(|entry| entry.kind == FileStatusKind::Conflicted); + repo.worktree_status = Loadable::Ready(Arc::new(unstaged.clone())); + repo.worktree_status_rev = 1; + repo.staged_status = Loadable::Ready(Arc::new(staged.clone())); + repo.staged_status_rev = 1; + repo.status = Loadable::Ready(Arc::new(RepoStatus { unstaged, staged })); + repo.status_rev = 1; +} + +pub(crate) fn seed_repo_status(repo: &mut RepoState, status: RepoStatus) { + let RepoStatus { unstaged, staged } = status; + seed_repo_status_entries(repo, unstaged, staged); +} + +pub(crate) fn load_split_repo_status(repo: &dyn GitRepository, context: &str) -> RepoStatus { + let unstaged = repo + .worktree_status() + .unwrap_or_else(|error| panic!("{context} worktree_status failed: {error}")); + let staged = repo + .staged_status() + .unwrap_or_else(|error| panic!("{context} staged_status failed: {error}")); + RepoStatus { unstaged, staged } +} + +pub(crate) fn measure_split_repo_status( + repo: &dyn GitRepository, + context: &str, +) -> (RepoStatus, u64, f64) { + let started_at = std::time::Instant::now(); + let unstaged = repo + .worktree_status() + .unwrap_or_else(|error| panic!("{context} worktree_status failed: {error}")); + let worktree_ms = started_at.elapsed().as_secs_f64() * 1_000.0; + + let started_at = std::time::Instant::now(); + let staged = repo + .staged_status() + .unwrap_or_else(|error| panic!("{context} staged_status failed: {error}")); + let staged_ms = started_at.elapsed().as_secs_f64() * 1_000.0; + + (RepoStatus { unstaged, staged }, 2, worktree_ms + staged_ms) +} + pub(crate) fn build_repo_switch_repo_state( repo_id: RepoId, workdir: &str, @@ -227,8 +285,7 @@ pub(crate) fn build_repo_switch_repo_state( repo.log = Loadable::Ready(log_page); repo.log_rev = 1; - repo.status = Loadable::Ready(Arc::new(build_synthetic_repo_status(status_entries))); - repo.status_rev = 1; + seed_repo_status(&mut repo, build_synthetic_repo_status(status_entries)); repo.tags = Loadable::Ready(Arc::new(build_tags_targeting_commits(commits, 32))); repo.tags_rev = 1; repo.remote_tags = Loadable::Ready(Arc::new(Vec::new())); diff --git a/crates/gitcomet-ui-gpui/src/view/rows/benchmarks/tests.rs b/crates/gitcomet-ui-gpui/src/view/rows/benchmarks/tests.rs index 337214ac..bd20cf5e 100644 --- a/crates/gitcomet-ui-gpui/src/view/rows/benchmarks/tests.rs +++ b/crates/gitcomet-ui-gpui/src/view/rows/benchmarks/tests.rs @@ -86,7 +86,7 @@ fn git_ops_status_fixture_reports_dirty_status_metrics() { assert_eq!(hash_without_trace, hash_with_trace); assert_eq!(metrics.tracked_files, 32); assert_eq!(metrics.dirty_files, 8); - assert_eq!(metrics.status_calls, 1); + assert_eq!(metrics.status_calls, 2); assert_eq!(metrics.log_walk_calls, 0); assert_eq!(metrics.diff_calls, 0); assert_eq!(metrics.blame_calls, 0); @@ -2171,15 +2171,10 @@ fn repo_tab_drag_hit_test_covers_all_tabs() { #[test] fn repo_tab_drag_hit_test_precomputes_expected_sweep() { let fixture = RepoTabDragFixture::new(4); - assert_eq!(fixture.hit_test_steps.len(), 12); - assert_eq!( - fixture.hit_test_steps.first().unwrap().target.repo_id, - RepoId(1) - ); - assert_eq!( - fixture.hit_test_steps.last().unwrap().target.repo_id, - RepoId(4) - ); + let target_ids = fixture.hit_test_target_repo_ids(); + assert_eq!(target_ids.len(), 12); + assert_eq!(target_ids.first().copied(), Some(RepoId(1))); + assert_eq!(target_ids.last().copied(), Some(RepoId(4))); } #[test] @@ -2711,7 +2706,7 @@ fn git_ops_status_clean_fixture_reports_zero_dirty_metrics() { assert_eq!(hash_without_trace, hash_with_trace); assert_eq!(metrics.tracked_files, 32); assert_eq!(metrics.dirty_files, 0); - assert_eq!(metrics.status_calls, 1); + assert_eq!(metrics.status_calls, 2); assert_eq!(metrics.log_walk_calls, 0); assert_eq!(metrics.ref_enumerate_calls, 0); assert!(metrics.status_ms > 0.0); @@ -3086,7 +3081,7 @@ fn fs_event_single_file_save_detects_one_dirty_file() { assert_ne!(hash, 0); assert_eq!(metrics.mutation_files, 1); assert_eq!(metrics.dirty_files_detected, 1); - assert_eq!(metrics.status_calls, 1); + assert_eq!(metrics.status_calls, 2); assert!(metrics.tracked_files >= 50); assert!(metrics.status_ms > 0.0); } @@ -3098,7 +3093,7 @@ fn fs_event_git_checkout_batch_detects_all_dirty_files() { assert_ne!(hash, 0); assert_eq!(metrics.mutation_files, 30); assert_eq!(metrics.dirty_files_detected, 30); - assert_eq!(metrics.status_calls, 1); + assert_eq!(metrics.status_calls, 2); assert!(metrics.tracked_files >= 100); } @@ -3109,7 +3104,7 @@ fn fs_event_rapid_saves_debounce_coalesces_into_single_status() { assert_ne!(hash, 0); assert_eq!(metrics.coalesced_saves, 20); assert_eq!(metrics.dirty_files_detected, 20); - assert_eq!(metrics.status_calls, 1); + assert_eq!(metrics.status_calls, 2); assert!(metrics.tracked_files >= 80); } @@ -3121,7 +3116,7 @@ fn fs_event_false_positive_under_churn_finds_zero_dirty() { assert_eq!(metrics.mutation_files, 20); assert_eq!(metrics.dirty_files_detected, 0); assert_eq!(metrics.false_positives, 20); - assert_eq!(metrics.status_calls, 1); + assert_eq!(metrics.status_calls, 2); } #[test] @@ -3180,7 +3175,7 @@ fn idle_background_refresh_short_window_reports_status_calls() { assert_eq!(metrics.open_repos, 3); assert_eq!(metrics.refresh_cycles, 4); assert_eq!(metrics.repos_refreshed, 12); - assert_eq!(metrics.status_calls, 12); + assert_eq!(metrics.status_calls, 24); assert!(metrics.status_ms > 0.0); assert!(metrics.avg_refresh_cycle_ms > 0.0); assert!(metrics.max_refresh_cycle_ms >= metrics.avg_refresh_cycle_ms); @@ -3205,7 +3200,7 @@ fn idle_wake_resume_reports_single_refresh_burst() { assert_eq!(metrics.open_repos, 2); assert_eq!(metrics.refresh_cycles, 1); assert_eq!(metrics.repos_refreshed, 2); - assert_eq!(metrics.status_calls, 2); + assert_eq!(metrics.status_calls, 4); assert!(metrics.status_ms > 0.0); assert!(metrics.wake_resume_ms > 0.0); } @@ -3231,11 +3226,13 @@ fn idle_cpu_usage_hash_is_deterministic_for_fixed_config() { #[test] fn idle_linux_proc_parsers_extract_runtime_and_rss() { assert_eq!( - parse_first_u64_ascii_token(b"123456789 456 789\n"), + runtime_fixtures::parse_first_u64_ascii_token(b"123456789 456 789\n"), Some(123_456_789) ); assert_eq!( - parse_vmrss_kib(b"Name:\ttest\nState:\tR\nVmRSS:\t 24696 kB\nThreads:\t1\n"), + runtime_fixtures::parse_vmrss_kib( + b"Name:\ttest\nState:\tR\nVmRSS:\t 24696 kB\nThreads:\t1\n", + ), Some(24_696) ); } diff --git a/crates/gitcomet-ui-gpui/src/view/rows/benchmarks/text_fixtures.rs b/crates/gitcomet-ui-gpui/src/view/rows/benchmarks/text_fixtures.rs index 5adf9d58..37e7f662 100644 --- a/crates/gitcomet-ui-gpui/src/view/rows/benchmarks/text_fixtures.rs +++ b/crates/gitcomet-ui-gpui/src/view/rows/benchmarks/text_fixtures.rs @@ -38,22 +38,11 @@ impl StagingFixture { let commits = build_synthetic_commits(100); let mut repo = build_synthetic_repo_state(20, 40, 2, 0, 0, 0, &commits); - repo.status = Loadable::Ready(Arc::new(RepoStatus { - staged: Vec::new(), - unstaged: entries, - })); - repo.status_rev = 1; + seed_repo_status_entries(&mut repo, entries, Vec::new()); repo.open = Loadable::Ready(()); Self { - baseline: AppState { - repos: vec![repo], - active_repo: Some(RepoId(1)), - clone: None, - notifications: Vec::new(), - banner_error: None, - auth_prompt: None, - }, + baseline: bench_app_state(vec![repo], Some(RepoId(1))), paths, scenario: StagingScenario::StageAll, } @@ -70,22 +59,11 @@ impl StagingFixture { let commits = build_synthetic_commits(100); let mut repo = build_synthetic_repo_state(20, 40, 2, 0, 0, 0, &commits); - repo.status = Loadable::Ready(Arc::new(RepoStatus { - staged: entries, - unstaged: Vec::new(), - })); - repo.status_rev = 1; + seed_repo_status_entries(&mut repo, Vec::new(), entries); repo.open = Loadable::Ready(()); Self { - baseline: AppState { - repos: vec![repo], - active_repo: Some(RepoId(1)), - clone: None, - notifications: Vec::new(), - banner_error: None, - auth_prompt: None, - }, + baseline: bench_app_state(vec![repo], Some(RepoId(1))), paths, scenario: StagingScenario::UnstageAll, } @@ -106,19 +84,11 @@ impl StagingFixture { let commits = build_synthetic_commits(100); let mut repo = build_synthetic_repo_state(20, 40, 2, 0, 0, 0, &commits); - repo.status = Loadable::Ready(Arc::new(RepoStatus { unstaged, staged })); - repo.status_rev = 1; + seed_repo_status_entries(&mut repo, unstaged, staged); repo.open = Loadable::Ready(()); Self { - baseline: AppState { - repos: vec![repo], - active_repo: Some(RepoId(1)), - clone: None, - notifications: Vec::new(), - banner_error: None, - auth_prompt: None, - }, + baseline: bench_app_state(vec![repo], Some(RepoId(1))), paths, scenario: StagingScenario::Interleaved, } @@ -418,14 +388,7 @@ fn build_undo_redo_baseline(region_count: usize) -> (AppState, RepoPath) { repo.conflict_state.conflict_rev = 1; repo.open = Loadable::Ready(()); - let baseline = AppState { - repos: vec![repo], - active_repo: Some(RepoId(1)), - clone: None, - notifications: Vec::new(), - banner_error: None, - auth_prompt: None, - }; + let baseline = bench_app_state(vec![repo], Some(RepoId(1))); (baseline, conflict_path) } diff --git a/crates/gitcomet-ui-gpui/src/view/rows/status.rs b/crates/gitcomet-ui-gpui/src/view/rows/status.rs index 45e58cf1..7d154733 100644 --- a/crates/gitcomet-ui-gpui/src/view/rows/status.rs +++ b/crates/gitcomet-ui-gpui/src/view/rows/status.rs @@ -256,31 +256,9 @@ pub(super) fn apply_status_multi_selection_click( } } -fn status_entries_for_section(status: &RepoStatus, section: StatusSection) -> Vec<&FileStatus> { - match section { - StatusSection::CombinedUnstaged => status.unstaged.iter().collect(), - StatusSection::Untracked => status - .unstaged - .iter() - .filter(|entry| entry.kind == FileStatusKind::Untracked) - .collect(), - StatusSection::Unstaged => status - .unstaged - .iter() - .filter(|entry| entry.kind != FileStatusKind::Untracked) - .collect(), - StatusSection::Staged => status.staged.iter().collect(), - } -} - -fn status_paths_for_section( - status: &RepoStatus, - section: StatusSection, -) -> Vec { - status_entries_for_section(status, section) - .into_iter() - .map(|entry| entry.path.clone()) - .collect() +fn status_paths_for_section(repo: &RepoState, section: StatusSection) -> Vec { + StatusSectionEntries::from_repo(repo, section) + .map_or_else(Vec::new, StatusSectionEntries::path_vec) } impl DetailsPaneView { @@ -342,7 +320,7 @@ impl DetailsPaneView { let status_rev = self .active_repo() .filter(|repo| repo.id == repo_id) - .map(|repo| repo.status_rev); + .map(|repo| status_section_rev(repo, section)); let sel = self.status_multi_selection_for_repo_mut(repo_id); apply_status_multi_selection_click( sel, @@ -402,16 +380,15 @@ fn render_status_rows_for_section( let Some(repo) = this.active_repo() else { return Vec::new(); }; - let Loadable::Ready(status) = &repo.status else { + let Some(entries) = StatusSectionEntries::from_repo(repo, section) else { return Vec::new(); }; - let entries = status_entries_for_section(status, section); let selected = repo.diff_state.diff_target.as_ref(); let selected_paths = this.status_selected_paths_for_area(repo.id, section.diff_area()); let multi_select_active = !selected_paths.is_empty(); let theme = this.theme; range - .filter_map(|ix| entries.get(ix).copied().map(|entry| (ix, entry))) + .filter_map(|ix| entries.get(ix).map(|entry| (ix, entry))) .map(|(ix, entry)| { let path_display = this.cached_path_display(&entry.path); let is_selected = if multi_select_active { @@ -668,10 +645,7 @@ fn status_row( let entries = if modifiers.shift { this.active_repo() .filter(|r| r.id == repo_id) - .and_then(|repo| match &repo.status { - Loadable::Ready(status) => Some(status_paths_for_section(status, section)), - _ => None, - }) + .map(|repo| status_paths_for_section(repo, section)) } else { None }; diff --git a/crates/win32-window-utils/Cargo.lock b/crates/win32-window-utils/Cargo.lock index 968e9955..c886df20 100644 --- a/crates/win32-window-utils/Cargo.lock +++ b/crates/win32-window-utils/Cargo.lock @@ -6,151 +6,20 @@ version = 4 name = "gitcomet-win32-window-utils" version = "0.1.7" dependencies = [ - "windows", -] - -[[package]] -name = "proc-macro2" -version = "1.0.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quote" -version = "1.0.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "syn" -version = "2.0.117" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "windows" -version = "0.61.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" -dependencies = [ - "windows-collections", - "windows-core", - "windows-future", - "windows-link", - "windows-numerics", -] - -[[package]] -name = "windows-collections" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" -dependencies = [ - "windows-core", -] - -[[package]] -name = "windows-core" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-future" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" -dependencies = [ - "windows-core", - "windows-link", - "windows-threading", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn", + "windows-sys", ] [[package]] name = "windows-link" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" - -[[package]] -name = "windows-numerics" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" -dependencies = [ - "windows-core", - "windows-link", -] - -[[package]] -name = "windows-result" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.4.2" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" -dependencies = [ - "windows-link", -] +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] -name = "windows-threading" -version = "0.1.0" +name = "windows-sys" +version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ "windows-link", ] diff --git a/crates/win32-window-utils/Cargo.toml b/crates/win32-window-utils/Cargo.toml index 17f5774c..0b2511b6 100644 --- a/crates/win32-window-utils/Cargo.toml +++ b/crates/win32-window-utils/Cargo.toml @@ -12,4 +12,4 @@ publish = false path = "src/lib.rs" [dependencies] -windows = { version = "0.61", features = ["Win32_Foundation", "Win32_Graphics_Gdi", "Win32_UI_WindowsAndMessaging"] } +windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_Graphics_Gdi", "Win32_UI_WindowsAndMessaging"] } diff --git a/crates/win32-window-utils/src/lib.rs b/crates/win32-window-utils/src/lib.rs index 27aead33..e9d0eae8 100644 --- a/crates/win32-window-utils/src/lib.rs +++ b/crates/win32-window-utils/src/lib.rs @@ -1,9 +1,9 @@ -use std::ffi::c_void; +use std::ptr::null; -use windows::Win32::Foundation::{HWND, LPARAM, POINT, WPARAM}; -use windows::Win32::Graphics::Gdi::ClientToScreen; -use windows::Win32::UI::WindowsAndMessaging::{ - EnableMenuItem, GWL_STYLE, GetSystemMenu, GetWindowLongPtrW, IsIconic, IsZoomed, +use windows_sys::Win32::Foundation::{HWND, LPARAM, POINT, WPARAM}; +use windows_sys::Win32::Graphics::Gdi::ClientToScreen; +use windows_sys::Win32::UI::WindowsAndMessaging::{ + EnableMenuItem, GWL_STYLE, GetSystemMenu, GetWindowLongPtrW, HMENU, IsIconic, IsZoomed, MENU_ITEM_FLAGS, MF_BYCOMMAND, MF_ENABLED, MF_GRAYED, PostMessageW, SC_CLOSE, SC_MAXIMIZE, SC_MINIMIZE, SC_MOVE, SC_RESTORE, SC_SIZE, SW_RESTORE, SetForegroundWindow, ShowWindowAsync, TPM_LEFTALIGN, TPM_RETURNCMD, TPM_RIGHTBUTTON, TPM_TOPALIGN, TrackPopupMenuEx, WINDOW_STYLE, @@ -12,8 +12,8 @@ use windows::Win32::UI::WindowsAndMessaging::{ /// Restore a Win32 window from the maximized state. pub fn restore_window(hwnd: isize) -> bool { - let hwnd = HWND(hwnd as *mut c_void); - unsafe { ShowWindowAsync(hwnd, SW_RESTORE).as_bool() } + let hwnd = hwnd as HWND; + unsafe { ShowWindowAsync(hwnd, SW_RESTORE) != 0 } } #[derive(Debug, Clone, Copy, Eq, PartialEq)] @@ -26,16 +26,20 @@ struct SystemMenuState { close: bool, } +fn has_style(style: WINDOW_STYLE, flag: WINDOW_STYLE) -> bool { + style & flag == flag +} + fn system_menu_state( style: WINDOW_STYLE, is_minimized: bool, is_maximized: bool, ) -> SystemMenuState { - let has_system_menu = style.contains(WS_SYSMENU); + let has_system_menu = has_style(style, WS_SYSMENU); let is_restored = !is_minimized && !is_maximized; - let can_resize = style.contains(WS_THICKFRAME); - let has_minimize = style.contains(WS_MINIMIZEBOX); - let has_maximize = style.contains(WS_MAXIMIZEBOX); + let can_resize = has_style(style, WS_THICKFRAME); + let has_minimize = has_style(style, WS_MINIMIZEBOX); + let has_maximize = has_style(style, WS_MAXIMIZEBOX); SystemMenuState { restore: has_system_menu && !is_restored, @@ -47,21 +51,17 @@ fn system_menu_state( } } -fn enable_menu_item( - menu: windows::Win32::UI::WindowsAndMessaging::HMENU, - command: u32, - enabled: bool, -) { +fn enable_menu_item(menu: HMENU, command: u32, enabled: bool) { let flags: MENU_ITEM_FLAGS = MF_BYCOMMAND | if enabled { MF_ENABLED } else { MF_GRAYED }; unsafe { let _ = EnableMenuItem(menu, command, flags); } } -fn sync_system_menu_state(hwnd: HWND, menu: windows::Win32::UI::WindowsAndMessaging::HMENU) { - let style = WINDOW_STYLE(unsafe { GetWindowLongPtrW(hwnd, GWL_STYLE) } as u32); - let state = system_menu_state(style, unsafe { IsIconic(hwnd).as_bool() }, unsafe { - IsZoomed(hwnd).as_bool() +fn sync_system_menu_state(hwnd: HWND, menu: HMENU) { + let style = unsafe { GetWindowLongPtrW(hwnd, GWL_STYLE) as WINDOW_STYLE }; + let state = system_menu_state(style, unsafe { IsIconic(hwnd) != 0 }, unsafe { + IsZoomed(hwnd) != 0 }); enable_menu_item(menu, SC_RESTORE, state.restore); @@ -74,16 +74,16 @@ fn sync_system_menu_state(hwnd: HWND, menu: windows::Win32::UI::WindowsAndMessag /// Show the native Win32 system menu for a window at the given client-area position. pub fn show_window_system_menu(hwnd: isize, x: i32, y: i32) { - let hwnd = HWND(hwnd as *mut c_void); + let hwnd = hwnd as HWND; let mut position = POINT { x, y }; unsafe { - if !ClientToScreen(hwnd, &mut position).as_bool() { + if ClientToScreen(hwnd, &mut position) == 0 { return; } - let menu = GetSystemMenu(hwnd, false); - if menu.0.is_null() { + let menu = GetSystemMenu(hwnd, 0); + if menu.is_null() { return; } @@ -91,22 +91,16 @@ pub fn show_window_system_menu(hwnd: isize, x: i32, y: i32) { let _ = SetForegroundWindow(hwnd); let command = TrackPopupMenuEx( menu, - (TPM_LEFTALIGN | TPM_TOPALIGN | TPM_RIGHTBUTTON | TPM_RETURNCMD).0, + TPM_LEFTALIGN | TPM_TOPALIGN | TPM_RIGHTBUTTON | TPM_RETURNCMD, position.x, position.y, hwnd, - None, - ) - .0 as usize; + null(), + ) as usize; - let _ = PostMessageW(Some(hwnd), WM_NULL, WPARAM::default(), LPARAM::default()); + let _ = PostMessageW(hwnd, WM_NULL, WPARAM::default(), LPARAM::default()); if command != 0 { - let _ = PostMessageW( - Some(hwnd), - WM_SYSCOMMAND, - WPARAM(command), - LPARAM::default(), - ); + let _ = PostMessageW(hwnd, WM_SYSCOMMAND, command as WPARAM, LPARAM::default()); } } } diff --git a/scripts/cargo-flatten-dupes.sh b/scripts/cargo-flatten-dupes.sh new file mode 100755 index 00000000..3ddf53cb --- /dev/null +++ b/scripts/cargo-flatten-dupes.sh @@ -0,0 +1,1073 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SELF_REL="scripts/cargo-flatten-dupes.sh" +DEFAULT_MANIFEST_PATH="$ROOT_DIR/Cargo.toml" +WHY_TREE_MAX_LINES=40 + +MODE="" +JSON_OUTPUT=0 +INCLUDE_DEV=0 +MANIFEST_PATH="$DEFAULT_MANIFEST_PATH" +FILTER_CRATE="" +TREE_EDGES="normal,build" + +TMP_FILES=() +ALL_METADATA_FILE="" +HOST_METADATA_FILE="" +ANALYSIS_FILE="" +WORKSPACE_ROOT="" +WORKSPACE_MANIFEST_PATH="" +HOST_TRIPLE="" +ANALYSIS_READY=0 + +usage() { + cat <<'EOF' +Usage: scripts/cargo-flatten-dupes.sh [options] [crate] + +Audit duplicate resolved Cargo crates, explain which requirement keeps a lower +version alive, and suggest local flattening steps where possible. + +Commands: + summary List duplicate crates with blocker summaries. + why Show incoming requirements and reverse-tree excerpts for a duplicate crate. + suggest [crate] Emit non-mutating local fix hints for one duplicate crate or all of them. + +Options: + --manifest-path PATH Cargo manifest to analyze. Default: workspace root Cargo.toml + --include-dev Include dev-dependencies in reachability and blocker analysis. + --json Emit machine-readable JSON for the selected command. + -h, --help Show this help. + +Notes: + - Analysis uses `cargo metadata --locked --offline` and does not edit files. + - Target scope defaults to all targets. This catches platform-specific skew. + - `suggest` reports local manifest or lockfile actions only when they can + remove a duplicate without relying on upstream changes. + +Examples: + scripts/cargo-flatten-dupes.sh summary + scripts/cargo-flatten-dupes.sh why rustix + scripts/cargo-flatten-dupes.sh suggest windows + scripts/cargo-flatten-dupes.sh --json suggest +EOF +} + +die() { + echo "error: $*" >&2 + exit 1 +} + +cleanup() { + if ((${#TMP_FILES[@]} == 0)); then + return + fi + + rm -f "${TMP_FILES[@]}" +} + +trap cleanup EXIT + +make_tmp() { + local tmp + tmp="$(mktemp)" + TMP_FILES+=("$tmp") + printf '%s\n' "$tmp" +} + +require_tool() { + local tool="$1" + command -v "$tool" >/dev/null 2>&1 || die "$tool is required." +} + +trim() { + local value="$1" + value="${value#"${value%%[![:space:]]*}"}" + value="${value%"${value##*[![:space:]]}"}" + printf '%s' "$value" +} + +normalize_version() { + printf '%s\n' "${1%%+*}" +} + +version_compare() { + local left right max + left="$(normalize_version "$1")" + right="$(normalize_version "$2")" + + if [[ "$left" == "$right" ]]; then + printf '0\n' + return + fi + + max="$(printf '%s\n%s\n' "$left" "$right" | sort -V | tail -n 1)" + if [[ "$max" == "$left" ]]; then + printf '1\n' + else + printf '%s\n' '-1' + fi +} + +version_ge() { + [[ "$(version_compare "$1" "$2")" != "-1" ]] +} + +version_gt() { + [[ "$(version_compare "$1" "$2")" == "1" ]] +} + +version_le() { + [[ "$(version_compare "$1" "$2")" != "1" ]] +} + +version_lt() { + [[ "$(version_compare "$1" "$2")" == "-1" ]] +} + +parse_req_base() { + local raw="$1" + local core + local -a parts=() + + raw="${raw%%+*}" + core="${raw%%-*}" + IFS='.' read -r -a parts <<<"$core" + + printf '%s\t%s\t%s\t%s\n' \ + "${#parts[@]}" \ + "${parts[0]:-0}" \ + "${parts[1]:-0}" \ + "${parts[2]:-0}" +} + +caret_upper_bound() { + local count major minor patch + IFS=$'\t' read -r count major minor patch <<<"$(parse_req_base "$1")" + + if ((major > 0)); then + printf '%s.0.0\n' "$((major + 1))" + elif ((minor > 0)); then + printf '0.%s.0\n' "$((minor + 1))" + else + printf '0.0.%s\n' "$((patch + 1))" + fi +} + +tilde_upper_bound() { + local count major minor patch + IFS=$'\t' read -r count major minor patch <<<"$(parse_req_base "$1")" + + if ((count <= 1)); then + printf '%s.0.0\n' "$((major + 1))" + else + printf '%s.%s.0\n' "$major" "$((minor + 1))" + fi +} + +req_clause_allows_version() { + local clause="$1" + local version="$2" + local count major minor patch lower upper rest + + clause="$(trim "$clause")" + version="$(normalize_version "$version")" + + if [[ -z "$clause" ]]; then + return 0 + fi + + case "$clause" in + \*) + return 0 + ;; + \^*) + rest="$(trim "${clause#^}")" + IFS=$'\t' read -r count major minor patch <<<"$(parse_req_base "$rest")" + lower="${major}.${minor}.${patch}" + upper="$(caret_upper_bound "$rest")" + version_ge "$version" "$lower" && version_lt "$version" "$upper" + return + ;; + \~*) + rest="$(trim "${clause#\~}")" + IFS=$'\t' read -r count major minor patch <<<"$(parse_req_base "$rest")" + lower="${major}.${minor}.${patch}" + upper="$(tilde_upper_bound "$rest")" + version_ge "$version" "$lower" && version_lt "$version" "$upper" + return + ;; + \>=*) + rest="$(trim "${clause#>=}")" + version_ge "$version" "$rest" + return + ;; + \>*) + rest="$(trim "${clause#>}")" + version_gt "$version" "$rest" + return + ;; + \<\=*) + rest="$(trim "${clause#<=}")" + version_le "$version" "$rest" + return + ;; + \<*) + rest="$(trim "${clause#<}")" + version_lt "$version" "$rest" + return + ;; + \=*) + rest="$(trim "${clause#=}")" + [[ "$(normalize_version "$rest")" == "$version" ]] + return + ;; + esac + + if [[ "$clause" == *"*"* ]]; then + IFS=$'\t' read -r _ major minor patch <<<"$(parse_req_base "${clause//\*/0}")" + IFS='.' read -r -a version_parts <<<"${version%%-*}" + + case "$clause" in + *.*.*) + [[ "${version_parts[0]:-0}" == "$major" && "${version_parts[1]:-0}" == "$minor" && "${version_parts[2]:-0}" == "$patch" ]] + ;; + *.*) + [[ "${version_parts[0]:-0}" == "$major" && "${version_parts[1]:-0}" == "$minor" ]] + ;; + *) + [[ "${version_parts[0]:-0}" == "$major" ]] + ;; + esac + return + fi + + if [[ "$clause" == *"||"* ]]; then + return 1 + fi + + IFS=$'\t' read -r count major minor patch <<<"$(parse_req_base "$clause")" + lower="${major}.${minor}.${patch}" + upper="$(caret_upper_bound "$clause")" + version_ge "$version" "$lower" && version_lt "$version" "$upper" +} + +req_allows_version() { + local req="$1" + local version="$2" + local clause + local -a clauses=() + + req="$(trim "$req")" + if [[ -z "$req" || "$req" == *"||"* ]]; then + return 1 + fi + + IFS=',' read -r -a clauses <<<"$req" + for clause in "${clauses[@]}"; do + clause="$(trim "$clause")" + if ! req_clause_allows_version "$clause" "$version"; then + return 1 + fi + done + + return 0 +} + +humanize_status() { + case "$1" in + locally-fixable) + printf 'locally-fixable' + ;; + platform-specific) + printf 'platform-specific' + ;; + upstream-blocked) + printf 'upstream-blocked' + ;; + source-mismatch) + printf 'source-mismatch' + ;; + *) + printf '%s' "$1" + ;; + esac +} + +display_path() { + local path="$1" + + if [[ -n "$WORKSPACE_ROOT" && "$path" == "$WORKSPACE_ROOT/"* ]]; then + printf '%s\n' "${path#$WORKSPACE_ROOT/}" + else + printf '%s\n' "$path" + fi +} + +manifest_dep_mode() { + local manifest="$1" + local dep="$2" + local snippet + + if [[ ! -f "$manifest" ]]; then + printf 'unknown\n' + return + fi + + snippet="$( + awk -v dep="$dep" ' + BEGIN { + capturing = 0 + open_count = 0 + close_count = 0 + } + { + if (!capturing && $0 ~ "^[[:space:]]*" dep "[[:space:]]*=") { + capturing = 1 + } + + if (!capturing) { + next + } + + print + open_count += gsub(/\{/, "{") + close_count += gsub(/\}/, "}") + + if (open_count == 0 || close_count >= open_count) { + exit + } + } + ' "$manifest" + )" + + if [[ -z "$snippet" ]]; then + printf 'unknown\n' + return + fi + + if grep -Eq 'workspace[[:space:]]*=[[:space:]]*true' <<<"$snippet"; then + printf 'workspace\n' + else + printf 'explicit\n' + fi +} + +parse_args() { + local -a positionals=() + + while (($# > 0)); do + case "$1" in + summary|why|suggest) + if [[ -n "$MODE" ]]; then + die "multiple commands given: $MODE and $1" + fi + MODE="$1" + ;; + --manifest-path) + shift + (($# > 0)) || die "--manifest-path requires a value" + MANIFEST_PATH="$1" + ;; + --include-dev) + INCLUDE_DEV=1 + ;; + --json) + JSON_OUTPUT=1 + ;; + -h|--help) + usage + exit 0 + ;; + --) + shift + positionals+=("$@") + break + ;; + -*) + die "unknown option: $1" + ;; + *) + positionals+=("$1") + ;; + esac + shift + done + + [[ -n "$MODE" ]] || die "missing command" + + case "$MODE" in + summary) + ((${#positionals[@]} == 0)) || die "summary does not take a crate name" + ;; + why) + ((${#positionals[@]} == 1)) || die "why requires exactly one crate name" + FILTER_CRATE="${positionals[0]}" + ;; + suggest) + ((${#positionals[@]} <= 1)) || die "suggest takes zero or one crate name" + FILTER_CRATE="${positionals[0]:-}" + ;; + esac +} + +collect_metadata() { + HOST_TRIPLE="$(rustc -vV | sed -n 's/^host: //p')" + [[ -n "$HOST_TRIPLE" ]] || die "failed to determine the host target triple" + + cargo metadata --format-version 1 --locked --offline --manifest-path "$MANIFEST_PATH" >"$ALL_METADATA_FILE" + cargo metadata --format-version 1 --locked --offline --manifest-path "$MANIFEST_PATH" --filter-platform "$HOST_TRIPLE" >"$HOST_METADATA_FILE" +} + +build_analysis() { + jq -n \ + --slurpfile all "$ALL_METADATA_FILE" \ + --slurpfile host "$HOST_METADATA_FILE" \ + --argjson include_dev "$INCLUDE_DEV" ' +def kind_label($kind): + if $kind == null then "normal" else $kind end; +def target_label($target): + if $target == null then "all" else $target end; +def source_label($pkg): + if $pkg.source == null then "path" else $pkg.source end; +def source_family($pkg): + if $pkg.source == null then "path" + elif ($pkg.source | startswith("registry+")) then "crates.io" + elif ($pkg.source | startswith("git+")) then "git" + else $pkg.source + end; +def semver_capture($version): + try ( + $version + | capture("^(?[0-9]+)\\.(?[0-9]+)\\.(?[0-9]+)(?:-(?
[^+]+))?(?:\\+(?.*))?$")
+  ) catch {
+    "maj": "0",
+    "min": "0",
+    "patch": "0",
+    "pre": "~",
+    "build": ""
+  };
+def semver_key($version):
+  (semver_capture($version)) as $m
+  | [
+      ($m.maj | tonumber),
+      ($m.min | tonumber),
+      ($m.patch | tonumber),
+      (if ($m.pre // null) == null then 1 else 0 end),
+      ($m.pre // ""),
+      ($m.build // "")
+    ];
+def allowed_dep_kinds($dep_kinds):
+  [
+    $dep_kinds[]?
+    | select(
+        (kind_label(.kind) == "normal")
+        or (kind_label(.kind) == "build")
+        or ($include_dev and kind_label(.kind) == "dev")
+      )
+  ];
+def reachable_ids($metadata):
+  ($metadata.resolve.nodes // []) as $nodes
+  | ($nodes
+      | map({
+          key: .id,
+          value: [
+            .deps[]?
+            | select((allowed_dep_kinds(.dep_kinds) | length) > 0)
+            | .pkg
+          ]
+        })
+      | from_entries) as $adj
+  | def closure($frontier; $seen):
+      if ($frontier | length) == 0 then ($seen | unique)
+      else
+        ([ $frontier[] | $adj[.][]? ] | unique) as $candidates
+        | [ $candidates[] as $candidate | select($seen | index($candidate) | not) | $candidate ] as $next
+        | closure($next; (($seen + $next) | unique))
+      end;
+    closure(($metadata.workspace_members // []); (($metadata.workspace_members // []) | unique));
+($all[0]) as $meta
+| ($host[0]) as $host_meta
+| (reachable_ids($meta)) as $reachable
+| (reachable_ids($host_meta)) as $host_reachable
+| ($meta.packages | map(. as $pkg | select($reachable | index($pkg.id))) | sort_by(.name, .version)) as $packages
+| ($packages | map({key: .id, value: .}) | from_entries) as $pkg_map
+| (
+    [
+      ($meta.resolve.nodes // [])[] as $node
+      | select($reachable | index($node.id))
+      | ($pkg_map[$node.id]) as $from
+      | $node.deps[]? as $edge
+      | (allowed_dep_kinds($edge.dep_kinds)) as $allowed
+      | select(($allowed | length) > 0)
+      | select($reachable | index($edge.pkg))
+      | ($pkg_map[$edge.pkg]) as $to
+      | ($from.dependencies
+          | map(select((.rename // .name) == $edge.name))) as $same_name_decls
+      | ($same_name_decls
+          | map(
+              . as $decl
+              | select(
+                  if ($allowed | length) == 0 then true
+                  else any(
+                    $allowed[];
+                    (($decl.kind // "normal") == kind_label(.kind))
+                    and (($decl.target // "all") == target_label(.target))
+                  )
+                  end
+                )
+            )) as $matched_decls
+      | {
+          to_id: $edge.pkg,
+          to_name: $to.name,
+          from_id: $node.id,
+          from_name: $from.name,
+          from_version: $from.version,
+          from_source: source_label($from),
+          from_source_family: source_family($from),
+          from_manifest_path: ($from.manifest_path // ""),
+          from_editable: (
+            $from.source == null
+            and (($from.manifest_path // "") | startswith($meta.workspace_root + "/"))
+          ),
+          dep_name: $edge.name,
+          reqs: (
+            if ($matched_decls | length) > 0 then ($matched_decls | map(.req) | unique | sort)
+            elif ($same_name_decls | length) > 0 then ($same_name_decls | map(.req) | unique | sort)
+            else ["unknown"]
+            end
+          ),
+          kind_labels: ($allowed | map(kind_label(.kind)) | unique | sort),
+          targets: ($allowed | map(target_label(.target)) | unique | sort),
+          direct_target_specific: ($allowed | any(.target != null))
+        }
+    ]
+  ) as $incoming_edges
+| ($incoming_edges | sort_by(.to_id) | group_by(.to_id) | map({key: .[0].to_id, value: .}) | from_entries) as $incoming_by_to
+| {
+    workspace_root: $meta.workspace_root,
+    manifest_path: ($meta.workspace_root + "/Cargo.toml"),
+    host_triple: "'"$HOST_TRIPLE"'",
+    include_dev: $include_dev,
+    target_scope: "all",
+    duplicates: (
+      $packages
+      | sort_by(.name)
+      | group_by(.name)
+      | map(select((map(.id) | unique | length) > 1))
+      | map(
+          . as $group
+          | ($group | sort_by(semver_key(.version))) as $sorted
+          | ($sorted[-1]) as $candidate_pkg
+          | ($sorted
+              | map(
+                  . as $pkg
+                  | {
+                      id,
+                      version,
+                      source: source_label($pkg),
+                      source_family: source_family($pkg),
+                      host_reachable: ($host_reachable | index($pkg.id) != null),
+                      inbound_edges: ($incoming_by_to[$pkg.id] // [])
+                    }
+                )) as $versions
+          | ($versions[0:-1]) as $lower_versions
+          | ([ $lower_versions[]?.inbound_edges[]? ]) as $lower_blockers
+          | ([ $lower_blockers[] | select(.from_editable) ]) as $local_blockers
+          | ([ $lower_blockers[] | select(.from_editable | not) ]) as $upstream_blockers
+          | ([ $group[] | source_family(.) ] | unique) as $source_families
+          | {
+              name: $group[0].name,
+              status: (
+                if ($source_families | length) > 1 then "source-mismatch"
+                elif (($lower_versions | length) > 0 and ([ $lower_versions[] | select(.host_reachable) ] | length) == 0) then "platform-specific"
+                elif (($lower_blockers | length) > 0 and ($local_blockers | length) == ($lower_blockers | length)) then "locally-fixable"
+                else "upstream-blocked"
+                end
+              ),
+              source_families: $source_families,
+              candidate: {
+                id: $candidate_pkg.id,
+                version: $candidate_pkg.version,
+                source: source_label($candidate_pkg),
+                source_family: source_family($candidate_pkg)
+              },
+              versions: $versions,
+              blockers: $lower_blockers,
+              local_blockers: $local_blockers,
+              upstream_blockers: $upstream_blockers
+            }
+        )
+      | sort_by(
+          (if .status == "locally-fixable" then 0 elif .status == "platform-specific" then 1 elif .status == "upstream-blocked" then 2 else 3 end),
+          .name
+        )
+    )
+  }'
+}
+
+ensure_analysis() {
+  if ((ANALYSIS_READY == 1)); then
+    return
+  fi
+
+  require_tool cargo
+  require_tool jq
+  require_tool rustc
+
+  ALL_METADATA_FILE="$(make_tmp)"
+  HOST_METADATA_FILE="$(make_tmp)"
+  ANALYSIS_FILE="$(make_tmp)"
+  TREE_EDGES="normal,build"
+  if ((INCLUDE_DEV == 1)); then
+    TREE_EDGES="normal,build,dev"
+  fi
+
+  collect_metadata
+  build_analysis >"$ANALYSIS_FILE"
+
+  WORKSPACE_ROOT="$(jq -r '.workspace_root' "$ANALYSIS_FILE")"
+  WORKSPACE_MANIFEST_PATH="$(jq -r '.manifest_path' "$ANALYSIS_FILE")"
+  ANALYSIS_READY=1
+}
+
+duplicate_record_or_die() {
+  local crate="$1"
+  local record
+
+  record="$(jq -c --arg crate "$crate" '.duplicates[] | select(.name == $crate)' "$ANALYSIS_FILE")"
+  [[ -n "$record" ]] || die "no duplicate resolved versions found for crate '$crate'"
+  printf '%s\n' "$record"
+}
+
+cargo_tree_excerpt() {
+  local spec="$1"
+  local tree
+  local line_count
+
+  tree="$(
+    cargo tree \
+      -i "$spec" \
+      --workspace \
+      --locked \
+      --offline \
+      --charset ascii \
+      --target all \
+      --edges "$TREE_EDGES"
+  )"
+
+  line_count="$(printf '%s\n' "$tree" | wc -l | tr -d ' ')"
+  if ((line_count > WHY_TREE_MAX_LINES)); then
+    printf '%s\n' "$tree" | sed -n "1,${WHY_TREE_MAX_LINES}p"
+    printf '...\n'
+  else
+    printf '%s\n' "$tree"
+  fi
+}
+
+emit_summary() {
+  if ((JSON_OUTPUT == 1)); then
+    cat "$ANALYSIS_FILE"
+    return
+  fi
+
+  local count
+  count="$(jq -r '.duplicates | length' "$ANALYSIS_FILE")"
+  if [[ "$count" == "0" ]]; then
+    echo "No duplicate crates found for the selected graph."
+    return
+  fi
+
+  printf 'Duplicate crates (target=all, edges=%s)\n' "$TREE_EDGES"
+  printf '%-19s %-24s %-34s %s\n' "STATUS" "CRATE" "VERSIONS" "DETAIL"
+
+  while IFS=$'\t' read -r status name versions detail; do
+    printf '%-19s %-24s %-34s %s\n' "$status" "$name" "$versions" "$detail"
+  done < <(
+    jq -r '
+      .duplicates[]
+      | [
+          .status,
+          .name,
+          (.versions | map(.version + " [" + .source_family + "]") | join(", ")),
+          (
+            if .status == "source-mismatch" then
+              "sources=" + (.source_families | join(", "))
+            else
+              ([ .blockers[] | "\(.from_name) \(.from_version) -> \(.reqs | join(" | "))" ]
+                | unique
+                | .[0:2]
+                | if length == 0 then "-" else join("; ") end)
+            end
+          )
+        ]
+      | @tsv
+    ' "$ANALYSIS_FILE"
+  )
+
+  printf '\nUse `%s why ` for reverse paths and `%s suggest ` for local actions.\n' "$SELF_REL" "$SELF_REL"
+}
+
+emit_why() {
+  local record_json="$1"
+  local name status candidate
+  local -a version_payloads=()
+  local version
+  local count
+
+  if ((JSON_OUTPUT == 1)); then
+    while IFS= read -r version; do
+      local repeated spec tree
+      repeated="$(jq -r --arg version "$version" '[.versions[] | select(.version == $version)] | length > 1' <<<"$record_json")"
+      spec="$(jq -r --arg version "$version" '.versions[] | select(.version == $version) | .id' <<<"$record_json" | head -n 1)"
+      if [[ "$repeated" == "true" ]]; then
+        tree="reverse tree omitted because multiple source variants share version $version"
+      else
+        spec="$(jq -r --arg version "$version" '.versions[] | select(.version == $version) | .name? // empty' <<<"$record_json")"
+        spec="${FILTER_CRATE}@${version}"
+        tree="$(cargo_tree_excerpt "$spec")"
+      fi
+      version_payloads+=("$(jq -n --arg version "$version" --arg spec "$spec" --arg reverse_tree "$tree" '{version: $version, spec: $spec, reverse_tree: $reverse_tree}')")
+    done < <(jq -r '.versions[].version' <<<"$record_json")
+
+    if ((${#version_payloads[@]} == 0)); then
+      jq -n --argjson duplicate "$record_json" '{duplicate: $duplicate, reverse_trees: []}'
+    else
+      printf '%s\n' "${version_payloads[@]}" | jq -s --argjson duplicate "$record_json" '{duplicate: $duplicate, reverse_trees: .}'
+    fi
+    return
+  fi
+
+  name="$(jq -r '.name' <<<"$record_json")"
+  status="$(jq -r '.status' <<<"$record_json")"
+  candidate="$(jq -r '.candidate.version + " [" + .candidate.source_family + "]"' <<<"$record_json")"
+
+  printf '%s [%s]\n' "$name" "$(humanize_status "$status")"
+  printf 'candidate: %s\n' "$candidate"
+  printf 'versions: %s\n' "$(jq -r '.versions | map(.version + " [" + .source_family + "]") | join(", ")' <<<"$record_json")"
+
+  while IFS= read -r version; do
+    local source_family host_reachable repeated tree spec
+    source_family="$(jq -r --arg version "$version" '.versions[] | select(.version == $version) | .source_family' <<<"$record_json" | head -n 1)"
+    host_reachable="$(jq -r --arg version "$version" '.versions[] | select(.version == $version) | .host_reachable' <<<"$record_json" | head -n 1)"
+    repeated="$(jq -r --arg version "$version" '[.versions[] | select(.version == $version)] | length > 1' <<<"$record_json")"
+
+    printf '\n%s [%s] host-reachable=%s\n' "$version" "$source_family" "$host_reachable"
+    echo "incoming requirements:"
+    while IFS=$'\t' read -r from_name from_version reqs kinds targets editable; do
+      printf '  - %s %s -> %s [kind=%s target=%s editable=%s]\n' "$from_name" "$from_version" "$reqs" "$kinds" "$targets" "$editable"
+    done < <(
+      jq -r --arg version "$version" '
+        .versions[]
+        | select(.version == $version)
+        | .inbound_edges[]
+        | [
+            .from_name,
+            .from_version,
+            (.reqs | join(" | ")),
+            (.kind_labels | join(",")),
+            (.targets | join(",")),
+            (if .from_editable then "yes" else "no" end)
+          ]
+        | @tsv
+      ' <<<"$record_json"
+    )
+
+    echo "reverse tree excerpt:"
+    if [[ "$repeated" == "true" ]]; then
+      echo "  reverse tree omitted because multiple source variants share version $version"
+    else
+      spec="${FILTER_CRATE}@${version}"
+      tree="$(cargo_tree_excerpt "$spec")"
+      sed 's/^/  /' <<<"$tree"
+    fi
+  done < <(jq -r '.versions[].version' <<<"$record_json")
+}
+
+build_upstream_blockers_json() {
+  local record_json="$1"
+
+  jq '
+    [
+      .upstream_blockers[]
+      | {
+          from_name,
+          from_version,
+          reqs: (.reqs | join(" | ")),
+          kinds: (.kind_labels | join(",")),
+          targets: (.targets | join(",")),
+          manifest_path: .from_manifest_path
+        }
+    ]
+    | unique_by([.from_name, .from_version, .reqs, .kinds, .targets, .manifest_path])
+    | sort_by(.from_name, .from_version, .reqs, .targets)
+  ' <<<"$record_json"
+}
+
+build_suggest_payload() {
+  local record_json="$1"
+  local name status candidate_version
+  local local_count upstream_count outcome reason
+  local all_allow=1
+  local -a all_reqs=()
+  local -a local_lines=()
+  local -a suggestion_jsons=()
+  local req mode target_manifest via_manifest key from_name from_version reqs kinds targets current_req
+  local repeated_note=""
+  local upstream_blockers_json
+  declare -A seen=()
+
+  name="$(jq -r '.name' <<<"$record_json")"
+  status="$(jq -r '.status' <<<"$record_json")"
+  candidate_version="$(jq -r '.candidate.version' <<<"$record_json")"
+  local_count="$(jq -r '.local_blockers | length' <<<"$record_json")"
+  upstream_count="$(jq -r '.upstream_blockers | length' <<<"$record_json")"
+  upstream_blockers_json="$(build_upstream_blockers_json "$record_json")"
+
+  case "$status" in
+    source-mismatch)
+      outcome="blocked"
+      reason="resolved from multiple source families: $(jq -r '.source_families | join(", ")' <<<"$record_json")"
+      jq -n --argjson duplicate "$record_json" --arg outcome "$outcome" --arg reason "$reason" --argjson blockers "$upstream_blockers_json" '{duplicate: $duplicate, outcome: $outcome, reason: $reason, blockers: $blockers, suggestions: []}'
+      return
+      ;;
+    upstream-blocked)
+      outcome="blocked"
+      reason="lower-version blockers come from upstream crates"
+      jq -n --argjson duplicate "$record_json" --arg outcome "$outcome" --arg reason "$reason" --argjson blockers "$upstream_blockers_json" '{duplicate: $duplicate, outcome: $outcome, reason: $reason, blockers: $blockers, suggestions: []}'
+      return
+      ;;
+  esac
+
+  if [[ "$local_count" == "0" ]]; then
+    outcome="blocked"
+    reason="no editable first-party blocker manifests were found"
+    jq -n --argjson duplicate "$record_json" --arg outcome "$outcome" --arg reason "$reason" --argjson blockers "$upstream_blockers_json" '{duplicate: $duplicate, outcome: $outcome, reason: $reason, blockers: $blockers, suggestions: []}'
+    return
+  fi
+
+  if [[ "$upstream_count" != "0" ]]; then
+    outcome="blocked"
+    reason="upstream crates still require a lower version, so local edits cannot fully flatten this duplicate"
+    jq -n --argjson duplicate "$record_json" --arg outcome "$outcome" --arg reason "$reason" --argjson blockers "$upstream_blockers_json" '{duplicate: $duplicate, outcome: $outcome, reason: $reason, blockers: $blockers, suggestions: []}'
+    return
+  fi
+
+  while IFS= read -r req; do
+    all_reqs+=("$req")
+  done < <(jq -r '.local_blockers[].reqs[]' <<<"$record_json")
+
+  for req in "${all_reqs[@]}"; do
+    if ! req_allows_version "$req" "$candidate_version"; then
+      all_allow=0
+      break
+    fi
+  done
+
+  if ((all_allow == 1)); then
+    outcome="actionable"
+    reason="all editable requirements already admit the highest resolved version"
+    jq -n \
+      --argjson duplicate "$record_json" \
+      --arg outcome "$outcome" \
+      --arg reason "$reason" \
+      --arg crate "$name" \
+      --arg version "$candidate_version" \
+      '{
+         duplicate: $duplicate,
+         outcome: $outcome,
+         reason: $reason,
+         suggestions: [
+           {
+             type: "cargo-update",
+             crate: $crate,
+             version: $version,
+             command: ("cargo update -p " + $crate + " --precise " + $version)
+           }
+         ]
+       }'
+    return
+  fi
+
+  while IFS=$'\t' read -r via_manifest from_name from_version reqs kinds targets; do
+    mode="$(manifest_dep_mode "$via_manifest" "$name")"
+    target_manifest="$via_manifest"
+    if [[ "$mode" == "workspace" ]]; then
+      target_manifest="$WORKSPACE_MANIFEST_PATH"
+    fi
+
+    key="${target_manifest}|${reqs}"
+    if [[ -n "${seen[$key]:-}" ]]; then
+      continue
+    fi
+    seen[$key]=1
+
+    current_req="$reqs"
+    suggestion_jsons+=("$(
+      jq -n \
+        --arg type "$(if [[ "$mode" == "workspace" ]]; then printf 'workspace-bump'; else printf 'manifest-bump'; fi)" \
+        --arg crate "$name" \
+        --arg target_manifest "$target_manifest" \
+        --arg via_manifest "$via_manifest" \
+        --arg from_requirement "$current_req" \
+        --arg to_requirement "^${candidate_version}" \
+        --arg via_package "${from_name} ${from_version}" \
+        --arg kinds "$kinds" \
+        --arg targets "$targets" \
+        '{
+           type: $type,
+           crate: $crate,
+           target_manifest: $target_manifest,
+           via_manifest: $via_manifest,
+           via_package: $via_package,
+           from_requirement: $from_requirement,
+           to_requirement: $to_requirement,
+           kinds: $kinds,
+           targets: $targets
+         }'
+    )")
+  done < <(
+    jq -r '
+      .local_blockers[]
+      | [
+          .from_manifest_path,
+          .from_name,
+          .from_version,
+          (.reqs | join(" | ")),
+          (.kind_labels | join(",")),
+          (.targets | join(","))
+        ]
+      | @tsv
+    ' <<<"$record_json"
+  )
+
+  if ((${#suggestion_jsons[@]} == 0)); then
+    outcome="blocked"
+    reason="editable blockers were found, but no manifest suggestion could be constructed"
+    jq -n --argjson duplicate "$record_json" --arg outcome "$outcome" --arg reason "$reason" --argjson blockers "$upstream_blockers_json" '{duplicate: $duplicate, outcome: $outcome, reason: $reason, blockers: $blockers, suggestions: []}'
+    return
+  fi
+
+  outcome="actionable"
+  reason="editable blocker requirements need to move to the highest resolved version"
+  printf '%s\n' "${suggestion_jsons[@]}" | jq -s --argjson duplicate "$record_json" --arg outcome "$outcome" --arg reason "$reason" --argjson blockers "$upstream_blockers_json" '{duplicate: $duplicate, outcome: $outcome, reason: $reason, blockers: $blockers, suggestions: .}'
+}
+
+emit_suggest() {
+  local -a payloads=()
+  local record_json payload_json
+
+  if [[ -n "$FILTER_CRATE" ]]; then
+    record_json="$(duplicate_record_or_die "$FILTER_CRATE")"
+    payload_json="$(build_suggest_payload "$record_json")"
+    if ((JSON_OUTPUT == 1)); then
+      printf '%s\n' "$payload_json"
+      return
+    fi
+    payloads=("$payload_json")
+  else
+    while IFS= read -r record_json; do
+      payloads+=("$(build_suggest_payload "$record_json")")
+    done < <(jq -c '.duplicates[]' "$ANALYSIS_FILE")
+
+    if ((JSON_OUTPUT == 1)); then
+      if ((${#payloads[@]} == 0)); then
+        jq -n '{duplicates: []}'
+      else
+        printf '%s\n' "${payloads[@]}" | jq -s '{duplicates: .}'
+      fi
+      return
+    fi
+  fi
+
+  if ((${#payloads[@]} == 0)); then
+    echo "No duplicate crates found for the selected graph."
+    return
+  fi
+
+  for payload_json in "${payloads[@]}"; do
+    local name status outcome reason
+    name="$(jq -r '.duplicate.name' <<<"$payload_json")"
+    status="$(jq -r '.duplicate.status' <<<"$payload_json")"
+    outcome="$(jq -r '.outcome' <<<"$payload_json")"
+    reason="$(jq -r '.reason' <<<"$payload_json")"
+
+    printf '%s [%s] %s\n' "$name" "$(humanize_status "$status")" "$outcome"
+    printf '  %s\n' "$reason"
+
+    if [[ "$(jq -r '.blockers | length' <<<"$payload_json")" != "0" ]]; then
+      while IFS= read -r blocker_json; do
+        local blocker_from_name blocker_from_version blocker_reqs blocker_kinds blocker_targets
+        blocker_from_name="$(jq -r '.from_name' <<<"$blocker_json")"
+        blocker_from_version="$(jq -r '.from_version' <<<"$blocker_json")"
+        blocker_reqs="$(jq -r '.reqs' <<<"$blocker_json")"
+        blocker_kinds="$(jq -r '.kinds' <<<"$blocker_json")"
+        blocker_targets="$(jq -r '.targets' <<<"$blocker_json")"
+        printf '  - blocker: %s %s -> %s [kind=%s target=%s]\n' \
+          "$blocker_from_name" \
+          "$blocker_from_version" \
+          "$blocker_reqs" \
+          "$blocker_kinds" \
+          "$blocker_targets"
+      done < <(jq -c '.blockers[]' <<<"$payload_json")
+    fi
+
+    if [[ "$(jq -r '.suggestions | length' <<<"$payload_json")" == "0" ]]; then
+      echo
+      continue
+    fi
+
+    while IFS= read -r suggestion_json; do
+      local type crate command target_manifest via_manifest via_package from_requirement to_requirement kinds targets
+      type="$(jq -r '.type' <<<"$suggestion_json")"
+
+      case "$type" in
+        cargo-update)
+          command="$(jq -r '.command' <<<"$suggestion_json")"
+          printf '  - run `%s`\n' "$command"
+          ;;
+        workspace-bump|manifest-bump)
+          crate="$(jq -r '.crate' <<<"$suggestion_json")"
+          target_manifest="$(jq -r '.target_manifest' <<<"$suggestion_json")"
+          via_manifest="$(jq -r '.via_manifest' <<<"$suggestion_json")"
+          via_package="$(jq -r '.via_package' <<<"$suggestion_json")"
+          from_requirement="$(jq -r '.from_requirement' <<<"$suggestion_json")"
+          to_requirement="$(jq -r '.to_requirement' <<<"$suggestion_json")"
+          kinds="$(jq -r '.kinds' <<<"$suggestion_json")"
+          targets="$(jq -r '.targets' <<<"$suggestion_json")"
+          printf '  - edit %s: move `%s` from `%s` to `%s` (via %s in %s; kind=%s target=%s)\n' \
+            "$(display_path "$target_manifest")" \
+            "$crate" \
+            "$from_requirement" \
+            "$to_requirement" \
+            "$via_package" \
+            "$(display_path "$via_manifest")" \
+            "$kinds" \
+            "$targets"
+          ;;
+      esac
+    done < <(jq -c '.suggestions[]' <<<"$payload_json")
+
+    echo
+  done
+}
+
+main() {
+  parse_args "$@"
+  ensure_analysis
+
+  case "$MODE" in
+    summary)
+      emit_summary
+      ;;
+    why)
+      emit_why "$(duplicate_record_or_die "$FILTER_CRATE")"
+      ;;
+    suggest)
+      emit_suggest
+      ;;
+  esac
+}
+
+main "$@"

From 3aa8b20864bd6060cbfbbb611548301bd59eac3e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Sampo=20Kivist=C3=B6?= 
Date: Sun, 12 Apr 2026 21:28:45 +0300
Subject: [PATCH 5/8] wip

---
 crates/gitcomet-state/src/model.rs            |  39 ++
 crates/gitcomet-state/src/msg/message.rs      |  11 +
 crates/gitcomet-state/src/session.rs          |  32 +-
 crates/gitcomet-state/src/store/reducer.rs    |  12 +
 .../src/store/reducer/actions_emit_effects.rs |   2 +-
 .../src/store/reducer/effects.rs              |  33 ++
 .../src/store/reducer/external_and_history.rs |  25 +-
 .../src/store/reducer/repo_management.rs      |  40 +-
 .../gitcomet-state/src/store/reducer/util.rs  | 111 ++--
 .../tests/session_integration.rs              |   9 +
 crates/gitcomet-ui-gpui/src/view/mod.rs       |  59 ++
 .../gitcomet-ui-gpui/src/view/mod_helpers.rs  |   1 -
 .../gitcomet-ui-gpui/src/view/panels/mod.rs   |   6 -
 .../src/view/panels/popover.rs                |  28 +-
 .../src/view/panels/popover/context_menu.rs   |  29 +-
 .../context_menu/history_column_settings.rs   |  81 ---
 .../view/panels/popover/context_menu/tag.rs   |  25 +-
 .../src/view/panels/popover/fingerprint.rs    |   3 -
 .../src/view/panels/tests/shortcuts.rs        |  37 --
 .../src/view/panes/history.rs                 | 222 +++++--
 .../src/view/panes/history/history_panel.rs   | 111 +---
 .../src/view/panes/main/core_impl.rs          |  46 +-
 .../gitcomet-ui-gpui/src/view/rows/history.rs |  27 +-
 .../src/view/rows/history_canvas.rs           |  50 +-
 .../src/view/settings_window.rs               | 327 ++++++++++-
 crates/gitcomet-ui-gpui/src/view/tooltip.rs   |  19 +-
 scripts/profile-gitcomet-process-tree.sh      | 546 ++++++++++++++++++
 27 files changed, 1513 insertions(+), 418 deletions(-)
 delete mode 100644 crates/gitcomet-ui-gpui/src/view/panels/popover/context_menu/history_column_settings.rs
 create mode 100755 scripts/profile-gitcomet-process-tree.sh

diff --git a/crates/gitcomet-state/src/model.rs b/crates/gitcomet-state/src/model.rs
index 8a73a810..c842ccc4 100644
--- a/crates/gitcomet-state/src/model.rs
+++ b/crates/gitcomet-state/src/model.rs
@@ -7,6 +7,7 @@ use gitcomet_core::conflict_session::{
 use gitcomet_core::domain::*;
 use gitcomet_core::process::GitRuntimeState;
 use gitcomet_core::services::BlameLine;
+use serde::{Deserialize, Serialize};
 use std::collections::VecDeque;
 use std::path::PathBuf;
 use std::sync::Arc;
@@ -21,6 +22,43 @@ pub struct SidebarDataRequest {
     pub stashes: bool,
 }
 
+#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
+#[serde(rename_all = "snake_case")]
+pub enum GitLogTagFetchMode {
+    OnRepositoryActivation,
+    Disabled,
+}
+
+impl Default for GitLogTagFetchMode {
+    fn default() -> Self {
+        Self::OnRepositoryActivation
+    }
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub struct GitLogSettings {
+    pub show_history_tags: bool,
+    pub tag_fetch_mode: GitLogTagFetchMode,
+}
+
+impl Default for GitLogSettings {
+    fn default() -> Self {
+        Self {
+            show_history_tags: true,
+            tag_fetch_mode: GitLogTagFetchMode::OnRepositoryActivation,
+        }
+    }
+}
+
+impl GitLogSettings {
+    pub fn auto_fetch_tags_on_repo_activation(self) -> bool {
+        matches!(
+            self.tag_fetch_mode,
+            GitLogTagFetchMode::OnRepositoryActivation
+        )
+    }
+}
+
 #[derive(Clone, Debug, Default, Eq, PartialEq)]
 pub struct RepoLoadsInFlight {
     in_flight: u32,
@@ -243,6 +281,7 @@ pub struct AppState {
     pub banner_error: Option,
     pub auth_prompt: Option,
     pub git_runtime: GitRuntimeState,
+    pub git_log_settings: GitLogSettings,
 }
 
 #[derive(Clone, Debug, Eq, PartialEq)]
diff --git a/crates/gitcomet-state/src/msg/message.rs b/crates/gitcomet-state/src/msg/message.rs
index 26f9abb1..3b131a6e 100644
--- a/crates/gitcomet-state/src/msg/message.rs
+++ b/crates/gitcomet-state/src/msg/message.rs
@@ -1,3 +1,4 @@
+use crate::model::GitLogTagFetchMode;
 use crate::model::{ConflictFileLoadMode, RepoId, SidebarDataRequest};
 use gitcomet_core::conflict_session::ConflictSession;
 use gitcomet_core::domain::*;
@@ -90,6 +91,10 @@ pub enum Msg {
     },
     CancelAuthPrompt,
     SetGitRuntimeState(GitRuntimeState),
+    SetGitLogSettings {
+        show_history_tags: bool,
+        tag_fetch_mode: GitLogTagFetchMode,
+    },
     SetActiveRepo {
         repo_id: RepoId,
     },
@@ -164,6 +169,12 @@ pub enum Msg {
     LoadSubmodules {
         repo_id: RepoId,
     },
+    LoadTags {
+        repo_id: RepoId,
+    },
+    LoadRemoteTags {
+        repo_id: RepoId,
+    },
     RefreshBranches {
         repo_id: RepoId,
     },
diff --git a/crates/gitcomet-state/src/session.rs b/crates/gitcomet-state/src/session.rs
index edb08bf7..91c1f0b4 100644
--- a/crates/gitcomet-state/src/session.rs
+++ b/crates/gitcomet-state/src/session.rs
@@ -1,4 +1,4 @@
-use crate::model::{AppState, RepoId};
+use crate::model::{AppState, GitLogTagFetchMode, RepoId};
 use gitcomet_core::domain::LogScope;
 use rustc_hash::FxHashSet;
 use serde::{Deserialize, Serialize};
@@ -32,9 +32,12 @@ pub struct UiSession {
     pub diff_scroll_sync: Option,
     pub change_tracking_height: Option,
     pub untracked_height: Option,
+    pub history_show_graph: Option,
     pub history_show_author: Option,
     pub history_show_date: Option,
     pub history_show_sha: Option,
+    pub history_show_tags: Option,
+    pub history_tag_fetch_mode: Option,
     pub git_executable_path: Option,
 }
 
@@ -92,9 +95,12 @@ struct UiSessionFileV2 {
     diff_scroll_sync: Option,
     change_tracking_height: Option,
     untracked_height: Option,
+    history_show_graph: Option,
     history_show_author: Option,
     history_show_date: Option,
     history_show_sha: Option,
+    history_show_tags: Option,
+    history_tag_fetch_mode: Option,
     git_executable_path: Option,
     repo_history_scopes: Option>,
     repo_fetch_prune_deleted_remote_tracking_branches: Option>,
@@ -149,9 +155,12 @@ pub fn load_from_path(path: &Path) -> UiSession {
         diff_scroll_sync: file.diff_scroll_sync,
         change_tracking_height: file.change_tracking_height,
         untracked_height: file.untracked_height,
+        history_show_graph: file.history_show_graph,
         history_show_author: file.history_show_author,
         history_show_date: file.history_show_date,
         history_show_sha: file.history_show_sha,
+        history_show_tags: file.history_show_tags,
+        history_tag_fetch_mode: file.history_tag_fetch_mode,
         git_executable_path: file
             .git_executable_path
             .as_deref()
@@ -356,9 +365,12 @@ pub struct UiSettings {
     pub diff_scroll_sync: Option,
     pub change_tracking_height: Option,
     pub untracked_height: Option,
+    pub history_show_graph: Option,
     pub history_show_author: Option,
     pub history_show_date: Option,
     pub history_show_sha: Option,
+    pub history_show_tags: Option,
+    pub history_tag_fetch_mode: Option,
     pub git_executable_path: Option>,
 }
 
@@ -419,6 +431,9 @@ pub fn persist_ui_settings_to_path(settings: UiSettings, path: &Path) -> io::Res
     if let Some(value) = settings.untracked_height {
         file.untracked_height = Some(value);
     }
+    if let Some(value) = settings.history_show_graph {
+        file.history_show_graph = Some(value);
+    }
     if let Some(value) = settings.history_show_author {
         file.history_show_author = Some(value);
     }
@@ -428,6 +443,12 @@ pub fn persist_ui_settings_to_path(settings: UiSettings, path: &Path) -> io::Res
     if let Some(value) = settings.history_show_sha {
         file.history_show_sha = Some(value);
     }
+    if let Some(value) = settings.history_show_tags {
+        file.history_show_tags = Some(value);
+    }
+    if let Some(value) = settings.history_tag_fetch_mode {
+        file.history_tag_fetch_mode = Some(value);
+    }
     if let Some(path) = settings.git_executable_path {
         file.git_executable_path = path.map(|path| path_storage_key(&path));
     }
@@ -1556,6 +1577,7 @@ mod tests {
                 history_show_date: None,
                 history_show_sha: None,
                 git_executable_path: None,
+                ..UiSettings::default()
             },
             &path,
         )
@@ -1614,6 +1636,7 @@ mod tests {
                 history_show_date: None,
                 history_show_sha: None,
                 git_executable_path: None,
+                ..UiSettings::default()
             },
             &path,
         )
@@ -1669,6 +1692,7 @@ mod tests {
                 history_show_date: None,
                 history_show_sha: None,
                 git_executable_path: None,
+                ..UiSettings::default()
             },
             &path,
         )
@@ -1724,6 +1748,7 @@ mod tests {
                 history_show_date: None,
                 history_show_sha: None,
                 git_executable_path: None,
+                ..UiSettings::default()
             },
             &path,
         )
@@ -1779,6 +1804,7 @@ mod tests {
                 history_show_date: None,
                 history_show_sha: None,
                 git_executable_path: None,
+                ..UiSettings::default()
             },
             &path,
         )
@@ -1837,6 +1863,7 @@ mod tests {
                 history_show_date: None,
                 history_show_sha: None,
                 git_executable_path: None,
+                ..UiSettings::default()
             },
             &path,
         )
@@ -1892,6 +1919,7 @@ mod tests {
                 history_show_date: None,
                 history_show_sha: None,
                 git_executable_path: None,
+                ..UiSettings::default()
             },
             &path,
         )
@@ -1948,6 +1976,7 @@ mod tests {
                 history_show_date: None,
                 history_show_sha: None,
                 git_executable_path: None,
+                ..UiSettings::default()
             },
             &path,
         )
@@ -2003,6 +2032,7 @@ mod tests {
                 history_show_date: None,
                 history_show_sha: None,
                 git_executable_path: Some(Some(PathBuf::new())),
+                ..UiSettings::default()
             },
             &path,
         )
diff --git a/crates/gitcomet-state/src/store/reducer.rs b/crates/gitcomet-state/src/store/reducer.rs
index 537c9e11..f552ca51 100644
--- a/crates/gitcomet-state/src/store/reducer.rs
+++ b/crates/gitcomet-state/src/store/reducer.rs
@@ -78,6 +78,8 @@ pub(crate) fn msg_requires_available_git(msg: &Msg) -> bool {
             | Msg::LoadBlame { .. }
             | Msg::LoadWorktrees { .. }
             | Msg::LoadSubmodules { .. }
+            | Msg::LoadTags { .. }
+            | Msg::LoadRemoteTags { .. }
             | Msg::RefreshBranches { .. }
             | Msg::StageHunk { .. }
             | Msg::UnstageHunk { .. }
@@ -552,6 +554,14 @@ pub(super) fn reduce(
             state.git_runtime = runtime;
             Vec::new()
         }
+        Msg::SetGitLogSettings {
+            show_history_tags,
+            tag_fetch_mode,
+        } => {
+            state.git_log_settings.show_history_tags = show_history_tags;
+            state.git_log_settings.tag_fetch_mode = tag_fetch_mode;
+            Vec::new()
+        }
         Msg::SetActiveRepo { repo_id } => repo_management::set_active_repo(state, repo_id),
         Msg::ReorderRepoTabs {
             repo_id,
@@ -610,6 +620,8 @@ pub(super) fn reduce(
         Msg::LoadBlame { repo_id, path, rev } => effects::load_blame(state, repo_id, path, rev),
         Msg::LoadWorktrees { repo_id } => effects::load_worktrees(state, repo_id),
         Msg::LoadSubmodules { repo_id } => effects::load_submodules(state, repo_id),
+        Msg::LoadTags { repo_id } => effects::load_tags(state, repo_id),
+        Msg::LoadRemoteTags { repo_id } => effects::load_remote_tags(state, repo_id),
         Msg::RefreshBranches { repo_id } => effects::refresh_branches(state, repo_id),
         Msg::StageHunk { repo_id, patch } => {
             begin_local_action(state, repo_id);
diff --git a/crates/gitcomet-state/src/store/reducer/actions_emit_effects.rs b/crates/gitcomet-state/src/store/reducer/actions_emit_effects.rs
index 899aad49..b62e0330 100644
--- a/crates/gitcomet-state/src/store/reducer/actions_emit_effects.rs
+++ b/crates/gitcomet-state/src/store/reducer/actions_emit_effects.rs
@@ -763,7 +763,7 @@ pub(super) fn repo_command_finished(
             extra_effects.extend(diff_reload_effects(repo_state, repo_id, target));
         }
     }
-    let mut effects = refresh_full_effects(repo_state);
+    let mut effects = refresh_full_effects(repo_state, state.git_log_settings);
     effects.extend(extra_effects);
     if clear_banner {
         clear_banner_error_for_repo(state, repo_id);
diff --git a/crates/gitcomet-state/src/store/reducer/effects.rs b/crates/gitcomet-state/src/store/reducer/effects.rs
index cfae6d87..82a64dba 100644
--- a/crates/gitcomet-state/src/store/reducer/effects.rs
+++ b/crates/gitcomet-state/src/store/reducer/effects.rs
@@ -322,6 +322,39 @@ pub(super) fn refresh_branches(state: &mut AppState, repo_id: RepoId) -> Vec Vec {
+    let Some(repo_state) = state.repos.iter_mut().find(|r| r.id == repo_id) else {
+        return Vec::new();
+    };
+    if !matches!(repo_state.open, Loadable::Ready(())) {
+        return Vec::new();
+    }
+    repo_state.set_tags(Loadable::Loading);
+    if repo_state.loads_in_flight.request(RepoLoadsInFlight::TAGS) {
+        vec![Effect::LoadTags { repo_id }]
+    } else {
+        Vec::new()
+    }
+}
+
+pub(super) fn load_remote_tags(state: &mut AppState, repo_id: RepoId) -> Vec {
+    let Some(repo_state) = state.repos.iter_mut().find(|r| r.id == repo_id) else {
+        return Vec::new();
+    };
+    if !matches!(repo_state.open, Loadable::Ready(())) {
+        return Vec::new();
+    }
+    repo_state.set_remote_tags(Loadable::Loading);
+    if repo_state
+        .loads_in_flight
+        .request(RepoLoadsInFlight::REMOTE_TAGS)
+    {
+        vec![Effect::LoadRemoteTags { repo_id }]
+    } else {
+        Vec::new()
+    }
+}
+
 pub(super) fn load_conflict_file(
     state: &mut AppState,
     repo_id: RepoId,
diff --git a/crates/gitcomet-state/src/store/reducer/external_and_history.rs b/crates/gitcomet-state/src/store/reducer/external_and_history.rs
index b4410a31..c43c9b34 100644
--- a/crates/gitcomet-state/src/store/reducer/external_and_history.rs
+++ b/crates/gitcomet-state/src/store/reducer/external_and_history.rs
@@ -1,7 +1,8 @@
 use super::util::{
-    SelectedConflictTarget, clear_banner_error_for_repo, diff_reload_effects,
-    handle_session_persist_result, push_diagnostic, refresh_full_effects, refresh_primary_effects,
-    selected_conflict_target, start_conflict_target_reload, start_current_conflict_target_reload,
+    SelectedConflictTarget, append_requested_status_refresh_effects, clear_banner_error_for_repo,
+    diff_reload_effects, handle_session_persist_result, push_diagnostic, refresh_full_effects,
+    refresh_primary_effects, selected_conflict_target, start_conflict_target_reload,
+    start_current_conflict_target_reload,
 };
 use crate::model::{AppState, DiagnosticKind, Loadable, RepoLoadsInFlight};
 use crate::msg::{Effect, RepoExternalChange};
@@ -52,6 +53,7 @@ fn reserve_initial_paginated_log_append_slack(commits: &mut Vec) {
 }
 
 pub(super) fn reload_repo(state: &mut AppState, repo_id: crate::model::RepoId) -> Vec {
+    let git_log_settings = state.git_log_settings;
     let Some(repo_state) = state.repos.iter_mut().find(|r| r.id == repo_id) else {
         return Vec::new();
     };
@@ -59,8 +61,12 @@ pub(super) fn reload_repo(state: &mut AppState, repo_id: crate::model::RepoId) -
     repo_state.set_head_branch(Loadable::Loading);
     repo_state.set_detached_head_commit(None);
     repo_state.set_branches(Loadable::Loading);
-    repo_state.set_tags(Loadable::Loading);
-    repo_state.set_remote_tags(Loadable::Loading);
+    if git_log_settings.show_history_tags && git_log_settings.auto_fetch_tags_on_repo_activation() {
+        repo_state.set_tags(Loadable::Loading);
+    } else {
+        repo_state.set_tags(Loadable::NotLoaded);
+    }
+    repo_state.set_remote_tags(Loadable::NotLoaded);
     repo_state.set_remotes(Loadable::Loading);
     repo_state.set_remote_branches(Loadable::Loading);
     repo_state.set_status(Loadable::Loading);
@@ -80,7 +86,7 @@ pub(super) fn reload_repo(state: &mut AppState, repo_id: crate::model::RepoId) -
     repo_state.set_selected_commit(None);
     repo_state.set_commit_details(Loadable::NotLoaded);
 
-    refresh_full_effects(repo_state)
+    refresh_full_effects(repo_state, git_log_settings)
 }
 
 pub(super) fn repo_externally_changed(
@@ -110,14 +116,15 @@ pub(super) fn repo_externally_changed(
         effects
     } else {
         let mut effects = Vec::new();
-        if (change.worktree || change.index)
+        if change.worktree && change.index {
+            append_requested_status_refresh_effects(repo_state, &mut effects);
+        } else if change.worktree
             && repo_state
                 .loads_in_flight
                 .request(RepoLoadsInFlight::WORKTREE_STATUS)
         {
             effects.push(Effect::LoadWorktreeStatus { repo_id });
-        }
-        if change.index
+        } else if change.index
             && repo_state
                 .loads_in_flight
                 .request(RepoLoadsInFlight::STAGED_STATUS)
diff --git a/crates/gitcomet-state/src/store/reducer/repo_management.rs b/crates/gitcomet-state/src/store/reducer/repo_management.rs
index 58e1507f..659817de 100644
--- a/crates/gitcomet-state/src/store/reducer/repo_management.rs
+++ b/crates/gitcomet-state/src/store/reducer/repo_management.rs
@@ -9,7 +9,8 @@ use super::util::{
 };
 use crate::model::{
     AppNotificationKind, AppState, CloneOpState, CloneOpStatus, CloneProgressMeter,
-    CloneProgressStage, DiagnosticKind, Loadable, RepoId, RepoLoadsInFlight, RepoState,
+    CloneProgressStage, DiagnosticKind, GitLogSettings, Loadable, RepoId, RepoLoadsInFlight,
+    RepoState,
 };
 use crate::msg::Effect;
 use crate::session;
@@ -31,10 +32,14 @@ pub(crate) const REORDER_REPO_TABS_INLINE_EFFECT_CAPACITY: usize = 1;
 pub(crate) type ReorderRepoTabsEffects =
     SmallVec<[Effect; REORDER_REPO_TABS_INLINE_EFFECT_CAPACITY]>;
 
-fn repo_switch_secondary_metadata_ready(repo_state: &RepoState) -> bool {
+fn repo_switch_secondary_metadata_ready(
+    repo_state: &RepoState,
+    git_log_settings: GitLogSettings,
+) -> bool {
     matches!(repo_state.branches, Loadable::Ready(_))
-        && matches!(repo_state.tags, Loadable::Ready(_))
-        && matches!(repo_state.remote_tags, Loadable::Ready(_))
+        && (!git_log_settings.show_history_tags
+            || !git_log_settings.auto_fetch_tags_on_repo_activation()
+            || matches!(repo_state.tags, Loadable::Ready(_)))
         && matches!(repo_state.remotes, Loadable::Ready(_))
         && matches!(repo_state.remote_branches, Loadable::Ready(_))
         && matches!(repo_state.stashes, Loadable::Ready(_))
@@ -42,8 +47,12 @@ fn repo_switch_secondary_metadata_ready(repo_state: &RepoState) -> bool {
         && matches!(repo_state.merge_commit_message, Loadable::Ready(_))
 }
 
-fn repo_switch_can_use_primary_refresh(repo_state: &RepoState, now: SystemTime) -> bool {
-    repo_switch_secondary_metadata_ready(repo_state)
+fn repo_switch_can_use_primary_refresh(
+    repo_state: &RepoState,
+    git_log_settings: GitLogSettings,
+    now: SystemTime,
+) -> bool {
+    repo_switch_secondary_metadata_ready(repo_state, git_log_settings)
         && repo_state
             .last_active_at
             .and_then(|last_active_at| now.duration_since(last_active_at).ok())
@@ -278,6 +287,7 @@ pub(super) fn fill_set_active_repo_inline(
     state.active_repo = Some(repo_id);
     let persist_effect = changed
         .then(|| persist_session_effect(state, Some(repo_id), "switching active repository"));
+    let git_log_settings = state.git_log_settings;
 
     let repo_state = &mut state.repos[repo_ix];
 
@@ -292,7 +302,8 @@ pub(super) fn fill_set_active_repo_inline(
         return;
     }
 
-    let use_full_refresh = changed && !repo_switch_can_use_primary_refresh(repo_state, now);
+    let use_full_refresh =
+        changed && !repo_switch_can_use_primary_refresh(repo_state, git_log_settings, now);
     repo_state.last_active_at = Some(now);
 
     // Reload the selected diff when switching repos; steady-state refreshes rely on the
@@ -333,7 +344,7 @@ pub(super) fn fill_set_active_repo_inline(
         base_effect_capacity + extra_effect_capacity <= SET_ACTIVE_REPO_INLINE_EFFECT_CAPACITY
     );
     if use_full_refresh {
-        append_refresh_full_effects(repo_state, effects);
+        append_refresh_full_effects(repo_state, git_log_settings, effects);
     } else {
         append_refresh_primary_effects(repo_state, effects);
     }
@@ -610,6 +621,7 @@ pub(super) fn repo_opened_ok(
     repo: Arc,
 ) -> Vec {
     repos.insert(repo_id, repo);
+    let git_log_settings = state.git_log_settings;
 
     let spec = RepoSpec {
         workdir: normalize_repo_path(spec.workdir),
@@ -623,8 +635,14 @@ pub(super) fn repo_opened_ok(
         repo_state.set_detached_head_commit(None);
         repo_state.set_upstream_divergence(Loadable::Loading);
         repo_state.set_branches(Loadable::Loading);
-        repo_state.set_tags(Loadable::Loading);
-        repo_state.set_remote_tags(Loadable::Loading);
+        if git_log_settings.show_history_tags
+            && git_log_settings.auto_fetch_tags_on_repo_activation()
+        {
+            repo_state.set_tags(Loadable::Loading);
+        } else {
+            repo_state.set_tags(Loadable::NotLoaded);
+        }
+        repo_state.set_remote_tags(Loadable::NotLoaded);
         repo_state.set_remotes(Loadable::Loading);
         repo_state.set_remote_branches(Loadable::Loading);
         repo_state.set_status(Loadable::Loading);
@@ -659,7 +677,7 @@ pub(super) fn repo_opened_ok(
 
     let should_refresh_worktrees = state.active_repo == Some(repo_id);
     if let Some(repo_state) = state.repos.iter_mut().find(|r| r.id == repo_id) {
-        let mut effects = refresh_full_effects(repo_state);
+        let mut effects = refresh_full_effects(repo_state, git_log_settings);
         if should_refresh_worktrees
             && repo_state
                 .loads_in_flight
diff --git a/crates/gitcomet-state/src/store/reducer/util.rs b/crates/gitcomet-state/src/store/reducer/util.rs
index 820bb9a6..e6b03010 100644
--- a/crates/gitcomet-state/src/store/reducer/util.rs
+++ b/crates/gitcomet-state/src/store/reducer/util.rs
@@ -1,7 +1,7 @@
 use crate::model::{
     AppNotification, AppNotificationKind, AppState, AuthPromptKind, CommandLogEntry,
-    ConflictFileLoadMode, DiagnosticEntry, DiagnosticKind, Loadable, RepoId, RepoLoadsInFlight,
-    RepoState,
+    ConflictFileLoadMode, DiagnosticEntry, DiagnosticKind, GitLogSettings, Loadable, RepoId,
+    RepoLoadsInFlight, RepoState,
 };
 use crate::msg::{ConflictAutosolveMode, ConflictAutosolveStats, Effect, RepoCommandKind};
 #[cfg(test)]
@@ -436,6 +436,30 @@ pub(super) fn refresh_primary_effect_capacity() -> usize {
     PRIMARY_REFRESH_MAX_EFFECTS
 }
 
+fn should_auto_fetch_history_tags(git_log_settings: GitLogSettings) -> bool {
+    git_log_settings.show_history_tags && git_log_settings.auto_fetch_tags_on_repo_activation()
+}
+
+pub(super) fn append_requested_status_refresh_effects(
+    repo_state: &mut RepoState,
+    effects: &mut impl EffectAccumulator,
+) {
+    let repo_id = repo_state.id;
+    let load_worktree = repo_state
+        .loads_in_flight
+        .request(RepoLoadsInFlight::WORKTREE_STATUS);
+    let load_staged = repo_state
+        .loads_in_flight
+        .request(RepoLoadsInFlight::STAGED_STATUS);
+
+    match (load_worktree, load_staged) {
+        (true, true) => effects.push_effect(Effect::LoadStatus { repo_id }),
+        (true, false) => effects.push_effect(Effect::LoadWorktreeStatus { repo_id }),
+        (false, true) => effects.push_effect(Effect::LoadStagedStatus { repo_id }),
+        (false, false) => {}
+    }
+}
+
 fn push_rebase_and_merge_refresh_effect(effects: &mut impl EffectAccumulator, repo_id: RepoId) {
     effects.push_effect(Effect::LoadRebaseAndMergeState { repo_id });
 }
@@ -478,14 +502,13 @@ pub(super) fn append_refresh_primary_effects(
         effects.push_effect(Effect::LoadHeadBranch { repo_id });
         effects.push_effect(Effect::LoadUpstreamDivergence { repo_id });
         push_rebase_and_merge_refresh_effect(effects, repo_id);
-        effects.push_effect(Effect::LoadWorktreeStatus { repo_id });
+        effects.push_effect(Effect::LoadStatus { repo_id });
         effects.push_effect(Effect::LoadLog {
             repo_id,
             scope,
             limit: DEFAULT_LOG_PAGE_SIZE,
             cursor: None,
         });
-        effects.push_effect(Effect::LoadStagedStatus { repo_id });
         return;
     }
 
@@ -502,12 +525,7 @@ pub(super) fn append_refresh_primary_effects(
         effects.push_effect(Effect::LoadUpstreamDivergence { repo_id });
     }
     append_requested_rebase_and_merge_refresh_effects(repo_state, effects);
-    if repo_state
-        .loads_in_flight
-        .request(RepoLoadsInFlight::WORKTREE_STATUS)
-    {
-        effects.push_effect(Effect::LoadWorktreeStatus { repo_id });
-    }
+    append_requested_status_refresh_effects(repo_state, effects);
     if repo_state
         .loads_in_flight
         .request_log(scope, DEFAULT_LOG_PAGE_SIZE, None)
@@ -522,26 +540,24 @@ pub(super) fn append_refresh_primary_effects(
             cursor: None,
         });
     }
-    if repo_state
-        .loads_in_flight
-        .request(RepoLoadsInFlight::STAGED_STATUS)
-    {
-        effects.push_effect(Effect::LoadStagedStatus { repo_id });
-    }
 }
 
 pub(super) fn refresh_full_effect_capacity() -> usize {
     FULL_REFRESH_MAX_EFFECTS
 }
 
-pub(super) fn refresh_full_effects(repo_state: &mut RepoState) -> Vec {
+pub(super) fn refresh_full_effects(
+    repo_state: &mut RepoState,
+    git_log_settings: GitLogSettings,
+) -> Vec {
     let mut effects = Vec::with_capacity(refresh_full_effect_capacity());
-    append_refresh_full_effects(repo_state, &mut effects);
+    append_refresh_full_effects(repo_state, git_log_settings, &mut effects);
     effects
 }
 
 pub(super) fn append_refresh_full_effects(
     repo_state: &mut RepoState,
+    git_log_settings: GitLogSettings,
     effects: &mut impl EffectAccumulator,
 ) {
     let repo_id = repo_state.id;
@@ -560,12 +576,7 @@ pub(super) fn append_refresh_full_effects(
     {
         effects.push_effect(Effect::LoadUpstreamDivergence { repo_id });
     }
-    if repo_state
-        .loads_in_flight
-        .request(RepoLoadsInFlight::WORKTREE_STATUS)
-    {
-        effects.push_effect(Effect::LoadWorktreeStatus { repo_id });
-    }
+    append_requested_status_refresh_effects(repo_state, effects);
     if repo_state.loads_in_flight.request_log(
         repo_state.history_state.history_scope,
         DEFAULT_LOG_PAGE_SIZE,
@@ -579,26 +590,16 @@ pub(super) fn append_refresh_full_effects(
             cursor: None,
         });
     }
-    if repo_state
-        .loads_in_flight
-        .request(RepoLoadsInFlight::STAGED_STATUS)
-    {
-        effects.push_effect(Effect::LoadStagedStatus { repo_id });
-    }
     if repo_state
         .loads_in_flight
         .request(RepoLoadsInFlight::BRANCHES)
     {
         effects.push_effect(Effect::LoadBranches { repo_id });
     }
-    if repo_state.loads_in_flight.request(RepoLoadsInFlight::TAGS) {
-        effects.push_effect(Effect::LoadTags { repo_id });
-    }
-    if repo_state
-        .loads_in_flight
-        .request(RepoLoadsInFlight::REMOTE_TAGS)
+    if should_auto_fetch_history_tags(git_log_settings)
+        && repo_state.loads_in_flight.request(RepoLoadsInFlight::TAGS)
     {
-        effects.push_effect(Effect::LoadRemoteTags { repo_id });
+        effects.push_effect(Effect::LoadTags { repo_id });
     }
     if repo_state
         .loads_in_flight
@@ -1372,13 +1373,13 @@ mod tests {
         let mut primary = repo_state(1);
         primary.set_log_loading_more(true);
         let primary_effects = refresh_primary_effects(&mut primary);
-        assert_eq!(primary_effects.len(), 6);
+        assert_eq!(primary_effects.len(), 5);
         assert!(!primary.log_loading_more);
         assert!(matches!(primary_effects[0], Effect::LoadHeadBranch { .. }));
         assert!(
             primary_effects
                 .iter()
-                .any(|effect| matches!(effect, Effect::LoadWorktreeStatus { .. }))
+                .any(|effect| matches!(effect, Effect::LoadStatus { .. }))
         );
         assert!(matches!(
             primary_effects[4],
@@ -1388,9 +1389,13 @@ mod tests {
             }
         ));
         assert!(
-            primary_effects
-                .iter()
-                .any(|effect| matches!(effect, Effect::LoadStagedStatus { .. }))
+            !primary_effects.iter().any(|effect| {
+                matches!(
+                    effect,
+                    Effect::LoadWorktreeStatus { .. } | Effect::LoadStagedStatus { .. }
+                )
+            }),
+            "primary refresh should coalesce staged and worktree status into LoadStatus"
         );
         assert!(
             primary_effects
@@ -1400,23 +1405,33 @@ mod tests {
 
         let mut full = repo_state(2);
         full.set_log_loading_more(true);
-        let full_effects = refresh_full_effects(&mut full);
-        assert_eq!(full_effects.len(), 11);
+        let full_effects = refresh_full_effects(&mut full, GitLogSettings::default());
+        assert_eq!(full_effects.len(), 9);
         assert!(!full.log_loading_more);
         assert!(
             full_effects
                 .iter()
-                .any(|effect| matches!(effect, Effect::LoadWorktreeStatus { .. }))
+                .any(|effect| matches!(effect, Effect::LoadStatus { .. }))
+        );
+        assert!(
+            !full_effects.iter().any(|effect| {
+                matches!(
+                    effect,
+                    Effect::LoadWorktreeStatus { .. } | Effect::LoadStagedStatus { .. }
+                )
+            }),
+            "full refresh should coalesce staged and worktree status into LoadStatus"
         );
         assert!(
             full_effects
                 .iter()
-                .any(|effect| matches!(effect, Effect::LoadStagedStatus { .. }))
+                .any(|effect| matches!(effect, Effect::LoadTags { .. }))
         );
         assert!(
-            full_effects
+            !full_effects
                 .iter()
-                .any(|effect| matches!(effect, Effect::LoadRemoteTags { .. }))
+                .any(|effect| matches!(effect, Effect::LoadRemoteTags { .. })),
+            "remote tags should lazy-load from tag-specific UI instead of refresh_full_effects"
         );
         assert!(
             !full_effects
diff --git a/crates/gitcomet-state/tests/session_integration.rs b/crates/gitcomet-state/tests/session_integration.rs
index 83bb3801..ca7ee75b 100644
--- a/crates/gitcomet-state/tests/session_integration.rs
+++ b/crates/gitcomet-state/tests/session_integration.rs
@@ -302,9 +302,12 @@ fn persist_ui_settings_to_path_updates_optional_fields_and_requires_both_window_
             diff_scroll_sync: Some("both".to_string()),
             change_tracking_height: Some(222),
             untracked_height: Some(111),
+            history_show_graph: Some(true),
             history_show_author: Some(false),
             history_show_date: Some(true),
             history_show_sha: Some(false),
+            history_show_tags: Some(false),
+            history_tag_fetch_mode: Some(gitcomet_state::model::GitLogTagFetchMode::Disabled),
             git_executable_path: None,
         },
         &session_file,
@@ -330,9 +333,15 @@ fn persist_ui_settings_to_path_updates_optional_fields_and_requires_both_window_
     assert_eq!(loaded.diff_scroll_sync.as_deref(), Some("both"));
     assert_eq!(loaded.change_tracking_height, Some(222));
     assert_eq!(loaded.untracked_height, Some(111));
+    assert_eq!(loaded.history_show_graph, Some(true));
     assert_eq!(loaded.history_show_author, Some(false));
     assert_eq!(loaded.history_show_date, Some(true));
     assert_eq!(loaded.history_show_sha, Some(false));
+    assert_eq!(loaded.history_show_tags, Some(false));
+    assert_eq!(
+        loaded.history_tag_fetch_mode,
+        Some(gitcomet_state::model::GitLogTagFetchMode::Disabled)
+    );
 
     session::persist_ui_settings_to_path(
         UiSettings {
diff --git a/crates/gitcomet-ui-gpui/src/view/mod.rs b/crates/gitcomet-ui-gpui/src/view/mod.rs
index 8e449feb..9f9d06d3 100644
--- a/crates/gitcomet-ui-gpui/src/view/mod.rs
+++ b/crates/gitcomet-ui-gpui/src/view/mod.rs
@@ -487,9 +487,16 @@ impl GitCometView {
         let restored_change_tracking_height = ui_session.change_tracking_height;
         let restored_untracked_height = ui_session.untracked_height;
 
+        let history_show_graph = ui_session.history_show_graph.unwrap_or(true);
         let history_show_author = ui_session.history_show_author.unwrap_or(true);
         let history_show_date = ui_session.history_show_date.unwrap_or(true);
         let history_show_sha = ui_session.history_show_sha.unwrap_or(false);
+        let history_show_tags = ui_session.history_show_tags.unwrap_or(true);
+        let history_tag_fetch_mode = ui_session.history_tag_fetch_mode.unwrap_or_default();
+        store.dispatch(Msg::SetGitLogSettings {
+            show_history_tags: history_show_tags,
+            tag_fetch_mode: history_tag_fetch_mode,
+        });
         let saved_open_repos = ui_session.open_repos.clone();
         let saved_active_repo = ui_session.active_repo.clone();
         let mut startup_repo_bootstrap_pending = false;
@@ -610,9 +617,15 @@ impl GitCometView {
                 timezone,
                 show_timezone,
                 diff_scroll_sync,
+                history_show_graph,
                 history_show_author,
                 history_show_date,
                 history_show_sha,
+                history_show_tags,
+                matches!(
+                    history_tag_fetch_mode,
+                    gitcomet_state::model::GitLogTagFetchMode::OnRepositoryActivation
+                ),
                 view_mode,
                 focused_mergetool_labels,
                 focused_mergetool_exit_code.clone(),
@@ -947,6 +960,52 @@ impl GitCometView {
         self.schedule_ui_settings_persist(cx);
     }
 
+    pub(in crate::view) fn set_history_column_preferences(
+        &mut self,
+        show_graph: bool,
+        show_author: bool,
+        show_date: bool,
+        show_sha: bool,
+        cx: &mut gpui::Context,
+    ) {
+        self.main_pane.update(cx, |pane, cx| {
+            pane.set_history_column_preferences(show_graph, show_author, show_date, show_sha, cx);
+        });
+        self.schedule_ui_settings_persist(cx);
+    }
+
+    pub(in crate::view) fn reset_history_column_widths(&mut self, cx: &mut gpui::Context) {
+        self.main_pane
+            .update(cx, |pane, cx| pane.reset_history_column_widths(cx));
+        self.schedule_ui_settings_persist(cx);
+    }
+
+    pub(in crate::view) fn set_history_tag_preferences(
+        &mut self,
+        show_tags: bool,
+        tag_fetch_mode: gitcomet_state::model::GitLogTagFetchMode,
+        cx: &mut gpui::Context,
+    ) {
+        let auto_fetch_tags_on_repo_activation = matches!(
+            tag_fetch_mode,
+            gitcomet_state::model::GitLogTagFetchMode::OnRepositoryActivation
+        );
+        self.main_pane.update(cx, |pane, cx| {
+            pane.set_history_tag_preferences(show_tags, auto_fetch_tags_on_repo_activation, cx);
+        });
+        self.store.dispatch(Msg::SetGitLogSettings {
+            show_history_tags: show_tags,
+            tag_fetch_mode,
+        });
+        if show_tags
+            && let Some(repo) = self.main_pane.read(cx).active_repo()
+            && matches!(repo.tags, Loadable::NotLoaded | Loadable::Error(_))
+        {
+            self.store.dispatch(Msg::LoadTags { repo_id: repo.id });
+        }
+        self.schedule_ui_settings_persist(cx);
+    }
+
     fn refresh_main_pane_after_panel_animation(&mut self, cx: &mut gpui::Context) {
         let main_pane = self.main_pane.clone();
         cx.defer(move |cx| {
diff --git a/crates/gitcomet-ui-gpui/src/view/mod_helpers.rs b/crates/gitcomet-ui-gpui/src/view/mod_helpers.rs
index 9049dac7..789c6dc6 100644
--- a/crates/gitcomet-ui-gpui/src/view/mod_helpers.rs
+++ b/crates/gitcomet-ui-gpui/src/view/mod_helpers.rs
@@ -2326,7 +2326,6 @@ pub(super) enum PopoverKind {
     HistoryBranchFilter {
         repo_id: RepoId,
     },
-    HistoryColumnSettings,
     ChangeTrackingSettings,
 }
 
diff --git a/crates/gitcomet-ui-gpui/src/view/panels/mod.rs b/crates/gitcomet-ui-gpui/src/view/panels/mod.rs
index 3f1434d1..d62d5c69 100644
--- a/crates/gitcomet-ui-gpui/src/view/panels/mod.rs
+++ b/crates/gitcomet-ui-gpui/src/view/panels/mod.rs
@@ -52,12 +52,6 @@ pub(in crate::view) enum ContextMenuAction {
         repo_id: RepoId,
         scope: gitcomet_core::domain::LogScope,
     },
-    SetHistoryColumns {
-        show_author: bool,
-        show_date: bool,
-        show_sha: bool,
-    },
-    ResetHistoryColumnWidths,
     SetChangeTrackingView {
         view: ChangeTrackingView,
     },
diff --git a/crates/gitcomet-ui-gpui/src/view/panels/popover.rs b/crates/gitcomet-ui-gpui/src/view/panels/popover.rs
index 2387d83c..69742758 100644
--- a/crates/gitcomet-ui-gpui/src/view/panels/popover.rs
+++ b/crates/gitcomet-ui-gpui/src/view/panels/popover.rs
@@ -776,6 +776,26 @@ impl PopoverHost {
         self.open_popover(kind, PopoverAnchor::Bounds(anchor_bounds), window, cx);
     }
 
+    fn request_lazy_popover_repo_data(&self, kind: &PopoverKind) {
+        let repo_id = match kind {
+            PopoverKind::TagMenu { repo_id, .. } => Some(*repo_id),
+            _ => None,
+        };
+        let Some(repo_id) = repo_id else {
+            return;
+        };
+        let Some(repo) = self.state.repos.iter().find(|repo| repo.id == repo_id) else {
+            return;
+        };
+
+        if matches!(repo.tags, Loadable::NotLoaded | Loadable::Error(_)) {
+            self.store.dispatch(Msg::LoadTags { repo_id });
+        }
+        if matches!(repo.remote_tags, Loadable::NotLoaded | Loadable::Error(_)) {
+            self.store.dispatch(Msg::LoadRemoteTags { repo_id });
+        }
+    }
+
     fn open_popover(
         &mut self,
         kind: PopoverKind,
@@ -783,12 +803,12 @@ impl PopoverHost {
         window: &mut Window,
         cx: &mut gpui::Context,
     ) {
+        self.request_lazy_popover_repo_data(&kind);
         let is_context_menu = matches!(
             &kind,
             PopoverKind::PullPicker
                 | PopoverKind::PushPicker
                 | PopoverKind::HistoryBranchFilter { .. }
-                | PopoverKind::HistoryColumnSettings
                 | PopoverKind::ChangeTrackingSettings
                 | PopoverKind::DiffHunkMenu { .. }
                 | PopoverKind::DiffEditorMenu { .. }
@@ -1283,7 +1303,6 @@ impl PopoverHost {
             PopoverKind::PullPicker
                 | PopoverKind::PushPicker
                 | PopoverKind::HistoryBranchFilter { .. }
-                | PopoverKind::HistoryColumnSettings
                 | PopoverKind::ChangeTrackingSettings
                 | PopoverKind::DiffHunkMenu { .. }
                 | PopoverKind::DiffEditorMenu { .. }
@@ -1362,7 +1381,6 @@ impl PopoverHost {
             | PopoverKind::ForceRemoveWorktreeConfirm { .. }
             | PopoverKind::PullReconcilePrompt { .. }
             | PopoverKind::HistoryBranchFilter { .. }
-            | PopoverKind::HistoryColumnSettings
             | PopoverKind::ChangeTrackingSettings => Corner::TopRight,
             _ => Corner::TopLeft,
         };
@@ -1552,10 +1570,6 @@ impl PopoverHost {
                 .context_menu_view(PopoverKind::HistoryBranchFilter { repo_id }, cx)
                 .min_w(px(160.0))
                 .max_w(px(220.0)),
-            PopoverKind::HistoryColumnSettings => self
-                .context_menu_view(PopoverKind::HistoryColumnSettings, cx)
-                .min_w(px(160.0))
-                .max_w(px(220.0)),
             PopoverKind::ChangeTrackingSettings => self
                 .context_menu_view(PopoverKind::ChangeTrackingSettings, cx)
                 .min_w(px(220.0))
diff --git a/crates/gitcomet-ui-gpui/src/view/panels/popover/context_menu.rs b/crates/gitcomet-ui-gpui/src/view/panels/popover/context_menu.rs
index 4f7de944..5a8888f1 100644
--- a/crates/gitcomet-ui-gpui/src/view/panels/popover/context_menu.rs
+++ b/crates/gitcomet-ui-gpui/src/view/panels/popover/context_menu.rs
@@ -11,7 +11,6 @@ mod conflict_resolver_output;
 mod diff_editor;
 mod diff_hunk;
 mod history_branch_filter;
-mod history_column_settings;
 mod pull;
 mod push;
 mod remote;
@@ -329,7 +328,6 @@ impl PopoverHost {
             PopoverKind::HistoryBranchFilter { repo_id } => {
                 Some(history_branch_filter::model(*repo_id))
             }
-            PopoverKind::HistoryColumnSettings => Some(history_column_settings::model(self, cx)),
             PopoverKind::ChangeTrackingSettings => Some(change_tracking_settings::model(self)),
             _ => None,
         }
@@ -341,7 +339,7 @@ impl PopoverHost {
         window: &mut Window,
         cx: &mut gpui::Context,
     ) {
-        let mut close_after_action = true;
+        let close_after_action = true;
         match action {
             ContextMenuAction::SelectDiff { repo_id, target } => {
                 self.store.dispatch(Msg::SelectDiff { repo_id, target });
@@ -470,31 +468,6 @@ impl PopoverHost {
             ContextMenuAction::SetHistoryScope { repo_id, scope } => {
                 self.store.dispatch(Msg::SetHistoryScope { repo_id, scope });
             }
-            ContextMenuAction::SetHistoryColumns {
-                show_author,
-                show_date,
-                show_sha,
-            } => {
-                self.main_pane.update(cx, |pane, cx| {
-                    pane.history_view.update(cx, |view, cx| {
-                        view.history_show_author = show_author;
-                        view.history_show_date = show_date;
-                        view.history_show_sha = show_sha;
-                        cx.notify();
-                    });
-                });
-                self.schedule_ui_settings_persist(cx);
-                close_after_action = false;
-            }
-            ContextMenuAction::ResetHistoryColumnWidths => {
-                self.main_pane.update(cx, |pane, cx| {
-                    pane.history_view.update(cx, |view, cx| {
-                        view.reset_history_column_widths();
-                        cx.notify();
-                    });
-                });
-                close_after_action = false;
-            }
             ContextMenuAction::SetChangeTrackingView { view } => {
                 self.change_tracking_view = view;
                 let root_view = self.root_view.clone();
diff --git a/crates/gitcomet-ui-gpui/src/view/panels/popover/context_menu/history_column_settings.rs b/crates/gitcomet-ui-gpui/src/view/panels/popover/context_menu/history_column_settings.rs
deleted file mode 100644
index fafe1b71..00000000
--- a/crates/gitcomet-ui-gpui/src/view/panels/popover/context_menu/history_column_settings.rs
+++ /dev/null
@@ -1,81 +0,0 @@
-use super::*;
-
-pub(super) fn model(host: &PopoverHost, cx: &gpui::Context) -> ContextMenuModel {
-    let (show_author, show_date, show_sha) = host
-        .main_pane
-        .read(cx)
-        .history_visible_column_preferences(cx);
-
-    model_for_preferences(show_author, show_date, show_sha)
-}
-
-fn model_for_preferences(show_author: bool, show_date: bool, show_sha: bool) -> ContextMenuModel {
-    let check = |enabled: bool| enabled.then_some("icons/check.svg".into());
-
-    ContextMenuModel::new(vec![
-        ContextMenuItem::Header("History columns".into()),
-        ContextMenuItem::Separator,
-        ContextMenuItem::Entry {
-            label: "Author".into(),
-            icon: check(show_author),
-            shortcut: Some("A".into()),
-            disabled: false,
-            action: Box::new(ContextMenuAction::SetHistoryColumns {
-                show_author: !show_author,
-                show_date,
-                show_sha,
-            }),
-        },
-        ContextMenuItem::Entry {
-            label: "Commit date".into(),
-            icon: check(show_date),
-            shortcut: Some("D".into()),
-            disabled: false,
-            action: Box::new(ContextMenuAction::SetHistoryColumns {
-                show_author,
-                show_date: !show_date,
-                show_sha,
-            }),
-        },
-        ContextMenuItem::Entry {
-            label: "SHA".into(),
-            icon: check(show_sha),
-            shortcut: Some("S".into()),
-            disabled: false,
-            action: Box::new(ContextMenuAction::SetHistoryColumns {
-                show_author,
-                show_date,
-                show_sha: !show_sha,
-            }),
-        },
-        ContextMenuItem::Entry {
-            label: "Reset column widths".into(),
-            icon: Some("icons/refresh.svg".into()),
-            shortcut: Some("R".into()),
-            disabled: false,
-            action: Box::new(ContextMenuAction::ResetHistoryColumnWidths),
-        },
-        ContextMenuItem::Separator,
-        ContextMenuItem::Label("Columns may auto-hide in narrow windows".into()),
-    ])
-}
-
-#[cfg(test)]
-mod tests {
-    use super::*;
-
-    #[test]
-    fn model_includes_reset_column_widths_entry() {
-        let model = super::model_for_preferences(true, true, true);
-
-        let has_reset_entry = model.items.iter().any(|item| {
-            matches!(
-                item,
-                ContextMenuItem::Entry { label, action, .. }
-                    if label.as_ref() == "Reset column widths"
-                        && matches!(&**action, ContextMenuAction::ResetHistoryColumnWidths)
-            )
-        });
-        assert!(has_reset_entry);
-    }
-}
diff --git a/crates/gitcomet-ui-gpui/src/view/panels/popover/context_menu/tag.rs b/crates/gitcomet-ui-gpui/src/view/panels/popover/context_menu/tag.rs
index f480dfb9..8191c412 100644
--- a/crates/gitcomet-ui-gpui/src/view/panels/popover/context_menu/tag.rs
+++ b/crates/gitcomet-ui-gpui/src/view/panels/popover/context_menu/tag.rs
@@ -6,12 +6,25 @@ pub(super) fn model(this: &PopoverHost, repo_id: RepoId, commit_id: &CommitId) -
     let short: SharedString = sha.get(0..8).unwrap_or(&sha).to_string().into();
 
     let repo = this.state.repos.iter().find(|r| r.id == repo_id);
-    let tags = repo
-        .and_then(|r| match &r.tags {
-            Loadable::Ready(tags) => Some(tags.as_slice()),
-            _ => None,
-        })
-        .unwrap_or(&[]);
+    let tags = match repo.map(|r| &r.tags) {
+        Some(Loadable::Ready(tags)) => Some(tags.as_slice()),
+        Some(Loadable::Error(err)) => {
+            return ContextMenuModel::new(vec![
+                ContextMenuItem::Header(format!("Tags on {short}").into()),
+                ContextMenuItem::Separator,
+                ContextMenuItem::Label(err.clone().into()),
+            ]);
+        }
+        Some(Loadable::Loading) | Some(Loadable::NotLoaded) => {
+            return ContextMenuModel::new(vec![
+                ContextMenuItem::Header(format!("Tags on {short}").into()),
+                ContextMenuItem::Separator,
+                ContextMenuItem::Label("Loading tags…".into()),
+            ]);
+        }
+        None => None,
+    }
+    .unwrap_or(&[]);
     let mut remote_names = repo
         .and_then(|r| match &r.remotes {
             Loadable::Ready(remotes) => Some(
diff --git a/crates/gitcomet-ui-gpui/src/view/panels/popover/fingerprint.rs b/crates/gitcomet-ui-gpui/src/view/panels/popover/fingerprint.rs
index c3b9ed9d..89c026f5 100644
--- a/crates/gitcomet-ui-gpui/src/view/panels/popover/fingerprint.rs
+++ b/crates/gitcomet-ui-gpui/src/view/panels/popover/fingerprint.rs
@@ -83,7 +83,6 @@ fn repo_for_popover<'a>(state: &'a AppState, popover: &PopoverKind) -> Option<&'
         | PopoverKind::PushPicker
         | PopoverKind::AppMenu
         | PopoverKind::DiffHunks
-        | PopoverKind::HistoryColumnSettings
         | PopoverKind::ConflictResolverInputRowMenu { .. }
         | PopoverKind::ConflictResolverChunkMenu { .. }
         | PopoverKind::ConflictResolverOutputMenu { .. } => state.active_repo,
@@ -226,7 +225,6 @@ fn hash_repo_for_popover(repo: &RepoState, popover: &PopoverKind, has
         | PopoverKind::CommitMenu { .. }
         | PopoverKind::CommitFileMenu { .. }
         | PopoverKind::StatusFileMenu { .. }
-        | PopoverKind::HistoryColumnSettings
         | PopoverKind::ChangeTrackingSettings
         | PopoverKind::ConflictResolverInputRowMenu { .. }
         | PopoverKind::ConflictResolverChunkMenu { .. }
@@ -460,7 +458,6 @@ fn hash_popover_kind(kind: &PopoverKind, hasher: &mut H) {
             48u8.hash(hasher);
             repo_id.hash(hasher);
         }
-        PopoverKind::HistoryColumnSettings => 49u8.hash(hasher),
         PopoverKind::MergeAbortConfirm { repo_id } => {
             51u8.hash(hasher);
             repo_id.hash(hasher);
diff --git a/crates/gitcomet-ui-gpui/src/view/panels/tests/shortcuts.rs b/crates/gitcomet-ui-gpui/src/view/panels/tests/shortcuts.rs
index fbba0071..78269925 100644
--- a/crates/gitcomet-ui-gpui/src/view/panels/tests/shortcuts.rs
+++ b/crates/gitcomet-ui-gpui/src/view/panels/tests/shortcuts.rs
@@ -299,43 +299,6 @@ fn history_context_menu_shortcuts_match_expected_actions(cx: &mut gpui::TestAppC
         });
     });
 
-    let history_columns_model = cx.update(|_window, app| {
-        context_menu_model_for(&view, app, PopoverKind::HistoryColumnSettings)
-    });
-    assert_declared_shortcuts(&history_columns_model, &["A", "D", "S", "R"]);
-    assert_shortcut_action!(
-        history_columns_model,
-        "A",
-        ContextMenuAction::SetHistoryColumns {
-            show_author,
-            show_date,
-            show_sha
-        } if !*show_author && *show_date && *show_sha
-    );
-    assert_shortcut_action!(
-        history_columns_model,
-        "D",
-        ContextMenuAction::SetHistoryColumns {
-            show_author,
-            show_date,
-            show_sha
-        } if *show_author && !*show_date && *show_sha
-    );
-    assert_shortcut_action!(
-        history_columns_model,
-        "S",
-        ContextMenuAction::SetHistoryColumns {
-            show_author,
-            show_date,
-            show_sha
-        } if *show_author && *show_date && !*show_sha
-    );
-    assert_shortcut_action!(
-        history_columns_model,
-        "R",
-        ContextMenuAction::ResetHistoryColumnWidths
-    );
-
     let change_tracking_model = cx.update(|_window, app| {
         context_menu_model_for(&view, app, PopoverKind::ChangeTrackingSettings)
     });
diff --git a/crates/gitcomet-ui-gpui/src/view/panes/history.rs b/crates/gitcomet-ui-gpui/src/view/panes/history.rs
index 6f261635..e27384ac 100644
--- a/crates/gitcomet-ui-gpui/src/view/panes/history.rs
+++ b/crates/gitcomet-ui-gpui/src/view/panes/history.rs
@@ -67,6 +67,7 @@ fn default_history_column_widths() -> HistoryColumnWidths {
 
 #[derive(Copy, Clone)]
 pub(in crate::view) struct HistoryColumnDragLayout {
+    pub(in crate::view) show_graph: bool,
     pub(in crate::view) show_author: bool,
     pub(in crate::view) show_date: bool,
     pub(in crate::view) show_sha: bool,
@@ -79,6 +80,7 @@ pub(in crate::view) struct HistoryColumnDragLayout {
 
 fn history_visible_columns_for_width(
     available_width: Pixels,
+    show_graph: bool,
     preferred: (bool, bool, bool),
     widths: HistoryColumnWidths,
 ) -> (bool, bool, bool) {
@@ -90,8 +92,7 @@ fn history_visible_columns_for_width(
 
     let (mut show_author, mut show_date, mut show_sha) = preferred;
 
-    // Always show Branch + Graph; Message is flex.
-    let fixed_base = widths.branch + widths.graph;
+    let fixed_base = widths.branch + if show_graph { widths.graph } else { px(0.0) };
     let mut fixed = fixed_base
         + if show_author { widths.author } else { px(0.0) }
         + if show_date { widths.date } else { px(0.0) }
@@ -126,16 +127,18 @@ fn history_column_drag_next_width(
     handle: HistoryColResizeHandle,
     candidate: Pixels,
     available_width: Pixels,
+    show_graph: bool,
     preferred: (bool, bool, bool),
     widths: HistoryColumnWidths,
 ) -> Pixels {
     let (show_author, show_date, show_sha) =
-        history_visible_columns_for_width(available_width, preferred, widths);
+        history_visible_columns_for_width(available_width, show_graph, preferred, widths);
     history_column_drag_clamped_width(
         handle,
         candidate,
         available_width,
         HistoryColumnDragLayout {
+            show_graph,
             show_author,
             show_date,
             show_sha,
@@ -150,6 +153,7 @@ fn history_column_drag_next_width(
 
 fn history_reset_widths_for_available_width(
     available_width: Pixels,
+    show_graph: bool,
     preferred: (bool, bool, bool),
 ) -> HistoryColumnWidths {
     let mut widths = default_history_column_widths();
@@ -157,6 +161,7 @@ fn history_reset_widths_for_available_width(
         HistoryColResizeHandle::Graph,
         widths.graph,
         available_width,
+        show_graph,
         preferred,
         widths,
     );
@@ -164,6 +169,7 @@ fn history_reset_widths_for_available_width(
         HistoryColResizeHandle::Branch,
         widths.branch,
         available_width,
+        show_graph,
         preferred,
         widths,
     );
@@ -193,7 +199,26 @@ pub(in crate::view) fn history_column_resize_drag_params(
     let (min_width, static_max_width) = history_column_static_bounds(handle);
     let other_fixed_width = match handle {
         HistoryColResizeHandle::Branch => {
-            layout.graph_w
+            (if layout.show_graph {
+                layout.graph_w
+            } else {
+                px(0.0)
+            }) + if layout.show_author {
+                layout.author_w
+            } else {
+                px(0.0)
+            } + if layout.show_date {
+                layout.date_w
+            } else {
+                px(0.0)
+            } + if layout.show_sha {
+                layout.sha_w
+            } else {
+                px(0.0)
+            }
+        }
+        HistoryColResizeHandle::Graph => {
+            layout.branch_w
                 + if layout.show_author {
                     layout.author_w
                 } else {
@@ -210,10 +235,10 @@ pub(in crate::view) fn history_column_resize_drag_params(
                     px(0.0)
                 }
         }
-        HistoryColResizeHandle::Graph => {
+        HistoryColResizeHandle::Author => {
             layout.branch_w
-                + if layout.show_author {
-                    layout.author_w
+                + if layout.show_graph {
+                    layout.graph_w
                 } else {
                     px(0.0)
                 }
@@ -228,23 +253,13 @@ pub(in crate::view) fn history_column_resize_drag_params(
                     px(0.0)
                 }
         }
-        HistoryColResizeHandle::Author => {
+        HistoryColResizeHandle::Date => {
             layout.branch_w
-                + layout.graph_w
-                + if layout.show_date {
-                    layout.date_w
+                + if layout.show_graph {
+                    layout.graph_w
                 } else {
                     px(0.0)
                 }
-                + if layout.show_sha {
-                    layout.sha_w
-                } else {
-                    px(0.0)
-                }
-        }
-        HistoryColResizeHandle::Date => {
-            layout.branch_w
-                + layout.graph_w
                 + if layout.show_author {
                     layout.author_w
                 } else {
@@ -258,7 +273,11 @@ pub(in crate::view) fn history_column_resize_drag_params(
         }
         HistoryColResizeHandle::Sha => {
             layout.branch_w
-                + layout.graph_w
+                + if layout.show_graph {
+                    layout.graph_w
+                } else {
+                    px(0.0)
+                }
                 + if layout.show_author {
                     layout.author_w
                 } else {
@@ -453,7 +472,12 @@ pub(in crate::view) fn history_visible_columns_for_layout(
     let mut show_date = layout.show_date;
     let mut show_sha = layout.show_sha;
 
-    let fixed_base = layout.branch_w + layout.graph_w;
+    let fixed_base = layout.branch_w
+        + if layout.show_graph {
+            layout.graph_w
+        } else {
+            px(0.0)
+        };
     let mut fixed = fixed_base
         + if show_author {
             layout.author_w
@@ -721,9 +745,12 @@ pub(in super::super) struct HistoryView {
     pub(in super::super) history_col_author: Pixels,
     pub(in super::super) history_col_date: Pixels,
     pub(in super::super) history_col_sha: Pixels,
+    pub(in super::super) history_show_graph: bool,
     pub(in super::super) history_show_author: bool,
     pub(in super::super) history_show_date: bool,
     pub(in super::super) history_show_sha: bool,
+    pub(in super::super) history_show_tags: bool,
+    pub(in super::super) history_auto_fetch_tags_on_repo_activation: bool,
     pub(in super::super) history_col_graph_auto: bool,
     pub(in super::super) history_col_resize: Option,
     pub(in super::super) history_cache: Option,
@@ -737,7 +764,7 @@ pub(in super::super) struct HistoryView {
 }
 
 impl HistoryView {
-    fn notify_fingerprint_for(state: &AppState) -> u64 {
+    fn notify_fingerprint_for(state: &AppState, show_history_tags: bool) -> u64 {
         let mut hasher = FxHasher::default();
         state.active_repo.hash(&mut hasher);
 
@@ -749,7 +776,9 @@ impl HistoryView {
             repo.detached_head_commit.hash(&mut hasher);
             repo.branches_rev.hash(&mut hasher);
             repo.remote_branches_rev.hash(&mut hasher);
-            repo.tags_rev.hash(&mut hasher);
+            if show_history_tags {
+                repo.tags_rev.hash(&mut hasher);
+            }
             repo.stashes_rev.hash(&mut hasher);
             repo.history_state.selected_commit_rev.hash(&mut hasher);
             repo.worktree_status_cache_rev().hash(&mut hasher);
@@ -767,9 +796,12 @@ impl HistoryView {
         date_time_format: DateTimeFormat,
         timezone: Timezone,
         show_timezone: bool,
+        history_show_graph: bool,
         history_show_author: bool,
         history_show_date: bool,
         history_show_sha: bool,
+        history_show_tags: bool,
+        history_auto_fetch_tags_on_repo_activation: bool,
         root_view: WeakEntity,
         tooltip_host: WeakEntity,
         last_window_size: Size,
@@ -777,10 +809,10 @@ impl HistoryView {
         cx: &mut gpui::Context,
     ) -> Self {
         let state = Arc::clone(&ui_model.read(cx).state);
-        let initial_fingerprint = Self::notify_fingerprint_for(&state);
+        let initial_fingerprint = Self::notify_fingerprint_for(&state, history_show_tags);
         let subscription = cx.observe(&ui_model, |this, model, cx| {
             let next = Arc::clone(&model.read(cx).state);
-            let next_fingerprint = Self::notify_fingerprint_for(&next);
+            let next_fingerprint = Self::notify_fingerprint_for(&next, this.history_show_tags);
             if next_fingerprint == this.notify_fingerprint {
                 this.state = next;
                 return;
@@ -815,9 +847,12 @@ impl HistoryView {
             history_col_author: default_widths.author,
             history_col_date: default_widths.date,
             history_col_sha: default_widths.sha,
+            history_show_graph,
             history_show_author,
             history_show_date,
             history_show_sha,
+            history_show_tags,
+            history_auto_fetch_tags_on_repo_activation,
             history_col_graph_auto: true,
             history_col_resize: None,
             history_cache: None,
@@ -853,7 +888,10 @@ impl HistoryView {
             detached_head_commit: repo.detached_head_commit.clone(),
             branches_rev: repo.branches_rev,
             remote_branches_rev: repo.remote_branches_rev,
-            tags_rev: repo.tags_rev,
+            tags_rev: self
+                .history_show_tags
+                .then_some(repo.tags_rev)
+                .unwrap_or_default(),
             stashes_rev: repo.stashes_rev,
             date_time_format: self.date_time_format,
             timezone: self.timezone,
@@ -913,17 +951,19 @@ impl HistoryView {
         )
     }
 
-    pub(in super::super) fn history_visible_column_preferences(&self) -> (bool, bool, bool) {
+    pub(in super::super) fn history_visible_column_preferences(&self) -> (bool, bool, bool, bool) {
         (
+            self.history_show_graph,
             self.history_show_author,
             self.history_show_date,
             self.history_show_sha,
         )
     }
 
-    pub(in super::super) fn history_visible_columns(&self) -> (bool, bool, bool) {
+    pub(in super::super) fn history_visible_columns(&self) -> (bool, bool, bool, bool) {
         let available = self.history_content_width;
         let layout = HistoryColumnDragLayout {
+            show_graph: self.history_show_graph,
             show_author: self.history_show_author,
             show_date: self.history_show_date,
             show_sha: self.history_show_sha,
@@ -933,17 +973,24 @@ impl HistoryView {
             date_w: self.history_col_date,
             sha_w: self.history_col_sha,
         };
-        history_visible_columns_for_layout_with_resize_state(
-            available,
-            layout,
-            self.history_col_resize.as_ref(),
-        )
+        let (show_author, show_date, show_sha) =
+            history_visible_columns_for_layout_with_resize_state(
+                available,
+                layout,
+                self.history_col_resize.as_ref(),
+            );
+        (self.history_show_graph, show_author, show_date, show_sha)
     }
 
     pub(in super::super) fn reset_history_column_widths(&mut self) {
         let widths = history_reset_widths_for_available_width(
             self.history_content_width,
-            self.history_visible_column_preferences(),
+            self.history_show_graph,
+            (
+                self.history_show_author,
+                self.history_show_date,
+                self.history_show_sha,
+            ),
         );
         self.history_col_branch = widths.branch;
         self.history_col_graph = widths.graph;
@@ -1022,6 +1069,60 @@ impl HistoryView {
         cx.notify();
     }
 
+    pub(in super::super) fn history_tag_preferences(&self) -> (bool, bool) {
+        (
+            self.history_show_tags,
+            self.history_auto_fetch_tags_on_repo_activation,
+        )
+    }
+
+    pub(in super::super) fn set_history_column_preferences(
+        &mut self,
+        show_graph: bool,
+        show_author: bool,
+        show_date: bool,
+        show_sha: bool,
+        cx: &mut gpui::Context,
+    ) {
+        if self.history_show_graph == show_graph
+            && self.history_show_author == show_author
+            && self.history_show_date == show_date
+            && self.history_show_sha == show_sha
+        {
+            return;
+        }
+
+        self.history_show_graph = show_graph;
+        self.history_show_author = show_author;
+        self.history_show_date = show_date;
+        self.history_show_sha = show_sha;
+        self.history_col_resize = None;
+        cx.notify();
+    }
+
+    pub(in super::super) fn set_history_tag_preferences(
+        &mut self,
+        show_tags: bool,
+        auto_fetch_tags_on_repo_activation: bool,
+        cx: &mut gpui::Context,
+    ) {
+        if self.history_show_tags == show_tags
+            && self.history_auto_fetch_tags_on_repo_activation == auto_fetch_tags_on_repo_activation
+        {
+            return;
+        }
+
+        let show_tags_changed = self.history_show_tags != show_tags;
+        self.history_show_tags = show_tags;
+        self.history_auto_fetch_tags_on_repo_activation = auto_fetch_tags_on_repo_activation;
+        if show_tags_changed {
+            self.notify_fingerprint = Self::notify_fingerprint_for(&self.state, show_tags);
+            self.history_cache = None;
+            self.history_cache_inflight = None;
+        }
+        cx.notify();
+    }
+
     pub(in super::super) fn set_last_window_size(&mut self, size: Size) {
         self.last_window_size = size;
     }
@@ -1461,9 +1562,13 @@ impl HistoryView {
                             Loadable::Ready(b) => Arc::clone(b),
                             _ => Arc::new(Vec::new()),
                         },
-                        tags: match &repo.tags {
-                            Loadable::Ready(t) => Arc::clone(t),
-                            _ => Arc::new(Vec::new()),
+                        tags: if self.history_show_tags {
+                            match &repo.tags {
+                                Loadable::Ready(t) => Arc::clone(t),
+                                _ => Arc::new(Vec::new()),
+                            }
+                        } else {
+                            Arc::new(Vec::new())
                         },
                         stashes: match &repo.stashes {
                             Loadable::Ready(s) => Arc::clone(s),
@@ -1725,19 +1830,26 @@ impl HistoryView {
                     if this.history_col_graph_auto && this.history_col_resize.is_none() {
                         let required = px(HISTORY_GRAPH_MARGIN_X_PX * 2.0
                             + HISTORY_GRAPH_COL_GAP_PX * (rebuild.max_lanes as f32));
-                        this.history_col_graph = history_column_drag_next_width(
-                            HistoryColResizeHandle::Graph,
-                            required.min(px(HISTORY_COL_GRAPH_MAX_PX)),
-                            this.history_content_width,
-                            this.history_visible_column_preferences(),
-                            HistoryColumnWidths {
-                                branch: this.history_col_branch,
-                                graph: this.history_col_graph,
-                                author: this.history_col_author,
-                                date: this.history_col_date,
-                                sha: this.history_col_sha,
-                            },
-                        );
+                        if this.history_show_graph {
+                            this.history_col_graph = history_column_drag_next_width(
+                                HistoryColResizeHandle::Graph,
+                                required.min(px(HISTORY_COL_GRAPH_MAX_PX)),
+                                this.history_content_width,
+                                this.history_show_graph,
+                                (
+                                    this.history_show_author,
+                                    this.history_show_date,
+                                    this.history_show_sha,
+                                ),
+                                HistoryColumnWidths {
+                                    branch: this.history_col_branch,
+                                    graph: this.history_col_graph,
+                                    author: this.history_col_author,
+                                    date: this.history_col_date,
+                                    sha: this.history_col_sha,
+                                },
+                            );
+                        }
                     }
 
                     this.history_cache_inflight = None;
@@ -1800,6 +1912,7 @@ mod tests {
 
     fn all_columns_visible_drag_layout() -> HistoryColumnDragLayout {
         HistoryColumnDragLayout {
+            show_graph: true,
             show_author: true,
             show_date: true,
             show_sha: true,
@@ -1986,7 +2099,7 @@ mod tests {
         let preferred = (true, true, true);
 
         assert_eq!(
-            history_visible_columns_for_width(available, preferred, widths),
+            history_visible_columns_for_width(available, true, preferred, widths),
             (false, false, false)
         );
 
@@ -1994,6 +2107,7 @@ mod tests {
             HistoryColResizeHandle::Graph,
             px(90.0),
             available,
+            true,
             preferred,
             widths,
         );
@@ -2003,7 +2117,7 @@ mod tests {
 
     #[test]
     fn reset_widths_clamp_default_graph_in_narrow_windows() {
-        let widths = history_reset_widths_for_available_width(px(396.0), (true, true, true));
+        let widths = history_reset_widths_for_available_width(px(396.0), true, (true, true, true));
 
         assert_eq!(widths.branch, px(HISTORY_COL_BRANCH_PX));
         assert_eq!(widths.graph, px(46.0));
@@ -2011,7 +2125,7 @@ mod tests {
 
     #[test]
     fn reset_widths_clamp_branch_after_graph_reaches_minimum() {
-        let widths = history_reset_widths_for_available_width(px(360.0), (true, true, true));
+        let widths = history_reset_widths_for_available_width(px(360.0), true, (true, true, true));
 
         assert_eq!(widths.graph, px(HISTORY_COL_GRAPH_MIN_PX));
         assert_eq!(widths.branch, px(96.0));
diff --git a/crates/gitcomet-ui-gpui/src/view/panes/history/history_panel.rs b/crates/gitcomet-ui-gpui/src/view/panes/history/history_panel.rs
index 738d4910..6ab0908e 100644
--- a/crates/gitcomet-ui-gpui/src/view/panes/history/history_panel.rs
+++ b/crates/gitcomet-ui-gpui/src/view/panes/history/history_panel.rs
@@ -272,7 +272,7 @@ impl HistoryView {
     fn history_column_headers(&mut self, cx: &mut gpui::Context) -> gpui::Div {
         let theme = self.theme;
         let icon_muted = with_alpha(theme.colors.accent, if theme.is_dark { 0.72 } else { 0.82 });
-        let (show_author, show_date, show_sha) = self.history_visible_columns();
+        let (show_graph, show_author, show_date, show_sha) = self.history_visible_columns();
         let col_author = self.history_col_author;
         let col_date = self.history_col_date;
         let col_sha = self.history_col_sha;
@@ -296,73 +296,6 @@ impl HistoryView {
             .active_context_menu_invoker
             .as_ref()
             .is_some_and(|id| id.as_ref() == scope_invoker.as_ref());
-        let column_settings_invoker: SharedString = "history_columns_settings_btn".into();
-        let column_settings_anchor_bounds: Rc>>> =
-            Rc::new(RefCell::new(None));
-        let column_settings_anchor_bounds_for_prepaint = Rc::clone(&column_settings_anchor_bounds);
-        let column_settings_anchor_bounds_for_click = Rc::clone(&column_settings_anchor_bounds);
-        let column_settings_active =
-            self.active_context_menu_invoker.as_ref() == Some(&column_settings_invoker);
-        let open_column_settings = {
-            let column_settings_invoker = column_settings_invoker.clone();
-            cx.listener(move |this, e: &ClickEvent, window, cx| {
-                this.activate_context_menu_invoker(column_settings_invoker.clone(), cx);
-                if let Some(bounds) = *column_settings_anchor_bounds_for_click.borrow() {
-                    this.open_popover_for_bounds(
-                        PopoverKind::HistoryColumnSettings,
-                        bounds,
-                        window,
-                        cx,
-                    );
-                } else {
-                    this.open_popover_at(
-                        PopoverKind::HistoryColumnSettings,
-                        e.position(),
-                        window,
-                        cx,
-                    );
-                }
-            })
-        };
-        let column_settings_btn_inner = div()
-            .id("history_columns_settings_btn")
-            .flex()
-            .items_center()
-            .justify_center()
-            .w(px(18.0))
-            .h(px(18.0))
-            .rounded(px(theme.radii.row))
-            .when(column_settings_active, |d| d.bg(theme.colors.active))
-            .hover(move |s| {
-                if column_settings_active {
-                    s.bg(theme.colors.active)
-                } else {
-                    s.bg(with_alpha(theme.colors.hover, 0.55))
-                }
-            })
-            .active(move |s| s.bg(theme.colors.active))
-            .cursor(CursorStyle::PointingHand)
-            .child(svg_icon("icons/cog.svg", icon_muted, px(12.0)))
-            .on_click(open_column_settings)
-            .on_hover(cx.listener(move |this, hovering: &bool, _w, cx| {
-                let text: SharedString = "History columns".into();
-                let mut changed = false;
-                if *hovering {
-                    changed |= this.set_tooltip_text_if_changed(Some(text.clone()), cx);
-                } else {
-                    changed |= this.clear_tooltip_if_matches(&text, cx);
-                }
-                if changed {
-                    cx.notify();
-                }
-            }));
-        let column_settings_btn = div()
-            .on_children_prepainted(move |children_bounds, _w, _cx| {
-                if let Some(bounds) = children_bounds.first() {
-                    *column_settings_anchor_bounds_for_prepaint.borrow_mut() = Some(*bounds);
-                }
-            })
-            .child(column_settings_btn_inner);
 
         let resize_handle = |id: &'static str, handle: HistoryColResizeHandle| {
             div()
@@ -390,6 +323,7 @@ impl HistoryView {
                         }
                         let available_width = this.history_content_width;
                         let drag_layout = super::HistoryColumnDragLayout {
+                            show_graph: this.history_show_graph,
                             show_author: this.history_show_author,
                             show_date: this.history_show_date,
                             show_sha: this.history_show_sha,
@@ -560,23 +494,23 @@ impl HistoryView {
                             ),
                     ),
             )
-            .child(
-                div()
-                    .w(self.history_col_graph)
-                    .flex()
-                    .justify_center()
-                    .px(cell_pad)
-                    .whitespace_nowrap()
-                    .overflow_hidden()
-                    .child("GRAPH"),
-            )
+            .when(show_graph, |header| {
+                header.child(
+                    div()
+                        .w(self.history_col_graph)
+                        .flex()
+                        .justify_center()
+                        .px(cell_pad)
+                        .whitespace_nowrap()
+                        .overflow_hidden()
+                        .child("GRAPH"),
+                )
+            })
             .child(
                 div()
                     .flex_1()
                     .min_w(px(0.0))
                     .flex()
-                    .items_center()
-                    .justify_between()
                     .px(cell_pad)
                     .whitespace_nowrap()
                     .overflow_hidden()
@@ -587,8 +521,7 @@ impl HistoryView {
                             .line_clamp(1)
                             .whitespace_nowrap()
                             .child("COMMIT MESSAGE"),
-                    )
-                    .child(column_settings_btn),
+                    ),
             )
             .when(show_author, |header| {
                 header.child(
@@ -634,16 +567,18 @@ impl HistoryView {
             );
         }
 
-        let mut header_with_handles = header
-            .child(
-                resize_handle("history_col_resize_branch", HistoryColResizeHandle::Branch)
-                    .left((self.history_col_branch - handle_half).max(px(0.0))),
-            )
-            .child(
+        let mut header_with_handles = header.child(
+            resize_handle("history_col_resize_branch", HistoryColResizeHandle::Branch)
+                .left((self.history_col_branch - handle_half).max(px(0.0))),
+        );
+
+        if show_graph {
+            header_with_handles = header_with_handles.child(
                 resize_handle("history_col_resize_graph", HistoryColResizeHandle::Graph).left(
                     (self.history_col_branch + self.history_col_graph - handle_half).max(px(0.0)),
                 ),
             );
+        }
 
         if show_author {
             let right_fixed = col_author
diff --git a/crates/gitcomet-ui-gpui/src/view/panes/main/core_impl.rs b/crates/gitcomet-ui-gpui/src/view/panes/main/core_impl.rs
index 7f90b8b6..80b881a6 100644
--- a/crates/gitcomet-ui-gpui/src/view/panes/main/core_impl.rs
+++ b/crates/gitcomet-ui-gpui/src/view/panes/main/core_impl.rs
@@ -790,9 +790,12 @@ impl MainPaneView {
         timezone: Timezone,
         show_timezone: bool,
         diff_scroll_sync: DiffScrollSync,
+        history_show_graph: bool,
         history_show_author: bool,
         history_show_date: bool,
         history_show_sha: bool,
+        history_show_tags: bool,
+        history_auto_fetch_tags_on_repo_activation: bool,
         view_mode: GitCometViewMode,
         focused_mergetool_labels: Option,
         focused_mergetool_exit_code: Option>,
@@ -920,9 +923,12 @@ impl MainPaneView {
                 date_time_format,
                 timezone,
                 show_timezone,
+                history_show_graph,
                 history_show_author,
                 history_show_date,
                 history_show_sha,
+                history_show_tags,
+                history_auto_fetch_tags_on_repo_activation,
                 root_view.clone(),
                 tooltip_host.clone(),
                 last_window_size,
@@ -2466,12 +2472,50 @@ impl MainPaneView {
     pub(in crate::view) fn history_visible_column_preferences(
         &self,
         cx: &gpui::App,
-    ) -> (bool, bool, bool) {
+    ) -> (bool, bool, bool, bool) {
         self.history_view
             .read(cx)
             .history_visible_column_preferences()
     }
 
+    pub(in crate::view) fn history_tag_preferences(&self, cx: &gpui::App) -> (bool, bool) {
+        self.history_view.read(cx).history_tag_preferences()
+    }
+
+    pub(in crate::view) fn set_history_column_preferences(
+        &mut self,
+        show_graph: bool,
+        show_author: bool,
+        show_date: bool,
+        show_sha: bool,
+        cx: &mut gpui::Context,
+    ) {
+        self.history_view.update(cx, |view, cx| {
+            view.set_history_column_preferences(show_graph, show_author, show_date, show_sha, cx);
+        });
+        cx.notify();
+    }
+
+    pub(in crate::view) fn set_history_tag_preferences(
+        &mut self,
+        show_tags: bool,
+        auto_fetch_tags_on_repo_activation: bool,
+        cx: &mut gpui::Context,
+    ) {
+        self.history_view.update(cx, |view, cx| {
+            view.set_history_tag_preferences(show_tags, auto_fetch_tags_on_repo_activation, cx);
+        });
+        cx.notify();
+    }
+
+    pub(in crate::view) fn reset_history_column_widths(&mut self, cx: &mut gpui::Context) {
+        self.history_view.update(cx, |view, cx| {
+            view.reset_history_column_widths();
+            cx.notify();
+        });
+        cx.notify();
+    }
+
     pub(in crate::view) fn open_popover_at(
         &mut self,
         kind: PopoverKind,
diff --git a/crates/gitcomet-ui-gpui/src/view/rows/history.rs b/crates/gitcomet-ui-gpui/src/view/rows/history.rs
index c9603a45..7524baee 100644
--- a/crates/gitcomet-ui-gpui/src/view/rows/history.rs
+++ b/crates/gitcomet-ui-gpui/src/view/rows/history.rs
@@ -1556,7 +1556,7 @@ impl HistoryView {
         let col_author = this.history_col_author;
         let col_date = this.history_col_date;
         let col_sha = this.history_col_sha;
-        let (show_author, show_date, show_sha) = this.history_visible_columns();
+        let (show_graph, show_author, show_date, show_sha) = this.history_visible_columns();
 
         let page = match &repo.log {
             Loadable::Ready(page) => Some(page),
@@ -1586,6 +1586,7 @@ impl HistoryView {
                         col_author,
                         col_date,
                         col_sha,
+                        show_graph,
                         show_author,
                         show_date,
                         show_sha,
@@ -1629,6 +1630,7 @@ impl HistoryView {
                     col_author,
                     col_date,
                     col_sha,
+                    show_graph,
                     show_author,
                     show_date,
                     show_sha,
@@ -1706,6 +1708,7 @@ fn history_table_row(
     col_author: Pixels,
     col_date: Pixels,
     col_sha: Pixels,
+    show_graph: bool,
     show_author: bool,
     show_date: bool,
     show_sha: bool,
@@ -1745,6 +1748,7 @@ fn history_table_row(
         col_author,
         col_date,
         col_sha,
+        show_graph,
         show_author,
         show_date,
         show_sha,
@@ -1846,6 +1850,7 @@ fn working_tree_summary_history_row(
     col_author: Pixels,
     col_date: Pixels,
     col_sha: Pixels,
+    show_graph: bool,
     show_author: bool,
     show_date: bool,
     show_sha: bool,
@@ -1954,15 +1959,17 @@ fn working_tree_summary_history_row(
                 .whitespace_nowrap()
                 .child(div()),
         )
-        .child(
-            div()
-                .w(col_graph)
-                .h_full()
-                .flex()
-                .justify_center()
-                .overflow_hidden()
-                .child(circle),
-        )
+        .when(show_graph, |row| {
+            row.child(
+                div()
+                    .w(col_graph)
+                    .h_full()
+                    .flex()
+                    .justify_center()
+                    .overflow_hidden()
+                    .child(circle),
+            )
+        })
         .child({
             let mut summary = div()
                 .flex_1()
diff --git a/crates/gitcomet-ui-gpui/src/view/rows/history_canvas.rs b/crates/gitcomet-ui-gpui/src/view/rows/history_canvas.rs
index 8209f14c..70f3722c 100644
--- a/crates/gitcomet-ui-gpui/src/view/rows/history_canvas.rs
+++ b/crates/gitcomet-ui-gpui/src/view/rows/history_canvas.rs
@@ -170,6 +170,7 @@ pub(super) fn history_commit_row_canvas(
     col_author: Pixels,
     col_date: Pixels,
     col_sha: Pixels,
+    show_graph: bool,
     show_author: bool,
     show_date: bool,
     show_sha: bool,
@@ -235,11 +236,14 @@ pub(super) fn history_commit_row_canvas(
                 size(col_branch.max(px(0.0)), bounds.size.height),
             );
             x += col_branch;
-            let graph_bounds = Bounds::new(
-                point(x, bounds.top()),
-                size(col_graph.max(px(0.0)), bounds.size.height),
-            );
-            x += col_graph;
+            let graph_w = if show_graph {
+                col_graph.max(px(0.0))
+            } else {
+                px(0.0)
+            };
+            let graph_bounds =
+                Bounds::new(point(x, bounds.top()), size(graph_w, bounds.size.height));
+            x += graph_w;
 
             let mut right_x = inner.right();
             let sha_bounds = if show_sha {
@@ -285,23 +289,25 @@ pub(super) fn history_commit_row_canvas(
                 size((summary_right - x).max(px(0.0)), bounds.size.height),
             );
 
-            window.with_content_mask(
-                Some(ContentMask {
-                    bounds: graph_bounds,
-                }),
-                |window| {
-                    window.paint_layer(graph_bounds, |window| {
-                        super::history_graph_paint::paint_history_graph(
-                            theme,
-                            graph_row,
-                            connect_from_top_col,
-                            is_stash_node,
-                            graph_bounds,
-                            window,
-                        );
-                    });
-                },
-            );
+            if show_graph {
+                window.with_content_mask(
+                    Some(ContentMask {
+                        bounds: graph_bounds,
+                    }),
+                    |window| {
+                        window.paint_layer(graph_bounds, |window| {
+                            super::history_graph_paint::paint_history_graph(
+                                theme,
+                                graph_row,
+                                connect_from_top_col,
+                                is_stash_node,
+                                graph_bounds,
+                                window,
+                            );
+                        });
+                    },
+                );
+            }
 
             let chip_height = px(HISTORY_TAG_CHIP_HEIGHT_PX);
             let chip_pad_x = px(HISTORY_TAG_CHIP_PADDING_X_PX);
diff --git a/crates/gitcomet-ui-gpui/src/view/settings_window.rs b/crates/gitcomet-ui-gpui/src/view/settings_window.rs
index 6bfc1865..ffef872f 100644
--- a/crates/gitcomet-ui-gpui/src/view/settings_window.rs
+++ b/crates/gitcomet-ui-gpui/src/view/settings_window.rs
@@ -2,6 +2,7 @@ use super::*;
 use gitcomet_core::process::{
     GitExecutablePreference, GitRuntimeState, install_git_executable_path, refresh_git_runtime,
 };
+use gitcomet_state::model::GitLogTagFetchMode;
 use gpui::{Stateful, TitlebarOptions, WindowBounds, WindowDecorations, WindowOptions};
 use std::sync::Arc;
 
@@ -68,6 +69,8 @@ enum SettingsSection {
     Timezone,
     ChangeTracking,
     Diff,
+    GitLogColumns,
+    GitLogTagFetch,
 }
 
 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -141,6 +144,12 @@ pub(crate) struct SettingsWindowView {
     show_timezone: bool,
     change_tracking_view: ChangeTrackingView,
     diff_scroll_sync: DiffScrollSync,
+    history_show_graph: bool,
+    history_show_author: bool,
+    history_show_date: bool,
+    history_show_sha: bool,
+    history_show_tags: bool,
+    history_tag_fetch_mode: GitLogTagFetchMode,
     current_view: SettingsView,
     open_source_licenses_scroll: UniformListScrollHandle,
     runtime_info: SettingsRuntimeInfo,
@@ -334,6 +343,40 @@ fn settings_theme_modes() -> Vec {
     modes
 }
 
+fn history_columns_settings_label(
+    show_graph: bool,
+    show_author: bool,
+    show_date: bool,
+    show_sha: bool,
+) -> SharedString {
+    let mut columns = Vec::new();
+    if show_graph {
+        columns.push("Graph");
+    }
+    if show_author {
+        columns.push("Author");
+    }
+    if show_date {
+        columns.push("Commit date");
+    }
+    if show_sha {
+        columns.push("SHA");
+    }
+
+    if columns.is_empty() {
+        "None".into()
+    } else {
+        columns.join(", ").into()
+    }
+}
+
+fn git_log_tag_fetch_mode_label(mode: GitLogTagFetchMode) -> &'static str {
+    match mode {
+        GitLogTagFetchMode::OnRepositoryActivation => "On repository activation",
+        GitLogTagFetchMode::Disabled => "Disabled",
+    }
+}
+
 impl SettingsWindowView {
     fn new(window: &mut Window, cx: &mut gpui::Context) -> Self {
         window.set_window_title(SETTINGS_WINDOW_TITLE);
@@ -367,6 +410,12 @@ impl SettingsWindowView {
             .as_deref()
             .and_then(DiffScrollSync::from_key)
             .unwrap_or_default();
+        let history_show_graph = ui_session.history_show_graph.unwrap_or(true);
+        let history_show_author = ui_session.history_show_author.unwrap_or(true);
+        let history_show_date = ui_session.history_show_date.unwrap_or(true);
+        let history_show_sha = ui_session.history_show_sha.unwrap_or(false);
+        let history_show_tags = ui_session.history_show_tags.unwrap_or(true);
+        let history_tag_fetch_mode = ui_session.history_tag_fetch_mode.unwrap_or_default();
         let theme = theme_mode.resolve_theme(window.appearance());
         let runtime_info = SettingsRuntimeInfo::detect();
         let git_executable_mode =
@@ -447,6 +496,12 @@ impl SettingsWindowView {
             show_timezone,
             change_tracking_view,
             diff_scroll_sync,
+            history_show_graph,
+            history_show_author,
+            history_show_date,
+            history_show_sha,
+            history_show_tags,
+            history_tag_fetch_mode,
             current_view: SettingsView::Root,
             open_source_licenses_scroll: UniformListScrollHandle::default(),
             runtime_info,
@@ -488,9 +543,12 @@ impl SettingsWindowView {
             diff_scroll_sync: Some(self.diff_scroll_sync.key().to_string()),
             change_tracking_height: None,
             untracked_height: None,
-            history_show_author: None,
-            history_show_date: None,
-            history_show_sha: None,
+            history_show_graph: Some(self.history_show_graph),
+            history_show_author: Some(self.history_show_author),
+            history_show_date: Some(self.history_show_date),
+            history_show_sha: Some(self.history_show_sha),
+            history_show_tags: Some(self.history_show_tags),
+            history_tag_fetch_mode: Some(self.history_tag_fetch_mode),
             git_executable_path: Some(self.selected_git_executable_path()),
         };
 
@@ -781,6 +839,69 @@ impl SettingsWindowView {
         cx.notify();
     }
 
+    fn set_history_column_preferences(
+        &mut self,
+        show_graph: bool,
+        show_author: bool,
+        show_date: bool,
+        show_sha: bool,
+        cx: &mut gpui::Context,
+    ) {
+        if self.history_show_graph == show_graph
+            && self.history_show_author == show_author
+            && self.history_show_date == show_date
+            && self.history_show_sha == show_sha
+        {
+            return;
+        }
+
+        self.history_show_graph = show_graph;
+        self.history_show_author = show_author;
+        self.history_show_date = show_date;
+        self.history_show_sha = show_sha;
+        self.persist_preferences(cx);
+        self.update_main_windows(cx, move |view, _window, cx| {
+            view.set_history_column_preferences(show_graph, show_author, show_date, show_sha, cx);
+        });
+        cx.notify();
+    }
+
+    fn set_history_show_tags(&mut self, enabled: bool, cx: &mut gpui::Context) {
+        if self.history_show_tags == enabled {
+            return;
+        }
+
+        self.history_show_tags = enabled;
+        if !enabled && self.expanded_section == Some(SettingsSection::GitLogTagFetch) {
+            self.expanded_section = None;
+        }
+        let tag_fetch_mode = self.history_tag_fetch_mode;
+        self.persist_preferences(cx);
+        self.update_main_windows(cx, move |view, _window, cx| {
+            view.set_history_tag_preferences(enabled, tag_fetch_mode, cx);
+        });
+        cx.notify();
+    }
+
+    fn set_history_tag_fetch_mode(
+        &mut self,
+        mode: GitLogTagFetchMode,
+        cx: &mut gpui::Context,
+    ) {
+        if self.history_tag_fetch_mode == mode {
+            return;
+        }
+
+        self.history_tag_fetch_mode = mode;
+        self.expanded_section = None;
+        let show_tags = self.history_show_tags;
+        self.persist_preferences(cx);
+        self.update_main_windows(cx, move |view, _window, cx| {
+            view.set_history_tag_preferences(show_tags, mode, cx);
+        });
+        cx.notify();
+    }
+
     fn option_row(
         &self,
         id: impl Into,
@@ -1726,6 +1847,48 @@ impl Render for SettingsWindowView {
                         this.toggle_section(SettingsSection::Diff, cx);
                     }));
 
+                let history_columns_row = self
+                    .summary_row(
+                        "settings_window_git_log_columns",
+                        "History columns",
+                        history_columns_settings_label(
+                            self.history_show_graph,
+                            self.history_show_author,
+                            self.history_show_date,
+                            self.history_show_sha,
+                        ),
+                        self.expanded_section == Some(SettingsSection::GitLogColumns),
+                        theme,
+                    )
+                    .on_click(cx.listener(|this, _e: &ClickEvent, _window, cx| {
+                        this.toggle_section(SettingsSection::GitLogColumns, cx);
+                    }));
+
+                let show_history_tags_row = self
+                    .toggle_row(
+                        "settings_window_git_log_show_tags",
+                        "Show tags in history view",
+                        self.history_show_tags,
+                        theme,
+                    )
+                    .on_click(cx.listener(|this, _e: &ClickEvent, _window, cx| {
+                        this.set_history_show_tags(!this.history_show_tags, cx);
+                    }));
+
+                let auto_fetch_tags_row = self
+                    .summary_row(
+                        "settings_window_git_log_tag_fetch_mode",
+                        "Automatically fetch tags",
+                        git_log_tag_fetch_mode_label(self.history_tag_fetch_mode).into(),
+                        self.expanded_section == Some(SettingsSection::GitLogTagFetch),
+                        theme,
+                    )
+                    .on_click(cx.listener(|this, _e: &ClickEvent, _window, cx| {
+                        if this.history_show_tags {
+                            this.toggle_section(SettingsSection::GitLogTagFetch, cx);
+                        }
+                    }));
+
                 let mut general_card = self
                     .card("settings_window_general", "General", theme)
                     .child(theme_row);
@@ -1992,6 +2155,163 @@ impl Render for SettingsWindowView {
                     ));
                 }
 
+                let mut git_log_card = self
+                    .card("settings_window_git_log_card", "Git log", theme)
+                    .child(history_columns_row);
+
+                if self.expanded_section == Some(SettingsSection::GitLogColumns) {
+                    git_log_card = git_log_card
+                        .child(
+                            self.toggle_row(
+                                "settings_window_git_log_column_graph",
+                                "Graph",
+                                self.history_show_graph,
+                                theme,
+                            )
+                            .on_click(cx.listener(
+                                |this, _e: &ClickEvent, _window, cx| {
+                                    this.set_history_column_preferences(
+                                        !this.history_show_graph,
+                                        this.history_show_author,
+                                        this.history_show_date,
+                                        this.history_show_sha,
+                                        cx,
+                                    );
+                                },
+                            )),
+                        )
+                        .child(
+                            self.toggle_row(
+                                "settings_window_git_log_column_author",
+                                "Author",
+                                self.history_show_author,
+                                theme,
+                            )
+                            .on_click(cx.listener(
+                                |this, _e: &ClickEvent, _window, cx| {
+                                    this.set_history_column_preferences(
+                                        this.history_show_graph,
+                                        !this.history_show_author,
+                                        this.history_show_date,
+                                        this.history_show_sha,
+                                        cx,
+                                    );
+                                },
+                            )),
+                        )
+                        .child(
+                            self.toggle_row(
+                                "settings_window_git_log_column_date",
+                                "Commit date",
+                                self.history_show_date,
+                                theme,
+                            )
+                            .on_click(cx.listener(
+                                |this, _e: &ClickEvent, _window, cx| {
+                                    this.set_history_column_preferences(
+                                        this.history_show_graph,
+                                        this.history_show_author,
+                                        !this.history_show_date,
+                                        this.history_show_sha,
+                                        cx,
+                                    );
+                                },
+                            )),
+                        )
+                        .child(
+                            self.toggle_row(
+                                "settings_window_git_log_column_sha",
+                                "SHA",
+                                self.history_show_sha,
+                                theme,
+                            )
+                            .on_click(cx.listener(
+                                |this, _e: &ClickEvent, _window, cx| {
+                                    this.set_history_column_preferences(
+                                        this.history_show_graph,
+                                        this.history_show_author,
+                                        this.history_show_date,
+                                        !this.history_show_sha,
+                                        cx,
+                                    );
+                                },
+                            )),
+                        )
+                        .child(
+                            div()
+                                .px_2()
+                                .pb_1()
+                                .text_xs()
+                                .text_color(theme.colors.text_muted)
+                                .child("Columns may auto-hide in narrow windows."),
+                        )
+                        .child(
+                            self.link_row(
+                                "settings_window_git_log_reset_widths",
+                                "Reset column widths",
+                                "Reset".into(),
+                                theme,
+                            )
+                            .on_click(cx.listener(
+                                |this, _e: &ClickEvent, _window, cx| {
+                                    this.update_main_windows(cx, |view, _window, cx| {
+                                        view.reset_history_column_widths(cx);
+                                    });
+                                    cx.notify();
+                                },
+                            )),
+                        );
+                }
+
+                git_log_card = git_log_card.child(show_history_tags_row);
+                if self.history_show_tags {
+                    git_log_card = git_log_card.child(auto_fetch_tags_row);
+
+                    if self.expanded_section == Some(SettingsSection::GitLogTagFetch) {
+                        git_log_card = git_log_card
+                            .child(
+                                self.option_row(
+                                    "settings_window_git_log_tag_fetch_mode_activation",
+                                    "On repository activation",
+                                    Some(
+                                        "Fetch local tags when a repository becomes active.".into(),
+                                    ),
+                                    self.history_tag_fetch_mode
+                                        == GitLogTagFetchMode::OnRepositoryActivation,
+                                    theme,
+                                )
+                                .on_click(cx.listener(
+                                    |this, _e: &ClickEvent, _window, cx| {
+                                        this.set_history_tag_fetch_mode(
+                                            GitLogTagFetchMode::OnRepositoryActivation,
+                                            cx,
+                                        );
+                                    },
+                                )),
+                            )
+                            .child(
+                                self.option_row(
+                                    "settings_window_git_log_tag_fetch_mode_disabled",
+                                    "Disabled",
+                                    Some(
+                                        "Skip automatic tag fetching on repository activation."
+                                            .into(),
+                                    ),
+                                    self.history_tag_fetch_mode == GitLogTagFetchMode::Disabled,
+                                    theme,
+                                )
+                                .on_click(cx.listener(
+                                    |this, _e: &ClickEvent, _window, cx| {
+                                        this.set_history_tag_fetch_mode(
+                                            GitLogTagFetchMode::Disabled,
+                                            cx,
+                                        );
+                                    },
+                                )),
+                            );
+                    }
+                }
+
                 let min_git_version = format!("{MIN_GIT_MAJOR}.{MIN_GIT_MINOR}");
                 let (git_icon_path, git_icon_color, git_status_text): (
                     &'static str,
@@ -2240,6 +2560,7 @@ impl Render for SettingsWindowView {
                     .child(general_card)
                     .child(change_tracking_card)
                     .child(diff_card)
+                    .child(git_log_card)
                     .child(git_executable_card)
                     .child(environment_card)
                     .child(links_card)
diff --git a/crates/gitcomet-ui-gpui/src/view/tooltip.rs b/crates/gitcomet-ui-gpui/src/view/tooltip.rs
index fce3b71e..cf7be6e4 100644
--- a/crates/gitcomet-ui-gpui/src/view/tooltip.rs
+++ b/crates/gitcomet-ui-gpui/src/view/tooltip.rs
@@ -27,10 +27,19 @@ impl GitCometView {
                         let sidebar_width: f32 = this.sidebar_width.round().into();
                         let details_width: f32 = this.details_width.round().into();
 
-                        let (history_show_author, history_show_date, history_show_sha) = this
+                        let (
+                            history_show_graph,
+                            history_show_author,
+                            history_show_date,
+                            history_show_sha,
+                        ) = this
                             .main_pane
                             .read(cx)
                             .history_visible_column_preferences(cx);
+                        let (history_show_tags, history_auto_fetch_tags_on_repo_activation) = this
+                            .main_pane
+                            .read(cx)
+                            .history_tag_preferences(cx);
                         let (change_tracking_height, untracked_height) =
                             this.details_pane.read(cx).saved_status_section_heights();
                         let repo_sidebar_collapsed_items =
@@ -56,9 +65,17 @@ impl GitCometView {
                             diff_scroll_sync: Some(this.diff_scroll_sync.key().to_string()),
                             change_tracking_height,
                             untracked_height,
+                            history_show_graph: Some(history_show_graph),
                             history_show_author: Some(history_show_author),
                             history_show_date: Some(history_show_date),
                             history_show_sha: Some(history_show_sha),
+                            history_show_tags: Some(history_show_tags),
+                            history_tag_fetch_mode: Some(if history_auto_fetch_tags_on_repo_activation
+                            {
+                                gitcomet_state::model::GitLogTagFetchMode::OnRepositoryActivation
+                            } else {
+                                gitcomet_state::model::GitLogTagFetchMode::Disabled
+                            }),
                             git_executable_path: None,
                         };
 
diff --git a/scripts/profile-gitcomet-process-tree.sh b/scripts/profile-gitcomet-process-tree.sh
new file mode 100755
index 00000000..bb834b23
--- /dev/null
+++ b/scripts/profile-gitcomet-process-tree.sh
@@ -0,0 +1,546 @@
+#!/usr/bin/env bash
+set -euo pipefail
+shopt -s nullglob
+
+usage() {
+  cat <<'EOF'
+Usage: scripts/profile-gitcomet-process-tree.sh [options] REPO_PATH [-- extra_gitcomet_args...]
+
+Launch GitComet under a process-tree profiling wrapper that captures:
+  - callgrind outputs for the parent process and traced child processes
+  - strace -ff per-process syscall logs
+  - Git Trace2 event/perf logs for git subprocesses
+
+Options:
+  --binary PATH             GitComet binary to launch.
+                            Default: ./target/release-with-debug/gitcomet
+  --out-dir PATH            Output directory for captured artifacts.
+                            Default: tmp/gitcomet-profiles/
+  --timeout SEC             Terminate the profiled process group after SEC seconds.
+  --manual-instrumentation  Start callgrind with instrumentation disabled.
+                            Turn it on later with callgrind_control.
+  --dry-run                 Print the composed command and output paths without running.
+  -h, --help                Show this help.
+
+Examples:
+  scripts/profile-gitcomet-process-tree.sh /home/sampo/chromium/src
+  scripts/profile-gitcomet-process-tree.sh --timeout 60 /home/sampo/chromium/src
+  scripts/profile-gitcomet-process-tree.sh \
+    --manual-instrumentation \
+    /home/sampo/chromium/src -- --version
+EOF
+}
+
+die() {
+  echo "$*" >&2
+  exit 1
+}
+
+quote_args() {
+  local quoted=""
+  printf -v quoted '%q ' "$@"
+  printf '%s\n' "${quoted% }"
+}
+
+require_tool() {
+  local tool_name="$1"
+  if ! command -v "$tool_name" >/dev/null 2>&1; then
+    die "Required tool not found: ${tool_name}"
+  fi
+}
+
+verify_strace_usable() {
+  local probe_target="/usr/bin/true"
+  if [[ ! -x "$probe_target" ]]; then
+    probe_target="/bin/true"
+  fi
+  if [[ ! -x "$probe_target" ]]; then
+    die "Could not find a probe target for strace usability checks."
+  fi
+
+  local probe_log
+  probe_log="$(mktemp)"
+  local probe_stderr=""
+  if probe_stderr="$(strace -o "$probe_log" "$probe_target" 2>&1 >/dev/null)"; then
+    rm -f "$probe_log"
+    return
+  fi
+  rm -f "$probe_log"
+
+  probe_stderr="$(trim_whitespace "${probe_stderr//$'\n'/ }")"
+  if [[ -z "$probe_stderr" ]]; then
+    probe_stderr="ptrace permission denied or strace is blocked in this environment"
+  fi
+  die "strace cannot trace processes in this environment: ${probe_stderr}"
+}
+
+trim_whitespace() {
+  local value="$1"
+  value="${value#"${value%%[![:space:]]*}"}"
+  value="${value%"${value##*[![:space:]]}"}"
+  printf '%s\n' "$value"
+}
+
+resolve_existing_dir() {
+  local raw="$1"
+  local candidate="$raw"
+  if [[ "$candidate" != /* ]]; then
+    candidate="${orig_cwd}/${candidate}"
+  fi
+  [[ -d "$candidate" ]] || die "Repository path is not a directory: ${raw}"
+  (
+    cd "$candidate"
+    pwd -P
+  )
+}
+
+resolve_path() {
+  local raw="$1"
+  if [[ "$raw" = /* ]]; then
+    printf '%s\n' "$raw"
+  else
+    printf '%s/%s\n' "$orig_cwd" "$raw"
+  fi
+}
+
+host_name() {
+  if command -v hostname >/dev/null 2>&1; then
+    hostname
+  else
+    uname -n
+  fi
+}
+
+append_summary_header() {
+  local title="$1"
+  {
+    printf '\n== %s ==\n' "$title"
+  } >>"$summary_path"
+}
+
+append_execve_summary() {
+  local trace_files=( "$strace_dir"/trace.* )
+  append_summary_header "Strace Execve Summary"
+  if ((${#trace_files[@]} == 0)); then
+    printf 'No strace trace files were captured.\n' >>"$summary_path"
+    return
+  fi
+
+  {
+    printf 'Trace files: %d\n' "${#trace_files[@]}"
+  } >>"$summary_path"
+
+  local emitted=0
+  local trace_file=""
+  local pid=""
+  local line=""
+  local display=""
+  while IFS= read -r trace_file; do
+    pid="${trace_file##*.}"
+    line="$(awk '/execve\(/ { print; exit }' "$trace_file" || true)"
+    if [[ -z "$line" ]]; then
+      continue
+    fi
+    display="$(printf '%s\n' "$line" | sed -E 's/^[^e]*execve\("([^"]+)", \[(.*)\], .*$/\1 [\2]/')"
+    if [[ "$display" == "$line" ]]; then
+      display="$(trim_whitespace "$line")"
+    fi
+    {
+      printf 'PID %s: %s\n' "$pid" "$display"
+    } >>"$summary_path"
+    emitted=$((emitted + 1))
+    if ((emitted >= 80)); then
+      {
+        printf '...truncated after %d processes; inspect %s for full per-PID traces.\n' \
+          "$emitted" "$strace_dir"
+      } >>"$summary_path"
+      return
+    fi
+  done < <(printf '%s\n' "${trace_files[@]}" | sort -t. -k2,2n)
+
+  if ((emitted == 0)); then
+    printf 'No execve records found in strace output.\n' >>"$summary_path"
+  fi
+}
+
+append_trace2_summary() {
+  append_summary_header "Git Trace2 Command Totals"
+
+  local trace_files=( "$trace2_perf_dir"/* )
+  if ((${#trace_files[@]} == 0)); then
+    printf 'No Trace2 perf logs were captured.\n' >>"$summary_path"
+    return
+  fi
+
+  local trace2_rows
+  trace2_rows="$(mktemp)"
+  local trace_file=""
+  local row=""
+  local cmd=""
+  local duration=""
+
+  for trace_file in "${trace_files[@]}"; do
+    [[ -f "$trace_file" ]] || continue
+    row="$(awk -F'\\|' '
+      /\| start[[:space:]]+\|/ && cmd == "" { cmd = $NF }
+      /\| atexit[[:space:]]+\|/ { dur = $6 }
+      /\| exit[[:space:]]+\|/ && dur == "" { dur = $6 }
+      END {
+        gsub(/^[[:space:]]+|[[:space:]]+$/, "", cmd)
+        gsub(/^[[:space:]]+|[[:space:]]+$/, "", dur)
+        if (cmd != "") {
+          printf "%s\t%s\t%s\n", FILENAME, dur, cmd
+        }
+      }
+    ' "$trace_file")"
+    if [[ -n "$row" ]]; then
+      printf '%s\n' "$row" >>"$trace2_rows"
+    fi
+  done
+
+  if [[ ! -s "$trace2_rows" ]]; then
+    printf 'Trace2 perf logs were present, but no command start rows were parsed.\n' >>"$summary_path"
+    rm -f "$trace2_rows"
+    return
+  fi
+
+  {
+    printf 'Raw Trace2 perf directory: %s\n' "$trace2_perf_dir"
+    awk -F'\t' '
+      {
+        duration = $2 + 0
+        command = $3
+        count[command] += 1
+        total[command] += duration
+        if (duration > max[command]) {
+          max[command] = duration
+        }
+      }
+      END {
+        for (command in count) {
+          printf "%.6f\t%d\t%.6f\t%s\n", total[command], count[command], max[command], command
+        }
+      }
+    ' "$trace2_rows" | sort -nr | awk -F'\t' '
+      BEGIN { emitted = 0 }
+      {
+        printf "count=%d total=%.3fs max=%.3fs %s\n", $2, $1, $3, $4
+        emitted += 1
+        if (emitted >= 20) {
+          exit
+        }
+      }
+    '
+  } >>"$summary_path"
+
+  rm -f "$trace2_rows"
+}
+
+append_callgrind_summary() {
+  append_summary_header "Callgrind Hotspots"
+
+  local callgrind_files=( "$callgrind_dir"/callgrind.out.* )
+  if ((${#callgrind_files[@]} == 0)); then
+    printf 'No callgrind outputs were captured.\n' >>"$summary_path"
+    return
+  fi
+
+  local callgrind_file=""
+  local pid=""
+  local annotation_file=""
+  local hotspot=""
+  for callgrind_file in "${callgrind_files[@]}"; do
+    [[ -f "$callgrind_file" ]] || continue
+    pid="${callgrind_file##*.}"
+    annotation_file="${callgrind_annotation_dir}/$(basename "$callgrind_file").annotate.txt"
+    if callgrind_annotate --auto=yes --inclusive=yes --threshold=95 "$callgrind_file" >"$annotation_file" 2>&1; then
+      hotspot="$(awk '
+        /^Ir[[:space:]]+file:function$/ { in_hotspots = 1; next }
+        in_hotspots && $1 ~ /^[0-9,]+$/ { print; exit }
+      ' "$annotation_file")"
+      if [[ -z "$hotspot" ]]; then
+        hotspot="(no hotspot line parsed)"
+      fi
+    else
+      hotspot="(callgrind_annotate failed; inspect ${annotation_file})"
+    fi
+    {
+      printf 'PID %s: %s\n' "$pid" "$hotspot"
+      printf '  annotation: %s\n' "$annotation_file"
+    } >>"$summary_path"
+  done
+}
+
+write_metadata() {
+  {
+    printf 'created_utc: %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
+    printf 'hostname: %s\n' "$(host_name)"
+    printf 'repo_root: %s\n' "$repo_root"
+    printf 'repo_path: %s\n' "$repo_path"
+    printf 'binary: %s\n' "$binary_path"
+    printf 'out_dir: %s\n' "$out_dir"
+    printf 'stdout_log: %s\n' "$stdout_log"
+    printf 'stderr_log: %s\n' "$stderr_log"
+    printf 'strace_dir: %s\n' "$strace_dir"
+    printf 'callgrind_dir: %s\n' "$callgrind_dir"
+    printf 'trace2_event_dir: %s\n' "$trace2_event_dir"
+    printf 'trace2_perf_dir: %s\n' "$trace2_perf_dir"
+    printf 'manual_instrumentation: %s\n' "$manual_instrumentation"
+    printf 'timeout_seconds: %s\n' "${timeout_secs:-none}"
+    printf 'command: %s\n' "$(quote_args "${profile_command[@]}")"
+  } >"$metadata_path"
+}
+
+finalize_metadata() {
+  {
+    printf 'root_pid: %s\n' "$root_pid"
+    printf 'completed_utc: %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
+    printf 'raw_exit_status: %s\n' "$run_status"
+    printf 'timed_out: %s\n' "$timed_out"
+    printf 'script_exit_status: %s\n' "$script_exit_status"
+  } >>"$metadata_path"
+}
+
+write_summary() {
+  {
+    printf 'GitComet process-tree profile bundle\n'
+    printf 'Repository: %s\n' "$repo_path"
+    printf 'Binary: %s\n' "$binary_path"
+    printf 'Output: %s\n' "$out_dir"
+    printf 'Root PID: %s\n' "$root_pid"
+    printf 'Raw exit status: %s\n' "$run_status"
+    printf 'Timed out: %s\n' "$timed_out"
+    if ((manual_instrumentation == 1)); then
+      printf 'Manual instrumentation: enabled\n'
+      printf 'Callgrind control hint: callgrind_control -i on\n'
+      printf 'Callgrind control hint: callgrind_control -i off\n'
+    fi
+  } >"$summary_path"
+
+  append_execve_summary
+  append_trace2_summary
+  append_callgrind_summary
+}
+
+cleanup_on_exit() {
+  local trap_status=$?
+  if [[ -n "${watchdog_pid:-}" ]]; then
+    kill "$watchdog_pid" 2>/dev/null || true
+    wait "$watchdog_pid" 2>/dev/null || true
+  fi
+  if [[ -n "${runner_pid:-}" ]] && kill -0 "$runner_pid" 2>/dev/null; then
+    kill -TERM -- "-${runner_pgid:-$runner_pid}" 2>/dev/null || true
+    sleep 1
+    kill -KILL -- "-${runner_pgid:-$runner_pid}" 2>/dev/null || true
+  fi
+  return "$trap_status"
+}
+
+orig_cwd="$PWD"
+repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)"
+
+binary_raw=""
+out_dir_raw=""
+timeout_secs=""
+manual_instrumentation=0
+dry_run=0
+repo_arg=""
+extra_gitcomet_args=()
+
+while [[ $# -gt 0 ]]; do
+  case "$1" in
+    --binary)
+      [[ $# -ge 2 ]] || die "Missing value for --binary"
+      binary_raw="$2"
+      shift 2
+      ;;
+    --out-dir)
+      [[ $# -ge 2 ]] || die "Missing value for --out-dir"
+      out_dir_raw="$2"
+      shift 2
+      ;;
+    --timeout)
+      [[ $# -ge 2 ]] || die "Missing value for --timeout"
+      timeout_secs="$2"
+      shift 2
+      ;;
+    --manual-instrumentation)
+      manual_instrumentation=1
+      shift
+      ;;
+    --dry-run)
+      dry_run=1
+      shift
+      ;;
+    --)
+      shift
+      extra_gitcomet_args+=("$@")
+      break
+      ;;
+    -h|--help)
+      usage
+      exit 0
+      ;;
+    -*)
+      die "Unknown option: $1"
+      ;;
+    *)
+      if [[ -n "$repo_arg" ]]; then
+        die "Unexpected extra positional argument: $1"
+      fi
+      repo_arg="$1"
+      shift
+      ;;
+  esac
+done
+
+[[ -n "$repo_arg" ]] || {
+  usage >&2
+  exit 2
+}
+
+if [[ -n "$timeout_secs" ]] && [[ ! "$timeout_secs" =~ ^[0-9]+$ ]]; then
+  die "--timeout expects a whole number of seconds"
+fi
+
+repo_path="$(resolve_existing_dir "$repo_arg")"
+binary_path="${repo_root}/target/release-with-debug/gitcomet"
+if [[ -n "$binary_raw" ]]; then
+  binary_path="$(resolve_path "$binary_raw")"
+fi
+
+timestamp="$(date -u +%Y%m%d-%H%M%SZ)"
+out_dir="${repo_root}/tmp/gitcomet-profiles/${timestamp}"
+if [[ -n "$out_dir_raw" ]]; then
+  out_dir="$(resolve_path "$out_dir_raw")"
+fi
+
+stdout_log="${out_dir}/stdout.log"
+stderr_log="${out_dir}/stderr.log"
+summary_path="${out_dir}/summary.txt"
+metadata_path="${out_dir}/metadata.txt"
+timed_out_flag="${out_dir}/timed_out.flag"
+strace_dir="${out_dir}/strace"
+strace_prefix="${strace_dir}/trace"
+callgrind_dir="${out_dir}/callgrind"
+callgrind_annotation_dir="${callgrind_dir}/annotations"
+trace2_event_dir="${out_dir}/trace2-event"
+trace2_perf_dir="${out_dir}/trace2-perf"
+
+profile_command=(
+  env
+  "GIT_TRACE2_EVENT=${trace2_event_dir}"
+  "GIT_TRACE2_PERF=${trace2_perf_dir}"
+  strace
+  -ff
+  -ttT
+  -o
+  "$strace_prefix"
+  valgrind
+  --tool=callgrind
+  --trace-children=yes
+  --callgrind-out-file="${callgrind_dir}/callgrind.out.%p"
+  --dump-instr=yes
+  --collect-jumps=yes
+  --sigill-diagnostics=no
+  --error-limit=no
+)
+
+if ((manual_instrumentation == 1)); then
+  profile_command+=(--instr-atstart=no)
+fi
+
+profile_command+=("$binary_path" "$repo_path")
+if ((${#extra_gitcomet_args[@]} > 0)); then
+  profile_command+=("${extra_gitcomet_args[@]}")
+fi
+
+if ((dry_run == 1)); then
+  {
+    printf 'Output directory: %s\n' "$out_dir"
+    printf 'Profile command: %s\n' "$(quote_args "${profile_command[@]}")"
+  }
+  exit 0
+fi
+
+[[ ! -e "$out_dir" ]] || die "Output directory already exists: ${out_dir}"
+
+require_tool git
+require_tool setsid
+require_tool strace
+require_tool valgrind
+require_tool callgrind_annotate
+if ((manual_instrumentation == 1)); then
+  require_tool callgrind_control
+fi
+
+if [[ ! -x "$binary_path" ]]; then
+  die "GitComet binary not found or not executable at ${binary_path}. Build it with: bash scripts/build_release_debug.sh"
+fi
+verify_strace_usable
+
+mkdir -p \
+  "$out_dir" \
+  "$strace_dir" \
+  "$callgrind_dir" \
+  "$callgrind_annotation_dir" \
+  "$trace2_event_dir" \
+  "$trace2_perf_dir"
+
+runner_pid=""
+runner_pgid=""
+watchdog_pid=""
+root_pid=""
+run_status=0
+timed_out=0
+script_exit_status=0
+
+trap cleanup_on_exit EXIT
+
+write_metadata
+
+setsid "${profile_command[@]}" >"$stdout_log" 2>"$stderr_log" &
+runner_pid="$!"
+runner_pgid="$runner_pid"
+root_pid="$runner_pid"
+
+if [[ -n "$timeout_secs" ]]; then
+  (
+    sleep "$timeout_secs"
+    if kill -0 "$runner_pid" 2>/dev/null; then
+      printf 'Timed out after %s seconds\n' "$timeout_secs" >"$timed_out_flag"
+      kill -TERM -- "-$runner_pgid" 2>/dev/null || true
+      sleep 5
+      kill -KILL -- "-$runner_pgid" 2>/dev/null || true
+    fi
+  ) &
+  watchdog_pid="$!"
+fi
+
+set +e
+wait "$runner_pid"
+run_status=$?
+set -e
+
+if [[ -n "$watchdog_pid" ]]; then
+  kill "$watchdog_pid" 2>/dev/null || true
+  wait "$watchdog_pid" 2>/dev/null || true
+  watchdog_pid=""
+fi
+
+runner_pid=""
+runner_pgid=""
+
+if [[ -f "$timed_out_flag" ]]; then
+  timed_out=1
+fi
+
+script_exit_status="$run_status"
+if ((timed_out == 1)); then
+  script_exit_status=124
+fi
+
+finalize_metadata
+write_summary
+
+exit "$script_exit_status"

From 8d24a7ecbcb3c53879d3e5e20a7c9e13e1c07d35 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Sampo=20Kivist=C3=B6?= 
Date: Mon, 13 Apr 2026 13:27:53 +0300
Subject: [PATCH 6/8] wip

---
 crates/gitcomet-state/src/model.rs            |  16 +-
 crates/gitcomet-state/src/store/mod.rs        |  17 --
 .../src/store/reducer/effects.rs              |   2 +-
 crates/gitcomet-state/src/store/tests.rs      | 115 +++++++++++-
 .../src/store/tests/external_and_history.rs   |   9 +-
 .../src/store/tests/repo_management.rs        |  23 +--
 .../src/view/panes/history.rs                 |   9 +-
 .../src/view/settings_window.rs               | 168 +++++++++++++++++-
 crates/gitcomet-ui-gpui/src/view/splash.rs    |  49 ++++-
 crates/gitcomet-ui-gpui/src/view/tests.rs     |  51 ++++++
 .../gitcomet-ui-gpui/src/view/toast_host.rs   |  43 +++--
 crates/gitcomet/src/main.rs                   |  70 +++++++-
 12 files changed, 502 insertions(+), 70 deletions(-)

diff --git a/crates/gitcomet-state/src/model.rs b/crates/gitcomet-state/src/model.rs
index c842ccc4..2060da15 100644
--- a/crates/gitcomet-state/src/model.rs
+++ b/crates/gitcomet-state/src/model.rs
@@ -24,17 +24,13 @@ pub struct SidebarDataRequest {
 
 #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
 #[serde(rename_all = "snake_case")]
+#[derive(Default)]
 pub enum GitLogTagFetchMode {
+    #[default]
     OnRepositoryActivation,
     Disabled,
 }
 
-impl Default for GitLogTagFetchMode {
-    fn default() -> Self {
-        Self::OnRepositoryActivation
-    }
-}
-
 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
 pub struct GitLogSettings {
     pub show_history_tags: bool,
@@ -80,7 +76,6 @@ impl RepoLoadsInFlight {
     pub const TAGS: u32 = 1 << 3;
     pub const REMOTES: u32 = 1 << 4;
     pub const REMOTE_BRANCHES: u32 = 1 << 5;
-    pub const STATUS: u32 = Self::WORKTREE_STATUS;
     pub const WORKTREE_STATUS: u32 = 1 << 6;
     pub const STAGED_STATUS: u32 = 1 << 7;
     pub const STASHES: u32 = 1 << 8;
@@ -1176,18 +1171,19 @@ mod tests {
         assert!(loads.is_in_flight(RepoLoadsInFlight::UPSTREAM_DIVERGENCE));
         assert!(loads.is_in_flight(RepoLoadsInFlight::REBASE_STATE));
         assert!(loads.is_in_flight(RepoLoadsInFlight::MERGE_COMMIT_MESSAGE));
-        assert!(loads.is_in_flight(RepoLoadsInFlight::STATUS));
+        assert!(loads.is_in_flight(RepoLoadsInFlight::WORKTREE_STATUS));
+        assert!(loads.is_in_flight(RepoLoadsInFlight::STAGED_STATUS));
         assert!(loads.is_in_flight(RepoLoadsInFlight::LOG));
     }
 
     #[test]
     fn request_primary_refresh_batch_skips_when_any_load_is_already_in_flight() {
         let mut loads = RepoLoadsInFlight::default();
-        assert!(loads.request(RepoLoadsInFlight::STATUS));
+        assert!(loads.request(RepoLoadsInFlight::WORKTREE_STATUS));
 
         assert!(!loads.request_primary_refresh_batch());
         assert!(!loads.is_in_flight(RepoLoadsInFlight::HEAD_BRANCH));
-        assert!(loads.is_in_flight(RepoLoadsInFlight::STATUS));
+        assert!(loads.is_in_flight(RepoLoadsInFlight::WORKTREE_STATUS));
         assert!(!loads.is_in_flight(RepoLoadsInFlight::LOG));
     }
 
diff --git a/crates/gitcomet-state/src/store/mod.rs b/crates/gitcomet-state/src/store/mod.rs
index 64ca78d4..8ea742ed 100644
--- a/crates/gitcomet-state/src/store/mod.rs
+++ b/crates/gitcomet-state/src/store/mod.rs
@@ -1,7 +1,6 @@
 use crate::model::{AppState, RepoId};
 use crate::msg::{Msg, StoreEvent};
 use gitcomet_core::path_utils::canonicalize_or_original;
-use gitcomet_core::process::refresh_git_runtime;
 use gitcomet_core::services::{GitBackend, GitRepository};
 use rustc_hash::FxHashMap as HashMap;
 use std::path::PathBuf;
@@ -446,22 +445,6 @@ impl AppStore {
     }
 
     pub fn dispatch(&self, msg: Msg) {
-        if reducer::msg_requires_available_git(&msg) {
-            let runtime = refresh_git_runtime();
-            let current_runtime = {
-                let state = self.state.read().unwrap_or_else(|e| e.into_inner());
-                state.git_runtime.clone()
-            };
-            if current_runtime != runtime {
-                send_or_log(
-                    &self.msg_tx,
-                    Msg::SetGitRuntimeState(runtime),
-                    SendFailureKind::StoreDispatch,
-                    "AppStore::dispatch/set-git-runtime-state",
-                );
-            }
-        }
-
         send_or_log(
             &self.msg_tx,
             msg,
diff --git a/crates/gitcomet-state/src/store/reducer/effects.rs b/crates/gitcomet-state/src/store/reducer/effects.rs
index 82a64dba..b1e1f522 100644
--- a/crates/gitcomet-state/src/store/reducer/effects.rs
+++ b/crates/gitcomet-state/src/store/reducer/effects.rs
@@ -1808,7 +1808,7 @@ mod tests {
             )));
             repo.set_conflict_hide_resolved(true);
         }
-        mark_pending(&mut state, repo_id, RepoLoadsInFlight::STATUS);
+        mark_pending(&mut state, repo_id, RepoLoadsInFlight::WORKTREE_STATUS);
         let effects = status_loaded(&mut state, repo_id, Ok(RepoStatus::default()));
         assert_eq!(effects.len(), 1);
         assert!(matches!(
diff --git a/crates/gitcomet-state/src/store/tests.rs b/crates/gitcomet-state/src/store/tests.rs
index 516eb72e..0a8ac0fe 100644
--- a/crates/gitcomet-state/src/store/tests.rs
+++ b/crates/gitcomet-state/src/store/tests.rs
@@ -7,7 +7,11 @@ use gitcomet_core::domain::{
 };
 use gitcomet_core::error::{Error, ErrorKind};
 use gitcomet_core::path_utils::canonicalize_or_original;
+use gitcomet_core::process::{
+    GitExecutablePreference, current_git_executable_preference, install_git_executable_preference,
+};
 use gitcomet_core::services::{CommandOutput, PullMode, Result};
+use std::fs;
 use std::path::{Path, PathBuf};
 use std::process::Command;
 use std::sync::{Arc, Mutex, MutexGuard, OnceLock};
@@ -204,6 +208,70 @@ pub(crate) fn staged_auth_test_lock() -> MutexGuard<'static, ()> {
         .unwrap_or_else(|e| e.into_inner())
 }
 
+fn git_runtime_store_test_lock() -> MutexGuard<'static, ()> {
+    static LOCK: OnceLock> = OnceLock::new();
+    LOCK.get_or_init(|| Mutex::new(()))
+        .lock()
+        .unwrap_or_else(|e| e.into_inner())
+}
+
+struct GitRuntimePreferenceResetGuard {
+    original: GitExecutablePreference,
+}
+
+impl GitRuntimePreferenceResetGuard {
+    fn install(preference: GitExecutablePreference) -> Self {
+        let original = current_git_executable_preference();
+        let _ = install_git_executable_preference(preference);
+        Self { original }
+    }
+}
+
+impl Drop for GitRuntimePreferenceResetGuard {
+    fn drop(&mut self) {
+        let _ = install_git_executable_preference(self.original.clone());
+    }
+}
+
+#[cfg(unix)]
+fn write_git_runtime_probe_script(script_path: &Path, probe_log: &Path) {
+    use std::os::unix::fs::PermissionsExt as _;
+
+    fs::write(
+        script_path,
+        format!(
+            "#!/bin/sh\nprintf 'probe\\n' >> '{}'\nprintf 'git version 9.9.9-test\\n'\n",
+            probe_log.display()
+        ),
+    )
+    .expect("write git runtime probe script");
+    let mut permissions = fs::metadata(script_path)
+        .expect("git runtime probe script metadata")
+        .permissions();
+    permissions.set_mode(0o700);
+    fs::set_permissions(script_path, permissions)
+        .expect("set git runtime probe script permissions");
+}
+
+#[cfg(windows)]
+fn write_git_runtime_probe_script(script_path: &Path, probe_log: &Path) {
+    fs::write(
+        script_path,
+        format!(
+            "@echo off\r\necho probe>>\"{}\"\r\necho git version 9.9.9-test\r\n",
+            probe_log.display()
+        ),
+    )
+    .expect("write git runtime probe script");
+}
+
+fn git_runtime_probe_count(probe_log: &Path) -> usize {
+    fs::read_to_string(probe_log)
+        .unwrap_or_default()
+        .lines()
+        .count()
+}
+
 fn has_worktree_status_effect(effects: &[Effect], repo_id: RepoId) -> bool {
     effects.iter().any(|effect| {
         matches!(
@@ -222,8 +290,19 @@ fn has_staged_status_effect(effects: &[Effect], repo_id: RepoId) -> bool {
     })
 }
 
+fn has_combined_status_effect(effects: &[Effect], repo_id: RepoId) -> bool {
+    effects.iter().any(|effect| {
+        matches!(
+            effect,
+            Effect::LoadStatus { repo_id: candidate } if *candidate == repo_id
+        )
+    })
+}
+
 fn has_status_refresh_effects(effects: &[Effect], repo_id: RepoId) -> bool {
-    has_worktree_status_effect(effects, repo_id) && has_staged_status_effect(effects, repo_id)
+    has_combined_status_effect(effects, repo_id)
+        || (has_worktree_status_effect(effects, repo_id)
+            && has_staged_status_effect(effects, repo_id))
 }
 
 #[test]
@@ -248,6 +327,40 @@ fn app_store_clone_dispatches_restore_and_close_paths() {
     assert_eq!(snapshot.active_repo, None);
 }
 
+#[test]
+fn app_store_dispatch_does_not_reprobe_git_runtime_for_git_messages() {
+    let _lock = git_runtime_store_test_lock();
+    let temp = tempfile::tempdir().expect("create tempdir for git runtime probe");
+    let probe_log = temp.path().join("git-runtime-probes.log");
+    #[cfg(unix)]
+    let script_path = temp.path().join("git");
+    #[cfg(windows)]
+    let script_path = temp.path().join("git.cmd");
+    write_git_runtime_probe_script(&script_path, &probe_log);
+
+    let _restore =
+        GitRuntimePreferenceResetGuard::install(GitExecutablePreference::Custom(script_path));
+
+    let backend: Arc = Arc::new(FailingBackend);
+    let (store, _event_rx) = AppStore::new(backend);
+
+    assert_eq!(
+        git_runtime_probe_count(&probe_log),
+        1,
+        "installing the custom runtime should probe exactly once"
+    );
+
+    store.dispatch(Msg::ReloadRepo {
+        repo_id: RepoId(999),
+    });
+
+    assert_eq!(
+        git_runtime_probe_count(&probe_log),
+        1,
+        "dispatch should not re-run `git --version` for regular Git-backed messages"
+    );
+}
+
 #[test]
 fn app_store_open_repo_effect_propagates_open_error_into_state() {
     let backend: Arc = Arc::new(FailingBackend);
diff --git a/crates/gitcomet-state/src/store/tests/external_and_history.rs b/crates/gitcomet-state/src/store/tests/external_and_history.rs
index b37b5b30..2d74fb15 100644
--- a/crates/gitcomet-state/src/store/tests/external_and_history.rs
+++ b/crates/gitcomet-state/src/store/tests/external_and_history.rs
@@ -670,7 +670,7 @@ fn reload_repo_sets_sections_loading_and_emits_refresh_effects() {
     assert!(repo_state.head_branch.is_loading());
     assert!(repo_state.branches.is_loading());
     assert!(repo_state.tags.is_loading());
-    assert!(repo_state.remote_tags.is_loading());
+    assert!(matches!(repo_state.remote_tags, Loadable::NotLoaded));
     assert!(repo_state.remotes.is_loading());
     assert!(repo_state.remote_branches.is_loading());
     assert!(repo_state.status.is_loading());
@@ -680,6 +680,13 @@ fn reload_repo_sets_sections_loading_and_emits_refresh_effects() {
     assert!(!repo_state.history_state.log_loading_more);
     assert!(repo_state.merge_commit_message.is_loading());
     assert!(has_status_refresh_effects(&effects, RepoId(1)));
+    assert!(
+        !effects.iter().any(|effect| matches!(
+            effect,
+            Effect::LoadRemoteTags { repo_id } if *repo_id == RepoId(1)
+        )),
+        "remote tags should lazy-load from tag UI, not repo reload"
+    );
 }
 
 #[test]
diff --git a/crates/gitcomet-state/src/store/tests/repo_management.rs b/crates/gitcomet-state/src/store/tests/repo_management.rs
index c05cf046..ee4d6158 100644
--- a/crates/gitcomet-state/src/store/tests/repo_management.rs
+++ b/crates/gitcomet-state/src/store/tests/repo_management.rs
@@ -4,7 +4,6 @@ use crate::model::{RepoLoadsInFlight, SidebarDataRequest};
 fn mark_repo_switch_secondary_metadata_ready(repo: &mut RepoState) {
     repo.branches = Loadable::Ready(Arc::new(Vec::new()));
     repo.tags = Loadable::Ready(Arc::new(Vec::new()));
-    repo.remote_tags = Loadable::Ready(Arc::new(Vec::new()));
     repo.remotes = Loadable::Ready(Arc::new(Vec::new()));
     repo.remote_branches = Loadable::Ready(Arc::new(Vec::new()));
     repo.stashes = Loadable::Ready(Arc::new(Vec::new()));
@@ -17,7 +16,6 @@ fn has_full_refresh_only_effects(effects: &[Effect], repo_id: RepoId) -> bool {
         matches!(
             effect,
             Effect::LoadTags { repo_id: candidate }
-                | Effect::LoadRemoteTags { repo_id: candidate }
                 | Effect::LoadRemotes { repo_id: candidate }
                 | Effect::LoadRemoteBranches { repo_id: candidate }
                 if *candidate == repo_id
@@ -1818,7 +1816,7 @@ fn repo_opened_ok_sets_loading_and_emits_refresh_effects() {
     assert!(repo_state.head_branch.is_loading());
     assert!(repo_state.branches.is_loading());
     assert!(repo_state.tags.is_loading());
-    assert!(repo_state.remote_tags.is_loading());
+    assert!(matches!(repo_state.remote_tags, Loadable::NotLoaded));
     assert!(repo_state.remotes.is_loading());
     assert!(repo_state.remote_branches.is_loading());
     assert!(repo_state.status.is_loading());
@@ -1880,18 +1878,13 @@ fn repo_opened_ok_sets_loading_and_emits_refresh_effects() {
             matches!(effect, Effect::LoadTags { repo_id: candidate } if *candidate == repo_id)
         }
     ));
-    assert!(has_effect_for_repo(
-        &effects,
-        RepoId(1),
-        |effect, repo_id| {
-            matches!(
-                effect,
-                Effect::LoadRemoteTags {
-                    repo_id: candidate
-                } if *candidate == repo_id
-            )
-        }
-    ));
+    assert!(
+        !effects.iter().any(|effect| matches!(
+            effect,
+            Effect::LoadRemoteTags { repo_id } if *repo_id == RepoId(1)
+        )),
+        "remote tags should lazy-load from tag UI, not repo open"
+    );
     assert!(has_effect_for_repo(
         &effects,
         RepoId(1),
diff --git a/crates/gitcomet-ui-gpui/src/view/panes/history.rs b/crates/gitcomet-ui-gpui/src/view/panes/history.rs
index e27384ac..e0ead58d 100644
--- a/crates/gitcomet-ui-gpui/src/view/panes/history.rs
+++ b/crates/gitcomet-ui-gpui/src/view/panes/history.rs
@@ -888,10 +888,11 @@ impl HistoryView {
             detached_head_commit: repo.detached_head_commit.clone(),
             branches_rev: repo.branches_rev,
             remote_branches_rev: repo.remote_branches_rev,
-            tags_rev: self
-                .history_show_tags
-                .then_some(repo.tags_rev)
-                .unwrap_or_default(),
+            tags_rev: if self.history_show_tags {
+                repo.tags_rev
+            } else {
+                Default::default()
+            },
             stashes_rev: repo.stashes_rev,
             date_time_format: self.date_time_format,
             timezone: self.timezone,
diff --git a/crates/gitcomet-ui-gpui/src/view/settings_window.rs b/crates/gitcomet-ui-gpui/src/view/settings_window.rs
index ffef872f..646277fd 100644
--- a/crates/gitcomet-ui-gpui/src/view/settings_window.rs
+++ b/crates/gitcomet-ui-gpui/src/view/settings_window.rs
@@ -4,6 +4,7 @@ use gitcomet_core::process::{
 };
 use gitcomet_state::model::GitLogTagFetchMode;
 use gpui::{Stateful, TitlebarOptions, WindowBounds, WindowDecorations, WindowOptions};
+use std::path::PathBuf;
 use std::sync::Arc;
 
 const SETTINGS_WINDOW_MIN_WIDTH_PX: f32 = 620.0;
@@ -377,6 +378,17 @@ fn git_log_tag_fetch_mode_label(mode: GitLogTagFetchMode) -> &'static str {
     }
 }
 
+fn applied_git_executable_path(runtime: &GitRuntimeState) -> Option {
+    match &runtime.preference {
+        GitExecutablePreference::SystemPath => None,
+        GitExecutablePreference::Custom(path) => Some(path.clone()),
+    }
+}
+
+fn git_executable_scope_note() -> &'static str {
+    "Applies only to the main GitComet browser window. Git-invoked command modes keep using git from System PATH."
+}
+
 impl SettingsWindowView {
     fn new(window: &mut Window, cx: &mut gpui::Context) -> Self {
         window.set_window_title(SETTINGS_WINDOW_TITLE);
@@ -549,7 +561,7 @@ impl SettingsWindowView {
             history_show_sha: Some(self.history_show_sha),
             history_show_tags: Some(self.history_show_tags),
             history_tag_fetch_mode: Some(self.history_tag_fetch_mode),
-            git_executable_path: Some(self.selected_git_executable_path()),
+            git_executable_path: Some(applied_git_executable_path(&self.runtime_info.git.runtime)),
         };
 
         cx.background_spawn(async move {
@@ -2371,6 +2383,15 @@ impl Render for SettingsWindowView {
 
                 let mut git_executable_card = self
                     .card("settings_window_git_executable", "Git executable", theme)
+                    .child(
+                        div()
+                            .id("settings_window_git_executable_scope_note")
+                            .px_2()
+                            .pb_1()
+                            .text_xs()
+                            .text_color(theme.colors.text_muted)
+                            .child(git_executable_scope_note()),
+                    )
                     .child(system_git_row)
                     .child(custom_git_row);
 
@@ -2548,9 +2569,10 @@ impl Render for SettingsWindowView {
                         )),
                     );
 
-                div()
+                let scroll_surface = div()
                     .id("settings_window_scroll")
-                    .flex_1()
+                    .h_full()
+                    .min_h(px(0.0))
                     .overflow_y_scroll()
                     .track_scroll(&self.settings_window_scroll)
                     .flex()
@@ -2563,7 +2585,37 @@ impl Render for SettingsWindowView {
                     .child(git_log_card)
                     .child(git_executable_card)
                     .child(environment_card)
-                    .child(links_card)
+                    .child(links_card);
+
+                div()
+                    .id("settings_window_root_view")
+                    .relative()
+                    .flex_1()
+                    .min_h(px(0.0))
+                    .child(
+                        div()
+                            .flex_1()
+                            .h_full()
+                            .min_h(px(0.0))
+                            .pr(components::Scrollbar::visible_gutter(
+                                self.settings_window_scroll.clone(),
+                                components::ScrollbarAxis::Vertical,
+                            ))
+                            .child(scroll_surface),
+                    )
+                    .child(
+                        {
+                            let scrollbar = components::Scrollbar::new(
+                                "settings_window_scrollbar",
+                                self.settings_window_scroll.clone(),
+                            )
+                            .always_visible();
+                            #[cfg(test)]
+                            let scrollbar = scrollbar.debug_selector("settings_window_scrollbar");
+                            scrollbar
+                        }
+                        .render(theme),
+                    )
             }
             SettingsView::OpenSourceLicenses => {
                 let rows = crate::view::open_source_licenses_data::open_source_license_rows();
@@ -2929,6 +2981,50 @@ mod tests {
         );
     }
 
+    #[test]
+    fn applied_git_executable_path_tracks_runtime_preference() {
+        assert_eq!(
+            applied_git_executable_path(&GitRuntimeState {
+                preference: GitExecutablePreference::SystemPath,
+                availability: GitExecutableAvailability::Available {
+                    version_output: "git version 2.51.0".to_string(),
+                },
+            }),
+            None
+        );
+        assert_eq!(
+            applied_git_executable_path(&GitRuntimeState {
+                preference: GitExecutablePreference::Custom(PathBuf::from("/opt/git/bin/git")),
+                availability: GitExecutableAvailability::Available {
+                    version_output: "git version 2.51.0".to_string(),
+                },
+            }),
+            Some(PathBuf::from("/opt/git/bin/git"))
+        );
+        assert_eq!(
+            applied_git_executable_path(&GitRuntimeState {
+                preference: GitExecutablePreference::Custom(PathBuf::new()),
+                availability: GitExecutableAvailability::Unavailable {
+                    detail: "missing".to_string(),
+                },
+            }),
+            Some(PathBuf::new())
+        );
+    }
+
+    #[test]
+    fn git_executable_scope_note_mentions_browser_only_scope() {
+        let note = git_executable_scope_note();
+        assert!(
+            note.contains("browser window"),
+            "expected browser-only scope note, got: {note}"
+        );
+        assert!(
+            note.contains("System PATH"),
+            "expected command-mode fallback note, got: {note}"
+        );
+    }
+
     #[test]
     fn parse_git_version_extracts_first_version_token() {
         assert_eq!(
@@ -3348,6 +3444,70 @@ mod tests {
         });
     }
 
+    #[gpui::test]
+    fn settings_window_root_view_renders_visible_scrollbar(cx: &mut gpui::TestAppContext) {
+        let _visual_guard = lock_visual_test();
+        let (store, events) = AppStore::new(std::sync::Arc::new(TestBackend));
+        let (_main_view, cx) =
+            cx.add_window_view(|window, cx| GitCometView::new(store, events, None, window, cx));
+
+        cx.update(|window, app| {
+            let _ = window.draw(app);
+            open_settings_window(app);
+        });
+        cx.run_until_parked();
+
+        let settings_window = cx.update(|_window, app| {
+            app.windows()
+                .into_iter()
+                .find_map(|window| window.downcast::())
+                .expect("settings window should be open")
+        });
+
+        let synthetic_fonts: Arc<[String]> = (0..200)
+            .map(|ix| format!("Test UI Font {ix:03}"))
+            .collect::>()
+            .into();
+
+        cx.update(|_window, app| {
+            let _ = settings_window.update(app, |settings, _window, cx| {
+                settings.ui_font_options = synthetic_fonts.clone();
+                settings.ui_font_family = synthetic_fonts[0].clone();
+                settings.expanded_section = Some(SettingsSection::UiFont);
+                settings.settings_window_scroll = ScrollHandle::default();
+                settings.ui_font_scroll = UniformListScrollHandle::default();
+                cx.notify();
+            });
+        });
+
+        let mut settings_cx = gpui::VisualTestContext::from_window(*settings_window.deref(), cx);
+        settings_cx.run_until_parked();
+        settings_cx.simulate_resize(size(
+            px(SETTINGS_WINDOW_DEFAULT_WIDTH_PX),
+            px(SETTINGS_WINDOW_MIN_HEIGHT_PX),
+        ));
+        settings_cx.run_until_parked();
+        settings_cx.update(|window, app| {
+            let _ = window.draw(app);
+        });
+
+        let max_offset = settings_window
+            .update(&mut settings_cx, |settings, _window, _cx| {
+                settings.settings_window_scroll.max_offset().y.max(px(0.0))
+            })
+            .expect("settings window should remain readable");
+        assert!(
+            max_offset > px(0.0),
+            "expected the root settings page to be scrollable during the test"
+        );
+        assert!(
+            settings_cx
+                .debug_bounds("settings_window_scrollbar")
+                .is_some(),
+            "expected a visible scrollbar in the root settings view"
+        );
+    }
+
     #[gpui::test]
     fn non_macos_settings_window_renders_custom_chrome_controls(cx: &mut gpui::TestAppContext) {
         if cfg!(target_os = "macos") {
diff --git a/crates/gitcomet-ui-gpui/src/view/splash.rs b/crates/gitcomet-ui-gpui/src/view/splash.rs
index 68ccd11b..1392a5a2 100644
--- a/crates/gitcomet-ui-gpui/src/view/splash.rs
+++ b/crates/gitcomet-ui-gpui/src/view/splash.rs
@@ -67,13 +67,43 @@ impl GitCometView {
         !self.state.git_runtime.is_available()
     }
 
-    fn git_runtime_unavailable_detail(&self) -> SharedString {
+    fn git_runtime_unavailable_detail(&self) -> String {
         self.state
             .git_runtime
             .unavailable_detail()
             .unwrap_or("GitComet could not find a usable Git executable.")
             .to_string()
-            .into()
+    }
+
+    fn git_unavailable_status_icon(theme: AppTheme) -> AnyElement {
+        div()
+            .id("git_unavailable_status_icon")
+            .debug_selector(|| "git_unavailable_status_icon".to_string())
+            .size(px(56.0))
+            .flex()
+            .items_center()
+            .justify_center()
+            .child(svg_icon(
+                "icons/warning.svg",
+                theme.colors.warning,
+                px(36.0),
+            ))
+            .into_any_element()
+    }
+
+    fn git_runtime_unavailable_detail_content(&self) -> AnyElement {
+        let detail = self.git_runtime_unavailable_detail();
+        if let Some((summary, recovery)) = detail.split_once(". ") {
+            return div()
+                .flex()
+                .flex_col()
+                .gap(px(0.0))
+                .child(format!("{summary}."))
+                .child(recovery.to_string())
+                .into_any_element();
+        }
+
+        div().child(detail).into_any_element()
     }
 
     fn should_show_git_unavailable_overlay(&self) -> bool {
@@ -252,6 +282,7 @@ impl GitCometView {
         let primary_hover = gpui::rgba(0x72c7ffff);
         let primary_active = gpui::rgba(0x48b6eeff);
         let primary_text = gpui::rgba(0x04172bff);
+        let settings_tooltip: SharedString = "Open settings".into();
 
         Self::splash_cta_button(
             "git_unavailable_open_settings",
@@ -277,6 +308,16 @@ impl GitCometView {
             cx.defer(crate::view::open_settings_window);
             cx.notify();
         }))
+        .on_hover(cx.listener({
+            let settings_tooltip = settings_tooltip.clone();
+            move |this, hovering: &bool, _w, cx| {
+                if *hovering {
+                    this.set_tooltip_text_if_changed(Some(settings_tooltip.clone()), cx);
+                } else {
+                    this.clear_tooltip_if_matches(&settings_tooltip, cx);
+                }
+            }
+        }))
     }
 
     fn git_unavailable_panel_content(
@@ -297,7 +338,7 @@ impl GitCometView {
             .flex_col()
             .items_center()
             .gap_3()
-            .child(Self::interstitial_logo(theme, px(84.0)))
+            .child(Self::git_unavailable_status_icon(theme))
             .child(
                 div()
                     .text_lg()
@@ -330,7 +371,7 @@ impl GitCometView {
                     .text_xs()
                     .line_height(px(18.0))
                     .text_color(theme.colors.text_muted)
-                    .child(self.git_runtime_unavailable_detail()),
+                    .child(self.git_runtime_unavailable_detail_content()),
             )
             .child(
                 div()
diff --git a/crates/gitcomet-ui-gpui/src/view/tests.rs b/crates/gitcomet-ui-gpui/src/view/tests.rs
index 8d0149a0..40bf2257 100644
--- a/crates/gitcomet-ui-gpui/src/view/tests.rs
+++ b/crates/gitcomet-ui-gpui/src/view/tests.rs
@@ -1798,6 +1798,8 @@ fn git_unavailable_splash_renders_open_settings_call_to_action(cx: &mut gpui::Te
 
     cx.debug_bounds("git_unavailable_screen")
         .expect("expected git unavailable splash screen");
+    cx.debug_bounds("git_unavailable_status_icon")
+        .expect("expected git unavailable status icon");
     cx.debug_bounds("git_unavailable_open_settings")
         .expect("expected open settings call to action");
     assert!(
@@ -1811,6 +1813,55 @@ fn git_unavailable_splash_renders_open_settings_call_to_action(cx: &mut gpui::Te
     });
 }
 
+#[gpui::test]
+fn git_unavailable_open_settings_button_publishes_expected_tooltip(cx: &mut gpui::TestAppContext) {
+    let _visual_guard = crate::test_support::lock_visual_test();
+    let (store, events) = AppStore::new(Arc::new(TestBackend));
+    let (view, cx) =
+        cx.add_window_view(|window, cx| GitCometView::new(store, events, None, window, cx));
+
+    let next = Arc::new(AppState {
+        git_runtime: unavailable_git_runtime_state(),
+        ..AppState::default()
+    });
+
+    cx.update(|window, app| {
+        view.update(app, |this, cx| {
+            this.apply_state_snapshot(Arc::clone(&next), cx);
+        });
+        let _ = window.draw(app);
+    });
+
+    let button_center = cx
+        .debug_bounds("git_unavailable_open_settings")
+        .expect("expected open settings call to action")
+        .center();
+    cx.simulate_mouse_move(button_center, None, gpui::Modifiers::default());
+    cx.run_until_parked();
+
+    cx.update(|_window, app| {
+        assert_eq!(
+            test_support::tooltip_text(view.read(app), app).map(|text| text.to_string()),
+            Some("Open settings".to_string())
+        );
+    });
+
+    let icon_center = cx
+        .debug_bounds("git_unavailable_status_icon")
+        .expect("expected git unavailable status icon")
+        .center();
+    cx.simulate_mouse_move(icon_center, None, gpui::Modifiers::default());
+    cx.run_until_parked();
+
+    cx.update(|_window, app| {
+        assert_eq!(
+            test_support::tooltip_text(view.read(app), app),
+            None,
+            "expected the open settings tooltip to clear after leaving the button"
+        );
+    });
+}
+
 #[gpui::test]
 fn git_unavailable_overlay_blocks_open_repositories(cx: &mut gpui::TestAppContext) {
     let _visual_guard = crate::test_support::lock_visual_test();
diff --git a/crates/gitcomet-ui-gpui/src/view/toast_host.rs b/crates/gitcomet-ui-gpui/src/view/toast_host.rs
index bc5ac3c9..64dc9872 100644
--- a/crates/gitcomet-ui-gpui/src/view/toast_host.rs
+++ b/crates/gitcomet-ui-gpui/src/view/toast_host.rs
@@ -11,6 +11,11 @@ pub(super) struct ToastHost {
     clone_progress_dest: Option>,
 }
 
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+enum ToastViewportCorner {
+    BottomRight,
+}
+
 #[derive(Debug, Eq, PartialEq)]
 struct CloneProgressSyncAction {
     progress_changed: bool,
@@ -25,6 +30,10 @@ fn clone_progress_shell_accent_color(theme: AppTheme) -> gpui::Rgba {
     with_alpha(theme.colors.accent, if theme.is_dark { 0.20 } else { 0.14 })
 }
 
+fn toast_viewport_corner() -> ToastViewportCorner {
+    ToastViewportCorner::BottomRight
+}
+
 fn looks_like_code_message(message: &str) -> bool {
     message.lines().any(|line| line.starts_with("    "))
 }
@@ -608,20 +617,29 @@ impl Render for ToastHost {
             children.push(progress_toast);
         }
 
-        div()
+        let root = div()
             .id("toast_layer")
-            .on_any_mouse_down(|_e, _w, cx| cx.stop_propagation())
-            .occlude()
             .absolute()
-            .right_0()
-            .bottom_0()
+            .top_0()
+            .left_0()
+            .size_full()
             .p(px(16.0))
             .flex()
-            .flex_col()
-            .items_end()
-            .gap(px(12.0))
-            .children(children)
-            .into_any_element()
+            .child(
+                div()
+                    .id("toast_stack")
+                    .on_any_mouse_down(|_e, _w, cx| cx.stop_propagation())
+                    .occlude()
+                    .flex()
+                    .flex_col()
+                    .items_end()
+                    .gap(px(12.0))
+                    .children(children),
+            );
+
+        match toast_viewport_corner() {
+            ToastViewportCorner::BottomRight => root.justify_end().items_end().into_any_element(),
+        }
     }
 }
 
@@ -921,4 +939,9 @@ mod tests {
             with_alpha(light.colors.accent, 0.14)
         );
     }
+
+    #[test]
+    fn toast_stack_anchor_is_bottom_right() {
+        assert_eq!(toast_viewport_corner(), ToastViewportCorner::BottomRight);
+    }
 }
diff --git a/crates/gitcomet/src/main.rs b/crates/gitcomet/src/main.rs
index fe016ab9..7650cd33 100644
--- a/crates/gitcomet/src/main.rs
+++ b/crates/gitcomet/src/main.rs
@@ -149,8 +149,6 @@ fn should_launch_focused_diff_gui(
 }
 
 fn main() {
-    install_configured_git_executable_preference();
-
     let mode = match cli::parse_app_mode() {
         Ok(mode) => mode,
         Err(msg) => {
@@ -159,6 +157,8 @@ fn main() {
         }
     };
 
+    install_configured_git_executable_preference(&mode);
+
     #[cfg(all(target_os = "linux", feature = "ui-gpui-runtime"))]
     if let Some(code) = maybe_relaunch_with_linux_x11_fallback(&mode) {
         std::process::exit(code);
@@ -303,7 +303,18 @@ fn main() {
     }
 }
 
-fn install_configured_git_executable_preference() {
+fn mode_uses_configured_git_executable_preference(mode: &AppMode) -> bool {
+    // The persisted custom Git executable is a browser-window preference.
+    // Git-invoked command modes intentionally keep using `git` from PATH so
+    // they track the invoking Git installation rather than browser settings.
+    matches!(mode, AppMode::Browser { .. })
+}
+
+fn install_configured_git_executable_preference(mode: &AppMode) {
+    if !mode_uses_configured_git_executable_preference(mode) {
+        return;
+    }
+
     let session = gitcomet_state::session::load();
     let _ = install_git_executable_path(session.git_executable_path);
 }
@@ -747,6 +758,59 @@ mod tests {
         assert!(!should_launch_focused_diff_gui(&config, &result));
     }
 
+    #[test]
+    fn configured_git_preference_is_intentionally_browser_only() {
+        assert!(mode_uses_configured_git_executable_preference(
+            &AppMode::Browser { path: None }
+        ));
+        assert!(!mode_uses_configured_git_executable_preference(
+            &AppMode::Difftool(cli::DifftoolConfig {
+                local: std::path::PathBuf::from("left.txt"),
+                remote: std::path::PathBuf::from("right.txt"),
+                display_path: None,
+                label_left: None,
+                label_right: None,
+                gui: false,
+            })
+        ));
+        assert!(!mode_uses_configured_git_executable_preference(
+            &AppMode::Mergetool(cli::MergetoolConfig {
+                merged: std::path::PathBuf::from("merged.txt"),
+                local: std::path::PathBuf::from("local.txt"),
+                remote: std::path::PathBuf::from("remote.txt"),
+                base: Some(std::path::PathBuf::from("base.txt")),
+                label_base: None,
+                label_local: None,
+                label_remote: None,
+                conflict_style: ConflictStyle::Merge,
+                diff_algorithm: DiffAlgorithm::Myers,
+                marker_size: DEFAULT_MARKER_SIZE,
+                auto: false,
+                gui: false,
+            })
+        ));
+        assert!(!mode_uses_configured_git_executable_preference(
+            &AppMode::Setup {
+                dry_run: false,
+                local: false,
+            }
+        ));
+        assert!(!mode_uses_configured_git_executable_preference(
+            &AppMode::Uninstall {
+                dry_run: false,
+                local: false,
+            }
+        ));
+        assert!(!mode_uses_configured_git_executable_preference(
+            &AppMode::ExtractMergeFixtures(cli::ExtractMergeFixturesConfig {
+                repo: std::path::PathBuf::from("/tmp/repo"),
+                output_dir: std::path::PathBuf::from("/tmp/out"),
+                max_merges: 10,
+                max_files_per_merge: 5,
+            })
+        ));
+    }
+
     #[test]
     #[cfg(feature = "ui-gpui-runtime")]
     fn build_focused_mergetool_gui_config_uses_default_labels() {

From 4bfb9f4db4dab4418e96a6ef2c9e8bae574e4821 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Sampo=20Kivist=C3=B6?= 
Date: Mon, 13 Apr 2026 14:11:02 +0300
Subject: [PATCH 7/8] changed SVG diff background white. Added more tests

---
 crates/gitcomet-core/src/process.rs           | 318 ++++++++++--
 crates/gitcomet-git-gix/src/repo/status.rs    | 458 +++++++++++++++++-
 .../gitcomet-ui-gpui/src/view/diff_utils.rs   |  25 +
 .../view/panes/main/diff_cache/image_cache.rs |  44 +-
 4 files changed, 795 insertions(+), 50 deletions(-)

diff --git a/crates/gitcomet-core/src/process.rs b/crates/gitcomet-core/src/process.rs
index 7fa99f49..45b0849a 100644
--- a/crates/gitcomet-core/src/process.rs
+++ b/crates/gitcomet-core/src/process.rs
@@ -292,6 +292,101 @@ pub fn lock_git_runtime_test() -> std::sync::MutexGuard<'static, ()> {
 mod tests {
     use super::*;
     use std::fs;
+    use std::path::Path;
+
+    #[cfg(unix)]
+    use std::os::unix::fs::PermissionsExt as _;
+
+    struct GitRuntimePreferenceResetGuard {
+        original: GitExecutablePreference,
+    }
+
+    impl GitRuntimePreferenceResetGuard {
+        fn install(preference: GitExecutablePreference) -> Self {
+            let original = current_git_executable_preference();
+            let _ = install_git_executable_preference(preference);
+            Self { original }
+        }
+    }
+
+    impl Drop for GitRuntimePreferenceResetGuard {
+        fn drop(&mut self) {
+            let _ = install_git_executable_preference(self.original.clone());
+        }
+    }
+
+    fn git_runtime_probe_count(probe_log: &Path) -> usize {
+        fs::read_to_string(probe_log)
+            .unwrap_or_default()
+            .lines()
+            .count()
+    }
+
+    fn create_git_probe_script(
+        stdout: &str,
+        stderr: &str,
+        exit_code: i32,
+        probe_log: Option<&Path>,
+    ) -> (tempfile::TempDir, PathBuf) {
+        let dir = tempfile::tempdir().expect("create temp dir");
+        #[cfg(unix)]
+        let script = dir.path().join("git");
+        #[cfg(windows)]
+        let script = dir.path().join("git.cmd");
+
+        write_git_probe_script(&script, stdout, stderr, exit_code, probe_log);
+        (dir, script)
+    }
+
+    #[cfg(unix)]
+    fn write_git_probe_script(
+        script_path: &Path,
+        stdout: &str,
+        stderr: &str,
+        exit_code: i32,
+        probe_log: Option<&Path>,
+    ) {
+        let mut script = String::from("#!/bin/sh\n");
+        if let Some(probe_log) = probe_log {
+            script.push_str(&format!("printf 'probe\\n' >> '{}'\n", probe_log.display()));
+        }
+        if !stdout.is_empty() {
+            script.push_str(&format!("printf '%s\\n' '{stdout}'\n"));
+        }
+        if !stderr.is_empty() {
+            script.push_str(&format!("printf '%s\\n' '{stderr}' >&2\n"));
+        }
+        script.push_str(&format!("exit {exit_code}\n"));
+
+        fs::write(script_path, script).expect("write git probe script");
+        let mut permissions = fs::metadata(script_path)
+            .expect("git probe metadata")
+            .permissions();
+        permissions.set_mode(0o700);
+        fs::set_permissions(script_path, permissions).expect("set git probe permissions");
+    }
+
+    #[cfg(windows)]
+    fn write_git_probe_script(
+        script_path: &Path,
+        stdout: &str,
+        stderr: &str,
+        exit_code: i32,
+        probe_log: Option<&Path>,
+    ) {
+        let mut script = String::from("@echo off\r\n");
+        if let Some(probe_log) = probe_log {
+            script.push_str(&format!(">>\"{}\" echo probe\r\n", probe_log.display()));
+        }
+        if !stdout.is_empty() {
+            script.push_str(&format!("echo {stdout}\r\n"));
+        }
+        if !stderr.is_empty() {
+            script.push_str(&format!("1>&2 echo {stderr}\r\n"));
+        }
+        script.push_str(&format!("exit /b {exit_code}\r\n"));
+        fs::write(script_path, script).expect("write git probe script");
+    }
 
     #[test]
     fn normalize_git_executable_path_makes_relative_paths_absolute() {
@@ -303,12 +398,50 @@ mod tests {
         );
     }
 
+    #[test]
+    fn git_executable_preference_from_optional_path_covers_all_variants() {
+        let relative = PathBuf::from("test-git");
+
+        assert_eq!(
+            GitExecutablePreference::from_optional_path(None),
+            GitExecutablePreference::SystemPath
+        );
+        assert_eq!(
+            GitExecutablePreference::from_optional_path(Some(PathBuf::new())),
+            GitExecutablePreference::Custom(PathBuf::new())
+        );
+        assert_eq!(
+            GitExecutablePreference::from_optional_path(Some(relative.clone())),
+            GitExecutablePreference::Custom(normalize_git_executable_path(relative))
+        );
+    }
+
+    #[test]
+    fn git_executable_preference_custom_path_and_display_label_cover_variants() {
+        let custom = GitExecutablePreference::Custom(PathBuf::from("/opt/git/bin/git"));
+        let empty_custom = GitExecutablePreference::Custom(PathBuf::new());
+
+        assert_eq!(GitExecutablePreference::SystemPath.custom_path(), None);
+        assert_eq!(custom.custom_path(), Some(Path::new("/opt/git/bin/git")));
+        assert_eq!(empty_custom.custom_path(), Some(Path::new("")));
+
+        assert_eq!(
+            GitExecutablePreference::SystemPath.display_label(),
+            "System PATH"
+        );
+        assert_eq!(
+            empty_custom.display_label(),
+            "Custom executable (not selected)"
+        );
+        assert_eq!(custom.display_label(), "/opt/git/bin/git");
+    }
+
     #[test]
     fn install_git_executable_preference_reports_missing_custom_path() {
         let _lock = lock_git_runtime_test();
-        let original = current_git_executable_preference();
-
         let missing = std::env::temp_dir().join("gitcomet-missing-git-executable");
+        let _restore = GitRuntimePreferenceResetGuard::install(current_git_executable_preference());
+
         let state =
             install_git_executable_preference(GitExecutablePreference::Custom(missing.clone()));
 
@@ -323,49 +456,96 @@ mod tests {
                 .expect("expected unavailable detail")
                 .contains(&missing.display().to_string())
         );
+    }
 
-        let _ = install_git_executable_preference(original);
+    #[test]
+    fn install_git_executable_preference_uses_custom_executable_stdout() {
+        let _lock = lock_git_runtime_test();
+        let (_dir, script) = create_git_probe_script("git version 9.9.9-test", "", 0, None);
+        let _restore = GitRuntimePreferenceResetGuard::install(current_git_executable_preference());
+
+        let state =
+            install_git_executable_preference(GitExecutablePreference::Custom(script.clone()));
+        assert!(state.is_available());
+        assert_eq!(state.version_output(), Some("git version 9.9.9-test"));
     }
 
     #[test]
-    fn install_git_executable_preference_uses_custom_executable() {
+    fn install_git_executable_preference_uses_stderr_when_stdout_is_empty() {
         let _lock = lock_git_runtime_test();
-        let original = current_git_executable_preference();
+        let (_dir, script) = create_git_probe_script("", "git version 8.8.8-test", 0, None);
+        let _restore = GitRuntimePreferenceResetGuard::install(current_git_executable_preference());
 
-        let dir = tempfile::tempdir().expect("create temp dir");
-        #[cfg(unix)]
-        let script = dir.path().join("git");
-        #[cfg(windows)]
-        let script = dir.path().join("git.cmd");
+        let state =
+            install_git_executable_preference(GitExecutablePreference::Custom(script.clone()));
 
-        #[cfg(unix)]
-        {
-            use std::os::unix::fs::PermissionsExt as _;
+        assert!(state.is_available());
+        assert_eq!(state.version_output(), Some("git version 8.8.8-test"));
+    }
 
-            fs::write(&script, "#!/bin/sh\necho 'git version 9.9.9-test'\n").expect("write script");
-            let mut permissions = fs::metadata(&script).expect("metadata").permissions();
-            permissions.set_mode(0o700);
-            fs::set_permissions(&script, permissions).expect("set permissions");
-        }
+    #[test]
+    fn install_git_executable_preference_reports_empty_version_output() {
+        let _lock = lock_git_runtime_test();
+        let (_dir, script) = create_git_probe_script("", "", 0, None);
+        let _restore = GitRuntimePreferenceResetGuard::install(current_git_executable_preference());
 
-        #[cfg(windows)]
-        {
-            fs::write(&script, "@echo off\r\necho git version 9.9.9-test\r\n")
-                .expect("write script");
-        }
+        let state =
+            install_git_executable_preference(GitExecutablePreference::Custom(script.clone()));
+
+        assert!(!state.is_available());
+        assert_eq!(
+            state.unavailable_detail(),
+            Some(
+                &format!(
+                    "Git executable at {} returned no version text.",
+                    script.display()
+                )[..]
+            )
+        );
+    }
+
+    #[test]
+    fn install_git_executable_preference_reports_process_failure_with_stderr() {
+        let _lock = lock_git_runtime_test();
+        let (_dir, script) = create_git_probe_script("", "fatal: not a git executable", 7, None);
+        let _restore = GitRuntimePreferenceResetGuard::install(current_git_executable_preference());
 
         let state =
             install_git_executable_preference(GitExecutablePreference::Custom(script.clone()));
-        assert!(state.is_available());
-        assert_eq!(state.version_output(), Some("git version 9.9.9-test"));
 
-        let _ = install_git_executable_preference(original);
+        assert!(!state.is_available());
+        assert_eq!(
+            state.unavailable_detail(),
+            Some(
+                &format!(
+                    "Git executable at {} failed: fatal: not a git executable",
+                    script.display()
+                )[..]
+            )
+        );
+    }
+
+    #[test]
+    fn install_git_executable_preference_reports_process_failure_without_stderr() {
+        let _lock = lock_git_runtime_test();
+        let (_dir, script) = create_git_probe_script("", "", 7, None);
+        let _restore = GitRuntimePreferenceResetGuard::install(current_git_executable_preference());
+
+        let state =
+            install_git_executable_preference(GitExecutablePreference::Custom(script.clone()));
+
+        assert!(!state.is_available());
+        let detail = state
+            .unavailable_detail()
+            .expect("expected unavailable detail");
+        assert!(detail.contains(&script.display().to_string()));
+        assert!(detail.contains("exited with"));
     }
 
     #[test]
     fn install_git_executable_preference_reports_missing_custom_selection() {
         let _lock = lock_git_runtime_test();
-        let original = current_git_executable_preference();
+        let _restore = GitRuntimePreferenceResetGuard::install(current_git_executable_preference());
 
         let state =
             install_git_executable_preference(GitExecutablePreference::Custom(PathBuf::new()));
@@ -381,7 +561,89 @@ mod tests {
                 "Custom Git executable is not configured. Choose an executable or switch back to System PATH."
             )
         );
+    }
+
+    #[test]
+    fn git_command_uses_installed_preference() {
+        let _lock = lock_git_runtime_test();
+        let temp = tempfile::tempdir().expect("create temp dir");
+        let probe_log = temp.path().join("git-probes.log");
+        let (_dir, script) =
+            create_git_probe_script("git version 7.7.7-test", "", 0, Some(&probe_log));
+        let _restore =
+            GitRuntimePreferenceResetGuard::install(GitExecutablePreference::Custom(script));
+
+        assert_eq!(
+            git_runtime_probe_count(&probe_log),
+            1,
+            "installing a custom executable should probe exactly once"
+        );
+
+        let output = git_command()
+            .arg("--version")
+            .output()
+            .expect("run configured git command");
+        assert!(output.status.success());
+        assert_eq!(
+            String::from_utf8_lossy(&output.stdout).trim(),
+            "git version 7.7.7-test"
+        );
+        assert_eq!(
+            git_runtime_probe_count(&probe_log),
+            2,
+            "running the returned command should invoke the configured executable"
+        );
+    }
 
-        let _ = install_git_executable_preference(original);
+    #[test]
+    fn refresh_git_runtime_reprobes_current_preference() {
+        let _lock = lock_git_runtime_test();
+        let temp = tempfile::tempdir().expect("create temp dir");
+        let probe_log = temp.path().join("git-probes.log");
+        let (_dir, script) =
+            create_git_probe_script("git version 6.6.6-test", "", 0, Some(&probe_log));
+        let _restore =
+            GitRuntimePreferenceResetGuard::install(GitExecutablePreference::Custom(script));
+
+        assert_eq!(git_runtime_probe_count(&probe_log), 1);
+        let refreshed = refresh_git_runtime();
+
+        assert!(refreshed.is_available());
+        assert_eq!(refreshed.version_output(), Some("git version 6.6.6-test"));
+        assert_eq!(
+            git_runtime_probe_count(&probe_log),
+            2,
+            "refresh should re-run the current runtime probe"
+        );
+    }
+
+    #[test]
+    fn bytes_to_text_preserving_utf8_preserves_valid_utf8() {
+        assert_eq!(
+            bytes_to_text_preserving_utf8("cafe 日本語".as_bytes()),
+            "cafe 日本語"
+        );
+    }
+
+    #[test]
+    fn bytes_to_text_preserving_utf8_escapes_invalid_bytes_without_losing_valid_segments() {
+        let input = b"ok\xffstill-valid\xc3\xa9\x80done";
+        assert_eq!(
+            bytes_to_text_preserving_utf8(input),
+            "ok\\xffstill-validé\\x80done"
+        );
+    }
+
+    #[test]
+    fn bytes_to_text_preserving_utf8_escapes_truncated_multibyte_tail() {
+        assert_eq!(
+            bytes_to_text_preserving_utf8(b"snowman \xe2\x98"),
+            "snowman \\xe2\\x98"
+        );
+    }
+
+    #[test]
+    fn bytes_to_text_preserving_utf8_handles_empty_input() {
+        assert_eq!(bytes_to_text_preserving_utf8(b""), "");
     }
 }
diff --git a/crates/gitcomet-git-gix/src/repo/status.rs b/crates/gitcomet-git-gix/src/repo/status.rs
index 9cec619e..b2c17365 100644
--- a/crates/gitcomet-git-gix/src/repo/status.rs
+++ b/crates/gitcomet-git-gix/src/repo/status.rs
@@ -1090,14 +1090,171 @@ fn supplement_gitlink_status_from_porcelain(
 mod tests {
     use super::{
         apply_porcelain_v2_gitlink_status_record, collect_unmerged_conflicts,
-        conflict_kind_from_stage_mask, map_directory_entry_status,
-        should_supplement_unmerged_conflicts, tree_id_for_commit,
+        conflict_kind_from_stage_mask, map_directory_entry_status, map_entry_status,
+        map_porcelain_v2_status_char, remove_conflicted_paths_from_staged,
+        should_supplement_unmerged_conflicts, sort_and_dedup_status_entries, tree_id_for_commit,
     };
-    use gitcomet_core::domain::{FileConflictKind, FileStatusKind};
+    use gitcomet_core::domain::{FileConflictKind, FileStatus, FileStatusKind};
     use rustc_hash::FxHashMap as HashMap;
     use std::fs;
-    use std::path::PathBuf;
-    use std::process::Command;
+    use std::path::{Path, PathBuf};
+    use std::process::{Command, Output};
+    use std::sync::OnceLock;
+
+    #[cfg(unix)]
+    use std::{fs::Permissions, os::unix::fs::PermissionsExt as _};
+
+    struct TestGitEnv {
+        _root: tempfile::TempDir,
+        global_config: PathBuf,
+        home_dir: PathBuf,
+        xdg_config_home: PathBuf,
+        gnupg_home: PathBuf,
+    }
+
+    fn ensure_isolated_git_test_env() -> &'static TestGitEnv {
+        static ENV: OnceLock = OnceLock::new();
+        ENV.get_or_init(|| {
+            let root = tempfile::tempdir().expect("test git env tempdir");
+            let home_dir = root.path().join("home");
+            let xdg_config_home = root.path().join("xdg");
+            let gnupg_home = root.path().join("gnupg");
+            let global_config = root.path().join("gitconfig");
+
+            fs::create_dir_all(&home_dir).expect("test git home");
+            fs::create_dir_all(&xdg_config_home).expect("test git xdg config home");
+            fs::create_dir_all(&gnupg_home).expect("test gnupg home");
+            fs::write(&global_config, "").expect("test global git config");
+
+            #[cfg(unix)]
+            fs::set_permissions(&gnupg_home, Permissions::from_mode(0o700))
+                .expect("test gnupg home permissions");
+
+            crate::install_test_git_command_environment(
+                global_config.clone(),
+                home_dir.clone(),
+                xdg_config_home.clone(),
+                gnupg_home.clone(),
+            );
+
+            TestGitEnv {
+                _root: root,
+                global_config,
+                home_dir,
+                xdg_config_home,
+                gnupg_home,
+            }
+        })
+    }
+
+    fn git_command() -> Command {
+        let env = ensure_isolated_git_test_env();
+        let mut cmd = Command::new("git");
+        cmd.env("GIT_CONFIG_NOSYSTEM", "1");
+        cmd.env("GIT_CONFIG_GLOBAL", &env.global_config);
+        cmd.env("HOME", &env.home_dir);
+        cmd.env("XDG_CONFIG_HOME", &env.xdg_config_home);
+        cmd.env("GNUPGHOME", &env.gnupg_home);
+        cmd.env("GIT_TERMINAL_PROMPT", "0");
+        cmd.env("GCM_INTERACTIVE", "Never");
+        cmd.env("GIT_ALLOW_PROTOCOL", "file");
+        cmd
+    }
+
+    fn git_output(workdir: &Path, args: &[&str]) -> Output {
+        git_command()
+            .arg("-C")
+            .arg(workdir)
+            .args(args)
+            .output()
+            .expect("spawn git")
+    }
+
+    fn git_success(workdir: &Path, args: &[&str]) {
+        let output = git_output(workdir, args);
+        assert!(
+            output.status.success(),
+            "git {:?} failed\nstdout:\n{}\nstderr:\n{}",
+            args,
+            String::from_utf8_lossy(&output.stdout),
+            String::from_utf8_lossy(&output.stderr)
+        );
+    }
+
+    fn git_expect_failure(workdir: &Path, args: &[&str]) -> Output {
+        let output = git_output(workdir, args);
+        assert!(
+            !output.status.success(),
+            "expected git {:?} to fail\nstdout:\n{}\nstderr:\n{}",
+            args,
+            String::from_utf8_lossy(&output.stdout),
+            String::from_utf8_lossy(&output.stderr)
+        );
+        output
+    }
+
+    fn init_test_repo(workdir: &Path) {
+        let _ = ensure_isolated_git_test_env();
+        git_success(workdir, &["init"]);
+        for args in [
+            ["config", "core.autocrlf", "false"].as_slice(),
+            ["config", "core.eol", "lf"].as_slice(),
+            ["config", "credential.helper", ""].as_slice(),
+            ["config", "credential.interactive", "never"].as_slice(),
+            ["config", "protocol.file.allow", "always"].as_slice(),
+            ["config", "commit.gpgsign", "false"].as_slice(),
+            ["config", "user.name", "Test User"].as_slice(),
+            ["config", "user.email", "test@example.com"].as_slice(),
+        ] {
+            git_success(workdir, args);
+        }
+    }
+
+    fn write_file(workdir: &Path, relative: &str, contents: &str) {
+        let path = workdir.join(relative);
+        if let Some(parent) = path.parent() {
+            fs::create_dir_all(parent).expect("create parent directories");
+        }
+        fs::write(path, contents).expect("write file");
+    }
+
+    fn open_repo(workdir: &Path) -> super::super::GixRepo {
+        let thread_safe_repo = gix::open(workdir).expect("open repo").into_sync();
+        super::super::GixRepo::new(workdir.to_path_buf(), thread_safe_repo)
+    }
+
+    fn file_status(path: &str, kind: FileStatusKind) -> FileStatus {
+        FileStatus {
+            path: PathBuf::from(path),
+            kind,
+            conflict: None,
+        }
+    }
+
+    fn conflicted_file_status(path: &str, conflict: FileConflictKind) -> FileStatus {
+        FileStatus {
+            path: PathBuf::from(path),
+            kind: FileStatusKind::Conflicted,
+            conflict: Some(conflict),
+        }
+    }
+
+    fn setup_both_modified_text_conflict(workdir: &Path, path: &str) {
+        init_test_repo(workdir);
+        write_file(workdir, path, "base\n");
+        git_success(workdir, &["add", path]);
+        git_success(workdir, &["commit", "-m", "base"]);
+
+        git_success(workdir, &["checkout", "-b", "feature"]);
+        write_file(workdir, path, "theirs\n");
+        git_success(workdir, &["commit", "-am", "theirs"]);
+
+        git_success(workdir, &["checkout", "-"]);
+        write_file(workdir, path, "ours\n");
+        git_success(workdir, &["commit", "-am", "ours"]);
+
+        let _ = git_expect_failure(workdir, &["merge", "feature"]);
+    }
 
     #[test]
     fn conflict_kind_from_stage_mask_covers_all_shapes() {
@@ -1200,6 +1357,63 @@ mod tests {
         );
     }
 
+    #[test]
+    fn sort_and_dedup_status_entries_prefers_highest_priority_kind_per_path() {
+        let mut entries = vec![
+            file_status("b.txt", FileStatusKind::Modified),
+            file_status("a.txt", FileStatusKind::Untracked),
+            file_status("a.txt", FileStatusKind::Deleted),
+            file_status("c.txt", FileStatusKind::Modified),
+            file_status("c.txt", FileStatusKind::Added),
+            file_status("d.txt", FileStatusKind::Modified),
+            file_status("d.txt", FileStatusKind::Renamed),
+        ];
+
+        sort_and_dedup_status_entries(&mut entries);
+
+        assert_eq!(
+            entries,
+            vec![
+                file_status("a.txt", FileStatusKind::Deleted),
+                file_status("b.txt", FileStatusKind::Modified),
+                file_status("c.txt", FileStatusKind::Added),
+                file_status("d.txt", FileStatusKind::Renamed),
+            ]
+        );
+    }
+
+    #[test]
+    fn remove_conflicted_paths_from_staged_ignores_empty_input() {
+        let expected = vec![file_status("a.txt", FileStatusKind::Modified)];
+        let mut staged = expected.clone();
+
+        remove_conflicted_paths_from_staged(&mut staged, std::iter::empty::());
+
+        assert_eq!(staged, expected);
+    }
+
+    #[test]
+    fn remove_conflicted_paths_from_staged_removes_only_matching_paths() {
+        let mut staged = vec![
+            file_status("a.txt", FileStatusKind::Modified),
+            file_status("b.txt", FileStatusKind::Added),
+            file_status("c.txt", FileStatusKind::Deleted),
+        ];
+
+        remove_conflicted_paths_from_staged(
+            &mut staged,
+            [PathBuf::from("b.txt"), PathBuf::from("missing.txt")],
+        );
+
+        assert_eq!(
+            staged,
+            vec![
+                file_status("a.txt", FileStatusKind::Modified),
+                file_status("c.txt", FileStatusKind::Deleted),
+            ]
+        );
+    }
+
     #[test]
     fn map_directory_entry_status_only_reports_untracked_entries() {
         use gix::dir::entry::Status;
@@ -1220,6 +1434,83 @@ mod tests {
         assert_eq!(map_directory_entry_status(Status::Pruned), None);
     }
 
+    #[test]
+    fn map_entry_status_maps_all_conflict_summaries() {
+        use gix::status::plumbing::index_as_worktree::{Conflict, EntryStatus};
+
+        for (summary, expected) in [
+            (Conflict::BothDeleted, FileConflictKind::BothDeleted),
+            (Conflict::AddedByUs, FileConflictKind::AddedByUs),
+            (Conflict::DeletedByThem, FileConflictKind::DeletedByThem),
+            (Conflict::AddedByThem, FileConflictKind::AddedByThem),
+            (Conflict::DeletedByUs, FileConflictKind::DeletedByUs),
+            (Conflict::BothAdded, FileConflictKind::BothAdded),
+            (Conflict::BothModified, FileConflictKind::BothModified),
+        ] {
+            assert_eq!(
+                map_entry_status::<(), ()>(EntryStatus::Conflict {
+                    summary,
+                    entries: Box::new([None, None, None]),
+                }),
+                (FileStatusKind::Conflicted, Some(expected))
+            );
+        }
+    }
+
+    #[test]
+    fn map_entry_status_maps_non_conflict_variants() {
+        use gix::status::plumbing::index_as_worktree::{Change, EntryStatus};
+
+        assert_eq!(
+            map_entry_status::<(), ()>(EntryStatus::IntentToAdd),
+            (FileStatusKind::Added, None)
+        );
+        assert_eq!(
+            map_entry_status::<(), ()>(
+                EntryStatus::NeedsUpdate(gix::index::entry::Stat::default())
+            ),
+            (FileStatusKind::Modified, None)
+        );
+        assert_eq!(
+            map_entry_status::<(), ()>(EntryStatus::Change(Change::Removed)),
+            (FileStatusKind::Deleted, None)
+        );
+        assert_eq!(
+            map_entry_status::<(), ()>(EntryStatus::Change(Change::Type {
+                worktree_mode: gix::index::entry::Mode::FILE,
+            })),
+            (FileStatusKind::Modified, None)
+        );
+        assert_eq!(
+            map_entry_status::<(), ()>(EntryStatus::Change(Change::Modification {
+                executable_bit_changed: false,
+                content_change: None,
+                set_entry_stat_size_zero: false,
+            })),
+            (FileStatusKind::Modified, None)
+        );
+        assert_eq!(
+            map_entry_status::<(), ()>(EntryStatus::Change(Change::SubmoduleModification(()))),
+            (FileStatusKind::Modified, None)
+        );
+    }
+
+    #[test]
+    fn map_porcelain_v2_status_char_maps_supported_values() {
+        for (ch, expected) in [
+            ('M', Some(FileStatusKind::Modified)),
+            ('T', Some(FileStatusKind::Modified)),
+            ('A', Some(FileStatusKind::Added)),
+            ('D', Some(FileStatusKind::Deleted)),
+            ('R', Some(FileStatusKind::Renamed)),
+            ('U', Some(FileStatusKind::Conflicted)),
+            ('.', None),
+            ('?', None),
+        ] {
+            assert_eq!(map_porcelain_v2_status_char(ch), expected);
+        }
+    }
+
     #[test]
     fn supplement_unmerged_conflicts_runs_for_in_progress_repo() {
         assert!(should_supplement_unmerged_conflicts(true, false));
@@ -1252,6 +1543,27 @@ mod tests {
         assert_eq!(unstaged[0].kind, FileStatusKind::Modified);
     }
 
+    #[test]
+    fn porcelain_gitlink_record_maps_conflicted_status_chars_to_both_lanes() {
+        let mut staged = Vec::new();
+        let mut unstaged = Vec::new();
+        apply_porcelain_v2_gitlink_status_record(
+            b"1 UU SC.. 160000 160000 160000 1111111111111111111111111111111111111111 2222222222222222222222222222222222222222 chess3",
+            &mut staged,
+            &mut unstaged,
+        )
+        .unwrap();
+
+        assert_eq!(
+            staged,
+            vec![file_status("chess3", FileStatusKind::Conflicted)]
+        );
+        assert_eq!(
+            unstaged,
+            vec![file_status("chess3", FileStatusKind::Conflicted)]
+        );
+    }
+
     #[test]
     fn porcelain_gitlink_record_maps_added_and_unstaged_modified() {
         let mut staged = Vec::new();
@@ -1272,6 +1584,51 @@ mod tests {
         assert_eq!(unstaged[0].kind, FileStatusKind::Modified);
     }
 
+    #[test]
+    fn porcelain_gitlink_record_ignores_non_type_one_records() {
+        let mut staged = Vec::new();
+        let mut unstaged = Vec::new();
+        apply_porcelain_v2_gitlink_status_record(
+            b"2 R. N... 160000 160000 160000 111 222 chess3",
+            &mut staged,
+            &mut unstaged,
+        )
+        .unwrap();
+
+        assert!(staged.is_empty());
+        assert!(unstaged.is_empty());
+    }
+
+    #[test]
+    fn porcelain_gitlink_record_ignores_non_gitlink_modes() {
+        let mut staged = Vec::new();
+        let mut unstaged = Vec::new();
+        apply_porcelain_v2_gitlink_status_record(
+            b"1 M. N... 100644 100644 100644 111 222 chess3",
+            &mut staged,
+            &mut unstaged,
+        )
+        .unwrap();
+
+        assert!(staged.is_empty());
+        assert!(unstaged.is_empty());
+    }
+
+    #[test]
+    fn porcelain_gitlink_record_ignores_missing_path() {
+        let mut staged = Vec::new();
+        let mut unstaged = Vec::new();
+        apply_porcelain_v2_gitlink_status_record(
+            b"1 M. SC.. 160000 160000 160000 111 222 ",
+            &mut staged,
+            &mut unstaged,
+        )
+        .unwrap();
+
+        assert!(staged.is_empty());
+        assert!(unstaged.is_empty());
+    }
+
     #[test]
     fn porcelain_gitlink_record_preserves_spaces_in_path() {
         let mut staged = Vec::new();
@@ -1306,19 +1663,80 @@ mod tests {
         assert_eq!(unstaged[0].kind, FileStatusKind::Modified);
     }
 
-    fn git_success(workdir: &std::path::Path, args: &[&str]) {
-        let output = Command::new("git")
-            .arg("-C")
-            .arg(workdir)
-            .args(args)
-            .output()
-            .expect("spawn git");
-        assert!(
-            output.status.success(),
-            "git {:?} failed\nstdout:\n{}\nstderr:\n{}",
-            args,
-            String::from_utf8_lossy(&output.stdout),
-            String::from_utf8_lossy(&output.stderr)
+    #[test]
+    fn status_impl_matches_lane_specific_statuses() {
+        let tmp = tempfile::tempdir().expect("tempdir");
+        let workdir = tmp.path();
+        init_test_repo(workdir);
+
+        write_file(workdir, "staged.txt", "base\n");
+        write_file(workdir, "unstaged.txt", "base\n");
+        git_success(workdir, &["add", "staged.txt", "unstaged.txt"]);
+        git_success(workdir, &["commit", "-m", "initial"]);
+
+        write_file(workdir, "staged.txt", "staged change\n");
+        git_success(workdir, &["add", "staged.txt"]);
+        write_file(workdir, "unstaged.txt", "unstaged change\n");
+        write_file(workdir, "untracked.txt", "untracked\n");
+
+        let gix_repo = open_repo(workdir);
+        let combined = gix_repo.status_impl().expect("combined status");
+        let staged = gix_repo.staged_status_impl().expect("staged status");
+        let unstaged = gix_repo.worktree_status_impl().expect("worktree status");
+
+        assert_eq!(combined.staged, staged);
+        assert_eq!(combined.unstaged, unstaged);
+        assert_eq!(
+            staged,
+            vec![file_status("staged.txt", FileStatusKind::Modified)]
+        );
+        assert_eq!(
+            unstaged,
+            vec![
+                file_status("unstaged.txt", FileStatusKind::Modified),
+                file_status("untracked.txt", FileStatusKind::Untracked),
+            ]
+        );
+    }
+
+    #[test]
+    fn staged_status_impl_on_unborn_head_uses_combined_status() {
+        let tmp = tempfile::tempdir().expect("tempdir");
+        let workdir = tmp.path();
+        init_test_repo(workdir);
+
+        write_file(workdir, "new.txt", "new\n");
+        git_success(workdir, &["add", "new.txt"]);
+
+        let gix_repo = open_repo(workdir);
+        let combined = gix_repo.status_impl().expect("combined status");
+        let staged = gix_repo.staged_status_impl().expect("staged status");
+
+        assert_eq!(combined.staged, staged);
+        assert_eq!(staged, vec![file_status("new.txt", FileStatusKind::Added)]);
+        assert!(combined.unstaged.is_empty());
+    }
+
+    #[test]
+    fn status_impl_removes_conflicted_paths_from_staged_lane() {
+        let tmp = tempfile::tempdir().expect("tempdir");
+        let workdir = tmp.path();
+        setup_both_modified_text_conflict(workdir, "tracked.txt");
+
+        let gix_repo = open_repo(workdir);
+        let combined = gix_repo.status_impl().expect("combined status");
+        let staged = gix_repo.staged_status_impl().expect("staged status");
+        let worktree = gix_repo.worktree_status_impl().expect("worktree status");
+
+        assert!(combined.staged.is_empty());
+        assert!(staged.is_empty());
+        assert_eq!(combined.unstaged, worktree);
+        assert_eq!(
+            worktree,
+            vec![conflicted_file_status(
+                "tracked.txt",
+                FileConflictKind::BothModified,
+            )]
         );
     }
 
@@ -1326,9 +1744,7 @@ mod tests {
     fn staged_status_impl_resolves_head_to_tree_before_tree_index_diff() {
         let tmp = tempfile::tempdir().expect("tempdir");
         let workdir = tmp.path();
-        git_success(workdir, &["init"]);
-        git_success(workdir, &["config", "user.name", "Test User"]);
-        git_success(workdir, &["config", "user.email", "test@example.com"]);
+        init_test_repo(workdir);
 
         fs::write(workdir.join("tracked.txt"), "base\n").expect("write tracked file");
         git_success(workdir, &["add", "tracked.txt"]);
diff --git a/crates/gitcomet-ui-gpui/src/view/diff_utils.rs b/crates/gitcomet-ui-gpui/src/view/diff_utils.rs
index f4fa03a9..302395ad 100644
--- a/crates/gitcomet-ui-gpui/src/view/diff_utils.rs
+++ b/crates/gitcomet-ui-gpui/src/view/diff_utils.rs
@@ -133,6 +133,10 @@ const SVG_PREVIEW_MAX_RASTER_EDGE_PX: f32 = 4096.0;
 static SVG_PREVIEW_USVG_OPTIONS: std::sync::LazyLock> =
     std::sync::LazyLock::new(resvg::usvg::Options::default);
 
+pub(in crate::view) fn fill_svg_viewport_white(pixmap: &mut resvg::tiny_skia::Pixmap) {
+    pixmap.fill(resvg::tiny_skia::Color::WHITE);
+}
+
 pub(in crate::view) fn rasterize_svg_png(
     svg_bytes: &[u8],
     target_width_px: f32,
@@ -164,6 +168,7 @@ pub(in crate::view) fn rasterize_svg_png(
     let raster_height = raster_height.max(1.0) as u32;
 
     let mut pixmap = resvg::tiny_skia::Pixmap::new(raster_width, raster_height)?;
+    fill_svg_viewport_white(&mut pixmap);
     let transform = resvg::tiny_skia::Transform::from_scale(
         raster_width as f32 / svg_width,
         raster_height as f32 / svg_height,
@@ -1099,6 +1104,26 @@ mod tests {
         assert_eq!(height, (SVG_PREVIEW_MIN_RASTER_WIDTH_PX / 2.0) as u32);
     }
 
+    #[test]
+    fn rasterize_svg_preview_png_fills_transparent_viewport_white() {
+        let svg = br##"
+
+"##;
+
+        let png = rasterize_svg_preview_png(svg).expect("svg should rasterize");
+        let decoded = image::load_from_memory_with_format(&png, image::ImageFormat::Png)
+            .expect("decode png")
+            .into_rgba8();
+
+        assert_eq!(decoded.get_pixel(0, 0).0, [255, 255, 255, 255]);
+        assert_eq!(
+            decoded
+                .get_pixel(decoded.width() / 2, decoded.height() / 2)
+                .0,
+            [0, 170, 255, 255]
+        );
+    }
+
     #[test]
     fn context_menu_selection_range_from_diff_text_requires_click_in_selection() {
         let selection = Some((
diff --git a/crates/gitcomet-ui-gpui/src/view/panes/main/diff_cache/image_cache.rs b/crates/gitcomet-ui-gpui/src/view/panes/main/diff_cache/image_cache.rs
index f5df3fa1..88e0031d 100644
--- a/crates/gitcomet-ui-gpui/src/view/panes/main/diff_cache/image_cache.rs
+++ b/crates/gitcomet-ui-gpui/src/view/panes/main/diff_cache/image_cache.rs
@@ -1,5 +1,5 @@
 use super::*;
-use crate::view::diff_utils::image_format_for_path;
+use crate::view::diff_utils::{fill_svg_viewport_white, image_format_for_path};
 use std::hash::Hash;
 use std::hash::Hasher;
 use std::sync::atomic::{AtomicUsize, Ordering};
@@ -200,6 +200,7 @@ pub(in crate::view) fn render_svg_image_diff_preview(
     let raster_width = raster_width.max(1.0) as u32;
     let raster_height = raster_height.max(1.0) as u32;
     let mut pixmap = resvg::tiny_skia::Pixmap::new(raster_width, raster_height)?;
+    fill_svg_viewport_white(&mut pixmap);
     let transform = resvg::tiny_skia::Transform::from_scale(
         raster_width as f32 / svg_width,
         raster_height as f32 / svg_height,
@@ -545,6 +546,30 @@ mod tests {
         .into_bytes()
     }
 
+    fn inset_rect_svg(width: u32, height: u32, inset_x: u32, inset_y: u32) -> Vec {
+        let inner_width = width.saturating_sub(inset_x.saturating_mul(2));
+        let inner_height = height.saturating_sub(inset_y.saturating_mul(2));
+        format!(
+            r##"
+
+"##
+        )
+        .into_bytes()
+    }
+
+    fn render_pixel_bgra(render: &gpui::RenderImage, x: usize, y: usize) -> [u8; 4] {
+        let size = render.size(0);
+        let width = size.width.0 as usize;
+        let offset = (y.saturating_mul(width).saturating_add(x)).saturating_mul(4);
+        let bytes = render.as_bytes(0).expect("render bytes");
+        [
+            bytes[offset],
+            bytes[offset + 1],
+            bytes[offset + 2],
+            bytes[offset + 3],
+        ]
+    }
+
     fn write_test_file(dir: &Path, name: &str, bytes: &[u8]) -> std::path::PathBuf {
         let path = dir.join(name);
         std::fs::write(&path, bytes).expect("write test file");
@@ -626,6 +651,23 @@ mod tests {
         assert!(preview.cached_path.is_none());
     }
 
+    #[test]
+    fn render_svg_image_diff_preview_fills_transparent_viewport_white() {
+        let svg = inset_rect_svg(4, 4, 1, 1);
+        let render = render_svg_image_diff_preview(&svg).expect("svg render image");
+        let size = render.size(0);
+
+        assert_eq!(render_pixel_bgra(&render, 0, 0), [255, 255, 255, 255]);
+        assert_eq!(
+            render_pixel_bgra(
+                &render,
+                (size.width.0 as usize) / 2,
+                (size.height.0 as usize) / 2,
+            ),
+            [255, 170, 0, 255]
+        );
+    }
+
     #[test]
     fn decode_file_image_diff_preview_side_upscales_small_svg_to_target_width() {
         let svg = solid_rect_svg(32, 16);

From 6c68859fe217724333eee4256b1e2c26e46f1763 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Sampo=20Kivist=C3=B6?= 
Date: Mon, 13 Apr 2026 15:01:16 +0300
Subject: [PATCH 8/8] fix an issue where text input crashes when focus is out
 of sync

---
 crates/gitcomet-core/src/merge_extraction.rs  | 41 ++++++++++++++
 crates/gitcomet-ui-gpui/src/kit/text_input.rs | 15 ++++-
 crates/gitcomet-ui-gpui/src/smoke_tests.rs    | 23 ++++++++
 .../src/view/panels/tests/file_status.rs      | 56 +++++++++++++++++++
 4 files changed, 134 insertions(+), 1 deletion(-)

diff --git a/crates/gitcomet-core/src/merge_extraction.rs b/crates/gitcomet-core/src/merge_extraction.rs
index 03884538..9f996cad 100644
--- a/crates/gitcomet-core/src/merge_extraction.rs
+++ b/crates/gitcomet-core/src/merge_extraction.rs
@@ -598,9 +598,38 @@ fn sanitize_fixture_component(path: &str) -> String {
 #[cfg(test)]
 mod tests {
     use super::*;
+    use crate::process::{
+        GitExecutablePreference, current_git_executable_preference,
+        install_git_executable_preference, lock_git_runtime_test,
+    };
     #[cfg(windows)]
     use std::sync::OnceLock;
 
+    // Keep merge-extraction tests isolated from process::tests, which mutate
+    // the shared git executable preference under this same mutex.
+    struct SystemGitRuntimeGuard {
+        _lock: std::sync::MutexGuard<'static, ()>,
+        original: GitExecutablePreference,
+    }
+
+    impl SystemGitRuntimeGuard {
+        fn new() -> Self {
+            let lock = lock_git_runtime_test();
+            let original = current_git_executable_preference();
+            let _ = install_git_executable_preference(GitExecutablePreference::SystemPath);
+            Self {
+                _lock: lock,
+                original,
+            }
+        }
+    }
+
+    impl Drop for SystemGitRuntimeGuard {
+        fn drop(&mut self) {
+            let _ = install_git_executable_preference(self.original.clone());
+        }
+    }
+
     #[cfg(windows)]
     fn is_git_shell_startup_failure(text: &str) -> bool {
         text.contains("sh.exe: *** fatal error -")
@@ -761,6 +790,7 @@ mod tests {
 
     #[test]
     fn discovers_merge_commits_with_two_parents() {
+        let _git_runtime = SystemGitRuntimeGuard::new();
         let repo = create_conflicting_merge_repo();
         let merges = discover_merge_commits(repo.path(), 10).expect("discover merges");
         assert_eq!(merges.len(), 1, "expected one merge commit");
@@ -773,6 +803,7 @@ mod tests {
 
     #[test]
     fn discovers_merge_commits_from_repo_subdirectory() {
+        let _git_runtime = SystemGitRuntimeGuard::new();
         let repo = create_conflicting_merge_repo();
         let subdir = repo.path().join("nested").join("dir");
         std::fs::create_dir_all(&subdir).expect("create nested subdirectory");
@@ -787,6 +818,7 @@ mod tests {
 
     #[test]
     fn discover_merge_commits_zero_max_merges_errors() {
+        let _git_runtime = SystemGitRuntimeGuard::new();
         let repo = create_conflicting_merge_repo();
         let error =
             discover_merge_commits(repo.path(), 0).expect_err("expected invalid argument error");
@@ -802,6 +834,7 @@ mod tests {
 
     #[test]
     fn discover_merge_commits_reports_not_git_repository_stderr() {
+        let _git_runtime = SystemGitRuntimeGuard::new();
         let tmp = tempfile::tempdir().expect("create temp dir");
         let repo = tmp.path();
         let error =
@@ -821,6 +854,7 @@ mod tests {
 
     #[test]
     fn discovers_merge_commits_after_recent_octopus_merges() {
+        let _git_runtime = SystemGitRuntimeGuard::new();
         if !require_git_shell_for_octopus_merge_tests() {
             return;
         }
@@ -912,6 +946,7 @@ mod tests {
 
     #[test]
     fn extracts_sorted_text_cases_and_skips_binary() {
+        let _git_runtime = SystemGitRuntimeGuard::new();
         let repo = create_conflicting_merge_repo();
         let merges = discover_merge_commits(repo.path(), 10).expect("discover merges");
         let cases = extract_merge_cases(repo.path(), &merges[0], 10).expect("extract cases");
@@ -934,6 +969,7 @@ mod tests {
     #[test]
     #[cfg(not(windows))]
     fn changed_files_handles_paths_with_newlines() {
+        let _git_runtime = SystemGitRuntimeGuard::new();
         let tmp = tempfile::tempdir().expect("create temp dir");
         let repo = tmp.path();
 
@@ -966,6 +1002,7 @@ mod tests {
 
     #[test]
     fn extracts_cases_with_missing_base_or_parent_as_empty_text() {
+        let _git_runtime = SystemGitRuntimeGuard::new();
         let repo = create_missing_side_conflict_merge_repo();
         let merges = discover_merge_commits(repo.path(), 10).expect("discover merges");
         assert_eq!(merges.len(), 1, "expected one merge commit");
@@ -1024,6 +1061,7 @@ mod tests {
 
     #[test]
     fn git_show_missing_path_is_treated_as_absent_side() {
+        let _git_runtime = SystemGitRuntimeGuard::new();
         let repo = create_conflicting_merge_repo();
         let missing = read_blob_bytes_optional(repo.path(), "HEAD", "this-file-does-not-exist")
             .expect("missing path should not error");
@@ -1035,6 +1073,7 @@ mod tests {
 
     #[test]
     fn git_show_non_missing_errors_are_propagated() {
+        let _git_runtime = SystemGitRuntimeGuard::new();
         let repo = create_conflicting_merge_repo();
         let err = read_blob_bytes_optional(repo.path(), "definitely-not-a-ref", "a.txt")
             .expect_err("invalid ref should surface as git command failure");
@@ -1056,6 +1095,7 @@ mod tests {
 
     #[test]
     fn read_blob_bytes_optional_reads_existing_blob_content() {
+        let _git_runtime = SystemGitRuntimeGuard::new();
         let repo = create_conflicting_merge_repo();
         let content =
             read_blob_bytes_optional(repo.path(), "HEAD", "a.txt").expect("read existing path");
@@ -1066,6 +1106,7 @@ mod tests {
 
     #[test]
     fn extracts_merge_cases_from_repo_subdirectory() {
+        let _git_runtime = SystemGitRuntimeGuard::new();
         let repo = create_conflicting_merge_repo();
         let subdir = repo.path().join("nested").join("dir");
         std::fs::create_dir_all(&subdir).expect("create nested subdirectory");
diff --git a/crates/gitcomet-ui-gpui/src/kit/text_input.rs b/crates/gitcomet-ui-gpui/src/kit/text_input.rs
index 02af0a55..add72a8d 100644
--- a/crates/gitcomet-ui-gpui/src/kit/text_input.rs
+++ b/crates/gitcomet-ui-gpui/src/kit/text_input.rs
@@ -3631,6 +3631,7 @@ impl Render for TextInput {
     fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement {
         let style = self.style;
         let focus = self.focus_handle.clone();
+        let entity_id = cx.entity().entity_id();
         let chromeless = self.chromeless;
         let multiline = self.multiline;
         let pad_x = if chromeless { px(0.0) } else { px(8.0) };
@@ -3763,7 +3764,19 @@ impl Render for TextInput {
             input = input.focus(move |s| s.border_color(style.focus_border));
         }
 
-        let mut outer = div().w_full().min_w(px(0.0)).flex().flex_col().child(input);
+        let render_id = ElementId::from(("text_input_root", entity_id));
+        let render_id =
+            ElementId::from((render_id, if is_focused { "focused" } else { "blurred" }));
+        let mut outer = div()
+            // Focus changes toggle GPUI platform input handler registration during paint.
+            // Key the subtree by focus state so GPUI doesn't reuse a stale unfocused paint
+            // range that contains no input handlers when the field becomes focused.
+            .id(render_id)
+            .w_full()
+            .min_w(px(0.0))
+            .flex()
+            .flex_col()
+            .child(input);
 
         if let Some(state) = self.context_menu {
             outer = outer.child(
diff --git a/crates/gitcomet-ui-gpui/src/smoke_tests.rs b/crates/gitcomet-ui-gpui/src/smoke_tests.rs
index 56a1fe95..5621eba1 100644
--- a/crates/gitcomet-ui-gpui/src/smoke_tests.rs
+++ b/crates/gitcomet-ui-gpui/src/smoke_tests.rs
@@ -351,6 +351,29 @@ fn text_input_constructs_without_panicking(cx: &mut gpui::TestAppContext) {
     });
 }
 
+#[gpui::test]
+fn text_input_focus_after_initial_draw_accepts_typed_input(cx: &mut gpui::TestAppContext) {
+    let (view, cx) = cx.add_window_view(TextInputHostView::new);
+
+    cx.update(|window, app| {
+        let _ = window.draw(app);
+    });
+
+    cx.update(|window, app| {
+        let focus = view.update(app, |this, cx| this.input.read(cx).focus_handle());
+        window.focus(&focus, app);
+        let _ = window.draw(app);
+    });
+
+    cx.simulate_input("x");
+
+    let text = cx.update(|window, app| {
+        let _ = window.draw(app);
+        view.read(app).input.read(app).text().to_string()
+    });
+    assert_eq!(text, "x");
+}
+
 #[gpui::test]
 fn text_input_supports_basic_clipboard_and_word_shortcuts(cx: &mut gpui::TestAppContext) {
     let _clipboard_guard = lock_clipboard_test();
diff --git a/crates/gitcomet-ui-gpui/src/view/panels/tests/file_status.rs b/crates/gitcomet-ui-gpui/src/view/panels/tests/file_status.rs
index 3c343fc1..8585ea59 100644
--- a/crates/gitcomet-ui-gpui/src/view/panels/tests/file_status.rs
+++ b/crates/gitcomet-ui-gpui/src/view/panels/tests/file_status.rs
@@ -1783,6 +1783,62 @@ fn merge_start_prefills_default_commit_message(cx: &mut gpui::TestAppContext) {
     });
 }
 
+#[gpui::test]
+fn commit_message_focus_after_initial_draw_accepts_typed_input(cx: &mut gpui::TestAppContext) {
+    let (store, events) = AppStore::new(Arc::new(TestBackend));
+    let (view, cx) = cx.add_window_view(|window, cx| {
+        super::super::GitCometView::new(store, events, None, window, cx)
+    });
+
+    let repo_id = gitcomet_state::model::RepoId(44);
+    let make_state = || {
+        let mut repo = opening_repo_state(repo_id, Path::new("/tmp/repo-commit-message-focus"));
+        repo.status = gitcomet_state::model::Loadable::Ready(
+            gitcomet_core::domain::RepoStatus {
+                staged: vec![gitcomet_core::domain::FileStatus {
+                    path: std::path::PathBuf::from("staged.txt"),
+                    kind: gitcomet_core::domain::FileStatusKind::Modified,
+                    conflict: None,
+                }],
+                unstaged: Vec::new(),
+            }
+            .into(),
+        );
+        app_state_with_repo(repo, repo_id)
+    };
+
+    cx.update(|window, app| {
+        view.update(app, |this, cx| {
+            push_test_state(this, make_state(), cx);
+        });
+        let _ = window.draw(app);
+    });
+
+    cx.update(|window, app| {
+        view.update(app, |this, cx| {
+            this.details_pane.update(cx, |pane, cx| {
+                let focus = pane.commit_message_input.read(cx).focus_handle();
+                window.focus(&focus, cx);
+            });
+        });
+        let _ = window.draw(app);
+    });
+
+    cx.simulate_input("x");
+
+    let text = cx.update(|window, app| {
+        let _ = window.draw(app);
+        view.read(app)
+            .details_pane
+            .read(app)
+            .commit_message_input
+            .read(app)
+            .text()
+            .to_string()
+    });
+    assert_eq!(text, "x");
+}
+
 #[gpui::test]
 fn commit_click_dispatches_after_state_update_without_intermediate_redraw(
     cx: &mut gpui::TestAppContext,