diff options
Diffstat (limited to 'src/app/git_refresh.rs')
| -rw-r--r-- | src/app/git_refresh.rs | 480 |
1 files changed, 480 insertions, 0 deletions
diff --git a/src/app/git_refresh.rs b/src/app/git_refresh.rs new file mode 100644 index 00000000..a868a0ed --- /dev/null +++ b/src/app/git_refresh.rs @@ -0,0 +1,480 @@ +use std::collections::HashMap; +use std::path::PathBuf; +use std::time::Instant; + +use super::{App, GIT_REMOTE_STATUS_REFRESH_INTERVAL, GIT_REPO_DISCOVERY_REFRESH_INTERVAL}; +use crate::events::AppEvent; +use crate::workspace::{GitStatusCacheEntry, GitStatusRefreshDemand, WorkspaceGitStatus}; + +#[derive(Clone, Debug, PartialEq, Eq)] +struct WorkspaceGitRefreshItem { + workspace_id: String, + resolved_identity_cwd: PathBuf, + cache_key_hint: Option<PathBuf>, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct WorkspaceGitRefreshTarget { + workspace_id: String, + resolved_identity_cwd: PathBuf, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct WorkspaceGitRefreshJob { + cache_key: PathBuf, + cached: Option<GitStatusCacheEntry>, + targets: Vec<WorkspaceGitRefreshTarget>, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct WorkspaceGitRefreshOutput { + results: Vec<WorkspaceGitStatus>, + cache_updates: Vec<(PathBuf, GitStatusCacheEntry)>, +} + +impl App { + pub(crate) fn start_git_status_refresh_if_due(&mut self, now: Instant) { + let Some(deadline) = self.git_refresh_deadline() else { + return; + }; + + if now < deadline { + return; + } + + let refresh_repo_discovery = self.git_identity_refresh_requested + || now.saturating_duration_since(self.last_git_repo_discovery_refresh) + >= GIT_REPO_DISCOVERY_REFRESH_INTERVAL; + let workspaces = self.workspace_git_refresh_items(refresh_repo_discovery); + + if workspaces.is_empty() { + self.last_git_remote_status_refresh = now; + self.git_identity_refresh_requested = false; + return; + } + + self.git_refresh_in_flight = true; + let event_tx = self.event_tx.clone(); + let cache = self.git_status_cache.clone(); + let mut demand = self.git_refresh_demand(); + if self.git_identity_refresh_requested { + demand.branch = true; + } + self.git_identity_refresh_requested = false; + if refresh_repo_discovery { + self.last_git_repo_discovery_refresh = now; + } + std::thread::spawn(move || { + let output = + refresh_workspace_git_statuses_with_cache_and_demand(workspaces, &cache, demand); + let _ = event_tx.blocking_send(AppEvent::GitStatusRefreshed { + results: output.results, + cache_updates: output.cache_updates, + }); + }); + } + + pub(crate) fn request_git_identity_refresh(&mut self, now: Instant) { + self.git_identity_refresh_requested = true; + self.mark_git_status_refresh_due(now); + } + + pub(crate) fn mark_git_status_refresh_due(&mut self, now: Instant) { + self.git_status_cache + .retain(|_, entry| entry.fingerprint.is_some()); + if self.git_refresh_in_flight { + self.git_refresh_due_after_in_flight = true; + return; + } + self.last_git_remote_status_refresh = now + .checked_sub(GIT_REMOTE_STATUS_REFRESH_INTERVAL) + .unwrap_or(now); + self.git_refresh_due_after_in_flight = false; + } + + pub(crate) fn git_refresh_deadline(&self) -> Option<Instant> { + (!self.git_refresh_in_flight + && !self.state.workspaces.is_empty() + && (self.git_identity_refresh_requested || !self.git_refresh_demand().is_empty())) + .then_some(self.last_git_remote_status_refresh + GIT_REMOTE_STATUS_REFRESH_INTERVAL) + } + + fn git_refresh_demand(&self) -> GitStatusRefreshDemand { + let mut demand = GitStatusRefreshDemand::default(); + for token in self.state.sidebar_spaces.rows.iter().flatten() { + match token.parts().0 { + crate::config::SpaceSidebarToken::Branch => demand.branch = true, + crate::config::SpaceSidebarToken::GitStatus => demand.ahead_behind = true, + _ => {} + } + } + demand + } + + fn workspace_git_refresh_items( + &self, + refresh_repo_discovery: bool, + ) -> Vec<WorkspaceGitRefreshItem> { + self.state + .workspaces + .iter() + .filter_map(|ws| { + let cwd = + ws.resolved_identity_cwd_from(&self.state.terminals, &self.terminal_runtimes)?; + let cache_key_hint = (!refresh_repo_discovery && ws.cached_identity_cwd == cwd) + .then(|| ws.cached_git_status_key.clone()); + Some(WorkspaceGitRefreshItem { + workspace_id: ws.id.clone(), + resolved_identity_cwd: cwd, + cache_key_hint, + }) + }) + .collect() + } +} + +fn deduplicate_git_refresh_items( + items: Vec<WorkspaceGitRefreshItem>, + cache: &HashMap<PathBuf, GitStatusCacheEntry>, +) -> Vec<WorkspaceGitRefreshJob> { + let mut indexes = HashMap::<PathBuf, usize>::new(); + let mut jobs = Vec::<WorkspaceGitRefreshJob>::new(); + + for item in items { + let cache_key = item.cache_key_hint.unwrap_or_else(|| { + crate::workspace::git_status_cache_key(&item.resolved_identity_cwd) + .unwrap_or_else(|| item.resolved_identity_cwd.clone()) + }); + let target = WorkspaceGitRefreshTarget { + workspace_id: item.workspace_id, + resolved_identity_cwd: item.resolved_identity_cwd, + }; + if let Some(&index) = indexes.get(&cache_key) { + jobs[index].targets.push(target); + continue; + } + + let cached = cache.get(&cache_key).cloned(); + indexes.insert(cache_key.clone(), jobs.len()); + jobs.push(WorkspaceGitRefreshJob { + cache_key, + cached, + targets: vec![target], + }); + } + + jobs +} + +fn refresh_workspace_git_statuses_with_cache_and_demand( + items: Vec<WorkspaceGitRefreshItem>, + cache: &HashMap<PathBuf, GitStatusCacheEntry>, + demand: GitStatusRefreshDemand, +) -> WorkspaceGitRefreshOutput { + let mut results = Vec::new(); + let mut cache_updates = Vec::new(); + + for job in deduplicate_git_refresh_items(items, cache) { + let (snapshot, cache_entry) = crate::workspace::git_status_snapshot_for_cwd_with_demand( + &job.cache_key, + job.cached.as_ref(), + demand, + ); + if let Some(cache_entry) = cache_entry { + cache_updates.push((job.cache_key.clone(), cache_entry)); + } + results.extend(job.targets.into_iter().map(move |target| { + snapshot.clone().into_workspace_status( + target.workspace_id, + target.resolved_identity_cwd, + job.cache_key.clone(), + demand, + ) + })); + } + + WorkspaceGitRefreshOutput { + results, + cache_updates, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::workspace::Workspace; + + #[test] + fn git_refresh_deduplicates_workspaces_with_same_cache_key() { + let repo = + std::env::temp_dir().join(format!("herdr-git-refresh-dedupe-{}", std::process::id())); + let nested = repo.join("nested"); + let other = repo.join("other"); + std::fs::create_dir_all(&nested).expect("create nested dir"); + std::fs::create_dir_all(&other).expect("create other dir"); + std::process::Command::new("git") + .arg("-C") + .arg(&repo) + .arg("init") + .output() + .expect("run git init"); + + let output = refresh_workspace_git_statuses_with_cache_and_demand( + vec![ + WorkspaceGitRefreshItem { + workspace_id: "one".into(), + resolved_identity_cwd: nested.clone(), + cache_key_hint: None, + }, + WorkspaceGitRefreshItem { + workspace_id: "two".into(), + resolved_identity_cwd: other.clone(), + cache_key_hint: None, + }, + ], + &HashMap::new(), + GitStatusRefreshDemand::ALL, + ); + + assert_eq!(output.cache_updates.len(), 1); + assert_eq!( + output.cache_updates[0].0, + std::fs::canonicalize(&repo).expect("canonical repo path") + ); + assert_eq!(output.results.len(), 2); + assert_eq!(output.results[0].workspace_id, "one"); + assert_eq!(output.results[0].resolved_identity_cwd, nested); + assert_eq!(output.results[1].workspace_id, "two"); + assert_eq!(output.results[1].resolved_identity_cwd, other); + + let _ = std::fs::remove_dir_all(repo); + } + + #[test] + fn git_refresh_item_collection_does_not_discover_uncached_cwd() { + let mut app = test_app(&crate::config::Config::default()); + let cwd = std::env::temp_dir().join(format!("herdr-uncached-cwd-{}", std::process::id())); + let mut ws = Workspace::test_new("test"); + ws.identity_cwd = cwd.clone(); + ws.tabs.clear(); + app.state.workspaces.push(ws); + + let items = app.workspace_git_refresh_items(false); + + assert_eq!(items.len(), 1); + assert_eq!(items[0].resolved_identity_cwd, cwd); + assert_eq!(items[0].cache_key_hint, None); + } + + #[test] + fn git_refresh_item_collection_reuses_matching_cached_key() { + let mut app = test_app(&crate::config::Config::default()); + let cwd = PathBuf::from("/repo/deep/nested"); + let cache_key = PathBuf::from("/repo"); + let mut ws = Workspace::test_new("test"); + ws.identity_cwd = cwd.clone(); + ws.cached_identity_cwd = cwd; + ws.cached_git_status_key = cache_key.clone(); + ws.tabs.clear(); + app.state.workspaces.push(ws); + + let items = app.workspace_git_refresh_items(false); + + assert_eq!(items.len(), 1); + assert_eq!(items[0].cache_key_hint, Some(cache_key)); + } + + #[test] + fn periodic_repo_discovery_ignores_cached_key_hints() { + let mut app = test_app(&crate::config::Config::default()); + let cwd = PathBuf::from("/repo/deep/nested"); + let mut ws = Workspace::test_new("test"); + ws.identity_cwd = cwd.clone(); + ws.cached_identity_cwd = cwd; + ws.cached_git_status_key = PathBuf::from("/repo"); + ws.tabs.clear(); + app.state.workspaces.push(ws); + + let items = app.workspace_git_refresh_items(true); + + assert_eq!(items.len(), 1); + assert_eq!(items[0].cache_key_hint, None); + } + + #[test] + fn cwd_identity_refresh_runs_once_without_sidebar_git_tokens() { + let mut config = crate::config::Config::default(); + config.ui.sidebar.spaces.rows = vec![vec![crate::config::SpaceSidebarToken::Workspace]]; + let mut app = test_app(&config); + app.state.workspaces.push(Workspace::test_new("test")); + let now = Instant::now(); + + app.request_git_identity_refresh(now); + + assert!(app.git_refresh_deadline().is_some()); + app.start_git_status_refresh_if_due(now); + assert!(app.git_refresh_in_flight); + assert!(!app.git_identity_refresh_requested); + } + + #[test] + fn due_git_refresh_does_not_start_without_sidebar_consumer() { + let mut config = crate::config::Config::default(); + config.ui.sidebar.spaces.rows = vec![vec![crate::config::SpaceSidebarToken::Workspace]]; + let mut app = test_app(&config); + app.state.workspaces.push(Workspace::test_new("test")); + let now = Instant::now(); + app.last_git_remote_status_refresh = now - GIT_REMOTE_STATUS_REFRESH_INTERVAL; + + app.start_git_status_refresh_if_due(now); + + assert!(!app.git_refresh_in_flight); + assert!(app.event_rx.try_recv().is_err()); + } + + #[test] + fn git_refresh_demand_matches_sidebar_rows() { + let cases = [ + ( + crate::config::SpaceSidebarToken::Workspace, + GitStatusRefreshDemand::default(), + ), + ( + crate::config::SpaceSidebarToken::Branch, + GitStatusRefreshDemand { + branch: true, + ahead_behind: false, + }, + ), + ( + crate::config::SpaceSidebarToken::GitStatus, + GitStatusRefreshDemand { + branch: false, + ahead_behind: true, + }, + ), + ]; + + for (token, expected) in cases { + let mut config = crate::config::Config::default(); + config.ui.sidebar.spaces.rows = vec![vec![token.clone()]]; + let mut app = test_app(&config); + app.state.workspaces.push(Workspace::test_new("test")); + + assert_eq!(app.git_refresh_demand(), expected, "token: {token:?}"); + assert_eq!( + app.git_refresh_deadline().is_some(), + !expected.is_empty(), + "token: {token:?}" + ); + } + } + + #[test] + fn unnamed_linked_worktree_does_not_force_periodic_branch_refresh() { + let mut config = crate::config::Config::default(); + config.ui.sidebar.spaces.rows = vec![vec![crate::config::SpaceSidebarToken::Workspace]]; + let mut app = test_app(&config); + let mut child = Workspace::test_new("test"); + child.custom_name = None; + child.worktree_space = Some(crate::workspace::WorktreeSpaceMembership { + key: "repo".into(), + label: "repo".into(), + repo_root: "/repo".into(), + checkout_path: "/repo-worktree".into(), + is_linked_worktree: true, + }); + app.state.workspaces.push(child); + + assert_eq!(app.git_refresh_deadline(), None); + } + + #[test] + fn custom_named_linked_worktree_does_not_require_branch_refresh() { + let mut config = crate::config::Config::default(); + config.ui.sidebar.spaces.rows = vec![vec![crate::config::SpaceSidebarToken::Workspace]]; + let mut app = test_app(&config); + let mut child = Workspace::test_new("custom"); + child.worktree_space = Some(crate::workspace::WorktreeSpaceMembership { + key: "repo".into(), + label: "repo".into(), + repo_root: "/repo".into(), + checkout_path: "/repo-worktree".into(), + is_linked_worktree: true, + }); + app.state.workspaces.push(child); + + assert_eq!(app.git_refresh_deadline(), None); + } + + #[test] + fn headless_deadline_can_suppress_git_refresh_timer() { + let mut app = test_app(&crate::config::Config::default()); + app.state.workspaces.push(Workspace::test_new("test")); + let now = Instant::now(); + app.last_git_remote_status_refresh = now - GIT_REMOTE_STATUS_REFRESH_INTERVAL; + + assert_eq!( + app.next_headless_loop_deadline_with_git_refresh(now, false, false), + None + ); + assert_eq!( + app.next_headless_loop_deadline_with_git_refresh(now, false, true), + Some(now) + ); + } + + #[test] + fn explicit_git_refresh_invalidates_cached_non_git_results() { + let mut app = test_app(&crate::config::Config::default()); + let cwd = std::env::temp_dir().join(format!("herdr-git-miss-{}", std::process::id())); + std::fs::create_dir_all(&cwd).unwrap(); + let (_, entry) = crate::workspace::git_status_snapshot_for_cwd_with_demand( + &cwd, + None, + GitStatusRefreshDemand::ALL, + ); + app.git_status_cache + .insert(cwd.clone(), entry.expect("non-Git cache entry")); + + app.mark_git_status_refresh_due(Instant::now()); + + assert!(app.git_status_cache.is_empty()); + std::fs::remove_dir_all(cwd).unwrap(); + } + + #[test] + fn git_refresh_due_request_survives_in_flight_refresh() { + let mut app = test_app(&crate::config::Config::default()); + let now = Instant::now(); + app.git_refresh_in_flight = true; + + app.mark_git_status_refresh_due(now); + assert!(app.git_refresh_due_after_in_flight); + + app.handle_internal_event(AppEvent::GitStatusRefreshed { + results: Vec::new(), + cache_updates: Vec::new(), + }); + + assert!(!app.git_refresh_in_flight); + assert!(!app.git_refresh_due_after_in_flight); + assert_eq!(app.git_refresh_deadline(), None); + + app.state.workspaces.push(Workspace::test_new("test")); + let deadline = app + .git_refresh_deadline() + .expect("refresh should be due once a workspace exists"); + assert!(deadline <= Instant::now()); + } + + fn test_app(config: &crate::config::Config) -> super::super::App { + super::super::App::new( + config, + true, + None, + tokio::sync::mpsc::unbounded_channel().1, + crate::api::EventHub::default(), + ) + } +} |
