perf: eliminate repeated workspace git discovery (#1842)

* perf: eliminate repeated workspace git discovery

refs #1838

* fix: keep workspace labels current without extra redraws

refs #1838

* fix: retain discovered workspace git metadata

refs #1838
This commit is contained in:
Can Celik 2026-07-25 04:26:55 +03:00 committed by GitHub
parent b33e5f973f
commit d4e0dd3d90
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 1080 additions and 364 deletions

View File

@ -2607,11 +2607,21 @@ impl AppState {
}
let ws = &mut self.workspaces[ws_idx];
if ws.cached_git_branch != result.branch {
if ws.cached_identity_cwd != result.resolved_identity_cwd {
ws.cached_identity_cwd = result.resolved_identity_cwd;
}
if ws.cached_auto_label != result.auto_label {
ws.cached_auto_label = result.auto_label;
changed |= ws.custom_name.is_none();
}
if ws.cached_git_status_key != result.status_cache_key {
ws.cached_git_status_key = result.status_cache_key;
}
if result.demand.branch && ws.cached_git_branch != result.branch {
ws.cached_git_branch = result.branch;
changed = true;
}
if ws.cached_git_ahead_behind != result.ahead_behind {
if result.demand.ahead_behind && ws.cached_git_ahead_behind != result.ahead_behind {
ws.cached_git_ahead_behind = result.ahead_behind;
changed = true;
}
@ -3103,7 +3113,8 @@ impl AppState {
let sound = sound_for_toast_kind(kind, suppress_active_tab_notifications)
.filter(|_| self.sound.allows(known_agent));
let build_toast = || {
let workspace_label = self.workspaces[ws_idx].display_name();
let workspace_label =
self.workspaces[ws_idx].display_name_from_terminals(&self.terminals);
let context =
notification_context(&self.workspaces[ws_idx], &workspace_label, ws_idx, pane_id);
ToastNotification {
@ -3904,7 +3915,10 @@ mod tests {
&terminal_runtimes,
vec![WorkspaceGitStatus {
workspace_id: first_id,
resolved_identity_cwd: first_cwd,
resolved_identity_cwd: first_cwd.clone(),
status_cache_key: first_cwd,
demand: crate::workspace::GitStatusRefreshDemand::ALL,
auto_label: "one".into(),
branch: Some("main".into()),
ahead_behind: Some((2, 1)),
space: None,
@ -3931,6 +3945,9 @@ mod tests {
vec![WorkspaceGitStatus {
workspace_id,
resolved_identity_cwd: std::path::PathBuf::from("/definitely/not/current"),
status_cache_key: std::path::PathBuf::from("/definitely/not/current"),
demand: crate::workspace::GitStatusRefreshDemand::ALL,
auto_label: "stale".into(),
branch: Some("main".into()),
ahead_behind: Some((0, 1)),
space: None,
@ -3942,6 +3959,36 @@ mod tests {
assert_eq!(state.workspaces[0].git_ahead_behind(), Some((1, 0)));
}
#[test]
fn apply_workspace_git_statuses_ignores_unrequested_branch_changes() {
let mut state = app_with_workspaces(&["one"]);
let workspace_id = state.workspaces[0].id.clone();
let cwd = state.workspaces[0].resolved_identity_cwd().unwrap();
state.workspaces[0].cached_auto_label = "one".into();
state.workspaces[0].cached_git_branch = Some("old".into());
let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new();
let changed = state.apply_workspace_git_statuses(
&terminal_runtimes,
vec![WorkspaceGitStatus {
workspace_id,
resolved_identity_cwd: cwd.clone(),
status_cache_key: cwd,
demand: crate::workspace::GitStatusRefreshDemand {
branch: false,
ahead_behind: true,
},
auto_label: "one".into(),
branch: Some("new".into()),
ahead_behind: None,
space: None,
}],
);
assert!(!changed);
assert_eq!(state.workspaces[0].branch().as_deref(), Some("old"));
}
#[test]
fn apply_workspace_git_statuses_clears_missing_git_status() {
let mut state = app_with_workspaces(&["one"]);
@ -3955,7 +4002,10 @@ mod tests {
&terminal_runtimes,
vec![WorkspaceGitStatus {
workspace_id,
resolved_identity_cwd: cwd,
resolved_identity_cwd: cwd.clone(),
status_cache_key: cwd,
demand: crate::workspace::GitStatusRefreshDemand::ALL,
auto_label: "one".into(),
branch: None,
ahead_behind: None,
space: None,
@ -3980,7 +4030,10 @@ mod tests {
&terminal_runtimes,
vec![WorkspaceGitStatus {
workspace_id,
resolved_identity_cwd: cwd,
resolved_identity_cwd: cwd.clone(),
status_cache_key: cwd,
demand: crate::workspace::GitStatusRefreshDemand::ALL,
auto_label: "other".into(),
branch: Some("scratch".into()),
ahead_behind: None,
space: Some(crate::workspace::GitSpaceMetadata {

View File

@ -59,6 +59,44 @@ impl App {
response_rx.try_recv().ok()
}
pub(crate) fn handle_internal_event_with_render_impact(&mut self, ev: AppEvent) -> bool {
match ev {
AppEvent::GitStatusRefreshed {
results,
cache_updates,
} => self.handle_git_status_refreshed(results, cache_updates),
ev => {
self.handle_internal_event(ev);
true
}
}
}
fn handle_git_status_refreshed(
&mut self,
results: Vec<crate::workspace::WorkspaceGitStatus>,
cache_updates: Vec<(std::path::PathBuf, crate::workspace::GitStatusCacheEntry)>,
) -> bool {
self.git_refresh_in_flight = false;
for (key, entry) in cache_updates {
self.git_status_cache.insert(key, entry);
}
if self.git_refresh_due_after_in_flight {
self.mark_git_status_refresh_due(Instant::now());
self.git_refresh_due_after_in_flight = false;
} else {
self.last_git_remote_status_refresh = Instant::now();
}
let changed = self
.state
.apply_workspace_git_statuses(&self.terminal_runtimes, results);
if changed {
self.render_dirty.store(true, Ordering::Release);
self.render_notify.notify_one();
}
changed
}
pub(crate) fn handle_internal_event(&mut self, ev: AppEvent) {
if let AppEvent::ClipboardWrite { content } = ev {
#[cfg(not(test))]
@ -90,23 +128,7 @@ impl App {
cache_updates,
} = ev
{
self.git_refresh_in_flight = false;
for (key, entry) in cache_updates {
self.git_status_cache.insert(key, entry);
}
if self.git_refresh_due_after_in_flight {
self.mark_git_status_refresh_due(Instant::now());
self.git_refresh_due_after_in_flight = false;
} else {
self.last_git_remote_status_refresh = Instant::now();
}
if self
.state
.apply_workspace_git_statuses(&self.terminal_runtimes, results)
{
self.render_dirty.store(true, Ordering::Release);
self.render_notify.notify_one();
}
self.handle_git_status_refreshed(results, cache_updates);
return;
}
@ -270,7 +292,7 @@ impl App {
}
self.sync_full_lifecycle_authority_detection_pauses();
if terminal_cwd_reported {
self.mark_git_status_refresh_due(Instant::now());
self.request_git_identity_refresh(Instant::now());
self.render_dirty.store(true, Ordering::Release);
self.render_notify.notify_one();
}

480
src/app/git_refresh.rs Normal file
View File

@ -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(),
)
}
}

View File

@ -12,6 +12,7 @@ mod api;
mod api_helpers;
mod config_io;
mod creation;
mod git_refresh;
mod ids;
mod input;
mod popup;
@ -38,6 +39,7 @@ pub(crate) const HEADLESS_ANIMATION_TICK_STEP: u32 = 8;
pub(crate) const SELECTION_AUTOSCROLL_INTERVAL: Duration = Duration::from_millis(30);
const RESIZE_POLL_INTERVAL: Duration = Duration::from_millis(100);
const GIT_REMOTE_STATUS_REFRESH_INTERVAL: Duration = Duration::from_millis(1500);
const GIT_REPO_DISCOVERY_REFRESH_INTERVAL: Duration = Duration::from_secs(5 * 60);
const AUTO_UPDATE_CHECK_INTERVAL: Duration = Duration::from_secs(30 * 60);
const PENDING_AGENT_RESUME_THEME_WAIT: Duration = Duration::from_millis(750);
const SESSION_SAVE_DEBOUNCE: Duration = Duration::from_secs(5);
@ -110,8 +112,10 @@ pub struct App {
pub(crate) copy_feedback_deadline: Option<Instant>,
pub(crate) last_api_notification_at: Option<Instant>,
pub(crate) last_git_remote_status_refresh: Instant,
pub(crate) last_git_repo_discovery_refresh: Instant,
pub(crate) git_refresh_in_flight: bool,
pub(crate) git_refresh_due_after_in_flight: bool,
pub(crate) git_identity_refresh_requested: bool,
pub(crate) git_status_cache: HashMap<std::path::PathBuf, crate::workspace::GitStatusCacheEntry>,
pub(crate) pending_api_worktree_creates: HashMap<std::path::PathBuf, u64>,
pub(crate) pending_api_worktree_removes: HashMap<String, u64>,
@ -736,8 +740,10 @@ impl App {
event_tx,
event_rx,
last_git_remote_status_refresh: Instant::now() - GIT_REMOTE_STATUS_REFRESH_INTERVAL,
last_git_repo_discovery_refresh: Instant::now(),
git_refresh_in_flight: false,
git_refresh_due_after_in_flight: false,
git_identity_refresh_requested: false,
git_status_cache: HashMap::new(),
pending_api_worktree_creates: HashMap::new(),
pending_api_worktree_removes: HashMap::new(),
@ -892,10 +898,11 @@ impl App {
pub(crate) fn handle_internal_event_with_prefix_sync(
&mut self,
event: crate::events::AppEvent,
) {
) -> bool {
let previous_mode = self.state.mode;
self.handle_internal_event(event);
let changed = self.handle_internal_event_with_render_impact(event);
self.sync_prefix_input_source(previous_mode);
changed
}
#[cfg(test)]
@ -1126,8 +1133,9 @@ impl App {
match event {
LoopEvent::Timer => {}
LoopEvent::Internal(ev) => {
self.handle_internal_event_with_prefix_sync(ev);
needs_render = true;
if self.handle_internal_event_with_prefix_sync(ev) {
needs_render = true;
}
}
LoopEvent::Api(msg) => {
if self.handle_api_request_message(*msg) {
@ -2130,6 +2138,20 @@ mod tests {
assert_eq!(app.git_refresh_deadline(), None);
}
#[test]
fn unchanged_git_status_event_has_no_render_impact() {
let mut app = test_app();
app.git_refresh_in_flight = true;
let changed = app.handle_internal_event_with_prefix_sync(AppEvent::GitStatusRefreshed {
results: Vec::new(),
cache_updates: Vec::new(),
});
assert!(!changed);
assert!(!app.git_refresh_in_flight);
}
#[test]
fn git_status_event_clears_in_flight_refresh() {
let mut app = test_app();
@ -2157,7 +2179,10 @@ mod tests {
app.handle_internal_event(AppEvent::GitStatusRefreshed {
results: vec![crate::workspace::WorkspaceGitStatus {
workspace_id,
resolved_identity_cwd,
resolved_identity_cwd: resolved_identity_cwd.clone(),
status_cache_key: resolved_identity_cwd,
demand: crate::workspace::GitStatusRefreshDemand::ALL,
auto_label: "one".into(),
branch: Some("render-dirty-test".into()),
ahead_behind: Some((1, 0)),
space: None,
@ -2387,6 +2412,21 @@ mod tests {
assert!(app.state.toast.is_none());
}
#[test]
fn unchanged_git_status_drain_has_no_render_impact() {
let mut app = test_app();
app.git_refresh_in_flight = true;
app.event_tx
.try_send(AppEvent::GitStatusRefreshed {
results: Vec::new(),
cache_updates: Vec::new(),
})
.unwrap();
assert!(!app.drain_internal_events());
assert!(!app.git_refresh_in_flight);
}
#[test]
fn internal_event_drain_limits_work_per_tick() {
let mut app = test_app();

View File

@ -4,40 +4,9 @@ use crossterm::terminal;
use super::{
background_update_check_enabled, pressed_key_identity, App, ANIMATION_INTERVAL,
AUTO_UPDATE_CHECK_INTERVAL, GIT_REMOTE_STATUS_REFRESH_INTERVAL, MIN_RENDER_INTERVAL,
RESIZE_POLL_INTERVAL, SELECTION_AUTOSCROLL_INTERVAL,
AUTO_UPDATE_CHECK_INTERVAL, MIN_RENDER_INTERVAL, RESIZE_POLL_INTERVAL,
SELECTION_AUTOSCROLL_INTERVAL,
};
use crate::events::AppEvent;
use crate::workspace::{GitStatusCacheEntry, Workspace, WorkspaceGitStatus};
use std::collections::HashMap;
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct WorkspaceGitRefreshItem {
pub(crate) workspace_id: String,
pub(crate) resolved_identity_cwd: std::path::PathBuf,
pub(crate) cache_key: std::path::PathBuf,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct WorkspaceGitRefreshTarget {
pub(crate) workspace_id: String,
pub(crate) resolved_identity_cwd: std::path::PathBuf,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct WorkspaceGitRefreshJob {
pub(crate) cache_key: std::path::PathBuf,
pub(crate) status_cwd: std::path::PathBuf,
pub(crate) cached: Option<GitStatusCacheEntry>,
pub(crate) targets: Vec<WorkspaceGitRefreshTarget>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct WorkspaceGitRefreshOutput {
pub(crate) results: Vec<WorkspaceGitStatus>,
pub(crate) cache_updates: Vec<(std::path::PathBuf, GitStatusCacheEntry)>,
}
fn retain_custom_command_after_wait(
pid: u32,
result: std::io::Result<Option<std::process::ExitStatus>>,
@ -562,50 +531,6 @@ impl App {
std::thread::spawn(move || crate::detect::manifest_update::auto_update(manifest_update_tx));
}
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 workspaces = self.workspace_git_refresh_items();
if workspaces.is_empty() {
self.last_git_remote_status_refresh = now;
return;
}
self.git_refresh_in_flight = true;
let event_tx = self.event_tx.clone();
let cache = self.git_status_cache.clone();
std::thread::spawn(move || {
let output = refresh_workspace_git_statuses_with_cache(workspaces, &cache);
let _ = event_tx.blocking_send(AppEvent::GitStatusRefreshed {
results: output.results,
cache_updates: output.cache_updates,
});
});
}
pub(crate) fn mark_git_status_refresh_due(&mut self, now: Instant) {
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())
.then_some(self.last_git_remote_status_refresh + GIT_REMOTE_STATUS_REFRESH_INTERVAL)
}
pub(crate) fn next_loop_deadline(&self, now: Instant, needs_render: bool) -> Option<Instant> {
self.next_loop_deadline_with_resize_poll(now, needs_render, true, true)
}
@ -659,103 +584,35 @@ impl App {
.min()
}
fn workspace_git_refresh_items(&self) -> Vec<WorkspaceGitRefreshItem> {
self.state
.workspaces
.iter()
.filter_map(|ws| {
let cwd =
ws.resolved_identity_cwd_from(&self.state.terminals, &self.terminal_runtimes)?;
let git_key = crate::workspace::git_status_cache_key(&cwd);
let cache_key = git_key.unwrap_or_else(|| cwd.clone());
Some(WorkspaceGitRefreshItem {
workspace_id: ws.id.clone(),
resolved_identity_cwd: cwd,
cache_key,
})
})
.collect()
}
pub(crate) fn drain_internal_events(&mut self) -> bool {
self.drain_internal_events_up_to(super::APP_EVENT_DRAIN_LIMIT)
.1
}
pub(crate) fn drain_all_internal_events(&mut self) -> bool {
let mut had_event = false;
while self.drain_internal_events_up_to(super::APP_EVENT_DRAIN_LIMIT) {
had_event = true;
let mut changed = false;
loop {
let (had_event, batch_changed) =
self.drain_internal_events_up_to(super::APP_EVENT_DRAIN_LIMIT);
changed |= batch_changed;
if !had_event {
break;
}
}
had_event
changed
}
fn drain_internal_events_up_to(&mut self, limit: usize) -> bool {
fn drain_internal_events_up_to(&mut self, limit: usize) -> (bool, bool) {
let mut had_event = false;
let mut changed = false;
for _ in 0..limit {
let Ok(ev) = self.event_rx.try_recv() else {
break;
};
had_event = true;
self.handle_internal_event_with_prefix_sync(ev);
changed |= self.handle_internal_event_with_prefix_sync(ev);
}
had_event
}
}
pub(crate) fn deduplicate_git_refresh_items(
items: Vec<WorkspaceGitRefreshItem>,
cache: &HashMap<std::path::PathBuf, GitStatusCacheEntry>,
) -> Vec<WorkspaceGitRefreshJob> {
let mut indexes = HashMap::<std::path::PathBuf, usize>::new();
let mut jobs = Vec::<WorkspaceGitRefreshJob>::new();
for item in items {
let target = WorkspaceGitRefreshTarget {
workspace_id: item.workspace_id,
resolved_identity_cwd: item.resolved_identity_cwd.clone(),
};
if let Some(&index) = indexes.get(&item.cache_key) {
jobs[index].targets.push(target);
continue;
}
let status_cwd = item.cache_key.clone();
let cached = cache.get(&item.cache_key).cloned();
indexes.insert(item.cache_key, jobs.len());
jobs.push(WorkspaceGitRefreshJob {
cache_key: status_cwd.clone(),
status_cwd,
cached,
targets: vec![target],
});
}
jobs
}
pub(crate) fn refresh_workspace_git_statuses_with_cache(
items: Vec<WorkspaceGitRefreshItem>,
cache: &HashMap<std::path::PathBuf, GitStatusCacheEntry>,
) -> 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) =
Workspace::git_status_snapshot_for_cwd_with_cache(&job.status_cwd, job.cached.as_ref());
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)
}));
}
WorkspaceGitRefreshOutput {
results,
cache_updates,
(had_event, changed)
}
}
@ -764,7 +621,6 @@ mod tests {
use super::*;
use crate::app::state;
use crate::workspace::Workspace;
use std::path::PathBuf;
#[test]
fn interrupted_custom_command_wait_keeps_child_for_retry() {
@ -796,131 +652,6 @@ mod tests {
(app, pane_id)
}
#[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(
vec![
WorkspaceGitRefreshItem {
workspace_id: "one".into(),
resolved_identity_cwd: nested.clone(),
cache_key: repo.clone(),
},
WorkspaceGitRefreshItem {
workspace_id: "two".into(),
resolved_identity_cwd: other.clone(),
cache_key: repo.clone(),
},
],
&HashMap::new(),
);
assert_eq!(output.cache_updates.len(), 1);
assert_eq!(output.cache_updates[0].0, repo);
assert_eq!(output.results.len(), 2);
assert_eq!(output.results[0].workspace_id, "one");
assert_eq!(
output.results[0].resolved_identity_cwd,
PathBuf::from(&nested)
);
assert_eq!(output.results[1].workspace_id, "two");
assert_eq!(
output.results[1].resolved_identity_cwd,
PathBuf::from(&other)
);
let _ = std::fs::remove_dir_all(repo);
}
#[test]
fn git_refresh_items_use_cwd_cache_key_for_non_git_cwd() {
let mut app = super::super::App::new(
&crate::config::Config::default(),
true,
None,
tokio::sync::mpsc::unbounded_channel().1,
crate::api::EventHub::default(),
);
let cwd = std::env::temp_dir().join(format!("herdr-non-git-cwd-{}", std::process::id()));
std::fs::create_dir_all(&cwd).expect("create temp cwd");
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();
assert_eq!(items.len(), 1);
assert_eq!(items[0].cache_key, cwd);
let _ = std::fs::remove_dir_all(&cwd);
}
#[test]
fn headless_deadline_can_suppress_git_refresh_timer() {
let mut app = super::super::App::new(
&crate::config::Config::default(),
true,
None,
tokio::sync::mpsc::unbounded_channel().1,
crate::api::EventHub::default(),
);
app.state.workspaces.push(Workspace::test_new("test"));
let now = Instant::now();
app.last_git_remote_status_refresh = now - super::super::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 git_refresh_due_request_survives_in_flight_refresh() {
let mut app = super::super::App::new(
&crate::config::Config::default(),
true,
None,
tokio::sync::mpsc::unbounded_channel().1,
crate::api::EventHub::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(crate::events::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());
}
#[test]
fn tick_selection_autoscroll_stops_when_metrics_unavailable() {
// Without a runtime, pane_scroll_metrics returns None.

View File

@ -403,15 +403,20 @@ fn restore_workspace(
}
let worktree_space = restored_worktree_space_membership(snap.worktree_space.clone());
let (cached_git_space, cached_auto_label, cached_git_status_key) =
crate::workspace::discover_workspace_git_identity(&snap.identity_cwd);
(
Some(Workspace {
id: workspace_id,
custom_name: snap.custom_name.clone(),
identity_cwd: snap.identity_cwd.clone(),
cached_identity_cwd: snap.identity_cwd.clone(),
cached_auto_label,
cached_git_status_key,
cached_git_branch: crate::workspace::git_branch(&snap.identity_cwd),
cached_git_ahead_behind: None,
cached_git_space: crate::workspace::git_space_metadata(&snap.identity_cwd),
cached_git_space,
worktree_space,
metadata_tokens: crate::metadata_tokens::MetadataTokens::default(),
metadata_token_sequences: HashMap::new(),

View File

@ -2272,10 +2272,7 @@ impl HeadlessServer {
true
}
_ => {
self.app.handle_internal_event(ev);
true
}
_ => self.app.handle_internal_event_with_render_impact(ev),
}
}
@ -5583,6 +5580,61 @@ next_tab = ""
app_client_marks_git_refresh_due_on_first_attach(RenderEncoding::SemanticFrame);
}
#[test]
fn unchanged_git_refresh_does_not_request_headless_render() {
let mut server = test_headless_server();
server.app.git_refresh_in_flight = true;
let mut workspace = crate::workspace::Workspace::test_new("one");
let workspace_id = workspace.id.clone();
let cwd = workspace.identity_cwd.clone();
workspace.cached_auto_label = "cached".into();
workspace.cached_git_status_key = cwd.clone();
workspace.cached_git_branch = None;
server.app.state.workspaces.push(workspace);
let changed = server.handle_internal_event_with_forwarding(AppEvent::GitStatusRefreshed {
results: vec![crate::workspace::WorkspaceGitStatus {
workspace_id,
resolved_identity_cwd: cwd.clone(),
status_cache_key: cwd,
demand: crate::workspace::GitStatusRefreshDemand::ALL,
auto_label: "cached".into(),
branch: None,
ahead_behind: None,
space: None,
}],
cache_updates: Vec::new(),
});
assert!(!changed);
assert!(!server.app.git_refresh_in_flight);
}
#[test]
fn changed_git_refresh_requests_headless_render() {
let mut server = test_headless_server();
let workspace = crate::workspace::Workspace::test_new("one");
let workspace_id = workspace.id.clone();
let cwd = workspace.identity_cwd.clone();
server.app.state.workspaces.push(workspace);
let changed = server.handle_internal_event_with_forwarding(AppEvent::GitStatusRefreshed {
results: vec![crate::workspace::WorkspaceGitStatus {
workspace_id,
resolved_identity_cwd: cwd.clone(),
status_cache_key: cwd,
demand: crate::workspace::GitStatusRefreshDemand::ALL,
auto_label: "one".into(),
branch: Some("changed".into()),
ahead_behind: None,
space: None,
}],
cache_updates: Vec::new(),
});
assert!(changed);
}
#[test]
fn terminal_attach_client_exits_when_attached_pane_dies() {
let mut server = test_headless_server();

View File

@ -197,12 +197,12 @@ fn workspace_row_height(app: &AppState, ws: &crate::workspace::Workspace, indent
let (state, seen) = ws.aggregate_state(&app.terminals);
let label = if indented {
grouped_child_display_label(
&ws.display_name(),
&ws.display_name_from_terminals(&app.terminals),
ws.branch().as_deref(),
ws.custom_name.is_some(),
)
} else {
ws.display_name()
ws.display_name_from_terminals(&app.terminals)
};
let token_values = ws.metadata_tokens.values();
tokens::space_rows(

View File

@ -20,11 +20,12 @@ mod tab;
#[cfg(test)]
use self::git::git_ahead_behind;
pub(crate) use self::tab::MovedPane;
use self::git::git_status_cache_key_for_space;
pub(crate) use self::{git::git_status_snapshot_for_cwd_with_demand, tab::MovedPane};
pub use self::{
git::{
derive_label_from_cwd, git_branch, git_space_metadata, git_status_cache_key,
GitSpaceMetadata, GitStatusCacheEntry,
derive_label_from_cwd, fallback_label_from_cwd, git_branch, git_space_metadata,
git_status_cache_key, GitSpaceMetadata, GitStatusCacheEntry, GitStatusRefreshDemand,
},
tab::{NewPane, Tab},
};
@ -42,6 +43,9 @@ pub struct WorktreeSpaceMembership {
pub struct WorkspaceGitStatus {
pub workspace_id: String,
pub resolved_identity_cwd: PathBuf,
pub status_cache_key: PathBuf,
pub demand: GitStatusRefreshDemand,
pub auto_label: String,
pub branch: Option<String>,
pub ahead_behind: Option<(usize, usize)>,
pub space: Option<GitSpaceMetadata>,
@ -49,20 +53,41 @@ pub struct WorkspaceGitStatus {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorkspaceGitStatusSnapshot {
pub auto_label: String,
pub branch: Option<String>,
pub ahead_behind: Option<(usize, usize)>,
pub space: Option<GitSpaceMetadata>,
}
pub(crate) fn discover_workspace_git_identity(
cwd: &std::path::Path,
) -> (Option<GitSpaceMetadata>, String, PathBuf) {
let space = git_space_metadata(cwd);
let auto_label = space
.as_ref()
.map(|space| space.label.clone())
.unwrap_or_else(|| fallback_label_from_cwd(cwd));
let status_cache_key = space
.as_ref()
.map(git_status_cache_key_for_space)
.unwrap_or_else(|| cwd.to_path_buf());
(space, auto_label, status_cache_key)
}
impl WorkspaceGitStatusSnapshot {
pub fn into_workspace_status(
self,
workspace_id: String,
resolved_identity_cwd: PathBuf,
status_cache_key: PathBuf,
demand: GitStatusRefreshDemand,
) -> WorkspaceGitStatus {
WorkspaceGitStatus {
workspace_id,
resolved_identity_cwd,
status_cache_key,
demand,
auto_label: self.auto_label,
branch: self.branch,
ahead_behind: self.ahead_behind,
space: self.space,
@ -149,6 +174,12 @@ pub struct Workspace {
pub custom_name: Option<String>,
/// Fallback workspace identity source for tests, old snapshots, or missing runtimes.
pub identity_cwd: PathBuf,
/// CWD from which the cached automatic label and Git metadata were derived.
pub(crate) cached_identity_cwd: PathBuf,
/// Automatic workspace label cached outside the render path.
pub(crate) cached_auto_label: String,
/// Cache key for periodic Git status associated with `cached_identity_cwd`.
pub(crate) cached_git_status_key: PathBuf,
/// Cached current git branch for the workspace repo.
pub(crate) cached_git_branch: Option<String>,
/// Cached ahead/behind counts for the workspace repo's current branch upstream.
@ -210,13 +241,18 @@ impl Workspace {
let tab = Tab::from_existing_pane(1, tab_label, moved, events, render_notify, render_dirty);
let mut public_pane_numbers = HashMap::new();
public_pane_numbers.insert(root_pane, 1);
let (cached_git_space, cached_auto_label, cached_git_status_key) =
discover_workspace_git_identity(&identity_cwd);
Self {
id,
custom_name: label,
identity_cwd: identity_cwd.clone(),
cached_identity_cwd: identity_cwd.clone(),
cached_auto_label,
cached_git_status_key,
cached_git_branch: git_branch(&identity_cwd),
cached_git_ahead_behind: None,
cached_git_space: git_space_metadata(&identity_cwd),
cached_git_space,
worktree_space: None,
metadata_tokens: crate::metadata_tokens::MetadataTokens::default(),
metadata_token_sequences: HashMap::new(),
@ -392,14 +428,19 @@ impl Workspace {
};
let mut public_pane_numbers = HashMap::new();
public_pane_numbers.insert(tab.root_pane, 1);
let (cached_git_space, cached_auto_label, cached_git_status_key) =
discover_workspace_git_identity(&initial_cwd);
Ok((
Self {
id,
custom_name: None,
identity_cwd: initial_cwd.clone(),
cached_identity_cwd: initial_cwd.clone(),
cached_auto_label,
cached_git_status_key,
cached_git_branch: git_branch(&initial_cwd),
cached_git_ahead_behind: None,
cached_git_space: None,
cached_git_space,
worktree_space: None,
metadata_tokens: crate::metadata_tokens::MetadataTokens::default(),
metadata_token_sequences: HashMap::new(),
@ -1036,6 +1077,7 @@ impl Workspace {
self.custom_name = Some(name);
}
#[cfg(test)]
pub fn resolved_identity_cwd(&self) -> Option<PathBuf> {
Some(self.identity_cwd.clone())
}
@ -1051,14 +1093,31 @@ impl Workspace {
.or_else(|| Some(self.identity_cwd.clone()))
}
#[cfg(test)]
pub fn display_name(&self) -> String {
if let Some(name) = &self.custom_name {
return name.clone();
}
self.resolved_identity_cwd()
.map(|cwd| derive_label_from_cwd(&cwd))
.unwrap_or_else(|| "workspace".into())
self.automatic_display_name_for_cwd(&self.identity_cwd)
}
pub(crate) fn display_name_from_terminals(
&self,
terminals: &HashMap<TerminalId, TerminalState>,
) -> String {
if let Some(name) = &self.custom_name {
return name.clone();
}
let cwd = self
.tabs
.first()
.and_then(|tab| tab.terminal_id(tab.root_pane))
.and_then(|terminal_id| terminals.get(terminal_id))
.map(|terminal| &terminal.cwd)
.unwrap_or(&self.identity_cwd);
self.automatic_display_name_for_cwd(cwd)
}
pub fn display_name_from(
@ -1071,10 +1130,18 @@ impl Workspace {
}
self.resolved_identity_cwd_from(terminals, terminal_runtimes)
.map(|cwd| derive_label_from_cwd(&cwd))
.map(|cwd| self.automatic_display_name_for_cwd(&cwd))
.unwrap_or_else(|| "workspace".into())
}
fn automatic_display_name_for_cwd(&self, cwd: &std::path::Path) -> String {
if cwd == self.cached_identity_cwd {
self.cached_auto_label.clone()
} else {
fallback_label_from_cwd(cwd)
}
}
pub fn branch(&self) -> Option<String> {
self.cached_git_branch.clone()
}
@ -1099,13 +1166,6 @@ impl Workspace {
self.cached_git_space = cwd.as_deref().and_then(git_space_metadata);
}
pub fn git_status_snapshot_for_cwd_with_cache(
resolved_identity_cwd: &std::path::Path,
cached: Option<&GitStatusCacheEntry>,
) -> (WorkspaceGitStatusSnapshot, Option<GitStatusCacheEntry>) {
self::git::git_status_snapshot_for_cwd(resolved_identity_cwd, cached)
}
pub fn find_tab_index_for_pane(&self, pane_id: PaneId) -> Option<usize> {
self.tabs
.iter()
@ -1210,6 +1270,9 @@ impl Workspace {
id: generate_workspace_id(),
custom_name: Some(name.to_string()),
identity_cwd: identity_cwd.clone(),
cached_identity_cwd: identity_cwd.clone(),
cached_auto_label: fallback_label_from_cwd(&identity_cwd),
cached_git_status_key: identity_cwd.clone(),
cached_git_branch: git_branch(&identity_cwd),
cached_git_ahead_behind: None,
cached_git_space: None,
@ -1571,6 +1634,92 @@ mod tests {
assert!(!target.tabs[0].panes.contains_key(&source_pane));
}
#[tokio::test]
async fn new_workspace_retains_discovered_git_metadata() {
let stamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("clock should be after unix epoch")
.as_nanos();
let root = std::env::temp_dir().join(format!(
"herdr-workspace-git-metadata-{}-{stamp}",
std::process::id()
));
std::fs::create_dir_all(root.join(".git")).expect("create git directory");
std::fs::write(root.join(".git/HEAD"), "ref: refs/heads/main\n").expect("write git head");
#[cfg(windows)]
let command = "C:\\Windows\\System32\\whoami.exe";
#[cfg(not(windows))]
let command = "/usr/bin/true";
let argv = vec![command.to_string()];
let (events, _) = mpsc::channel(64);
let render_notify = Arc::new(Notify::new());
let render_dirty = Arc::new(AtomicBool::new(false));
let (workspace, _terminal, runtime) = Workspace::new_argv_command(
root.clone(),
24,
80,
&argv,
1024,
crate::terminal_theme::TerminalTheme::default(),
events,
render_notify,
render_dirty,
)
.expect("create workspace");
let space = workspace
.git_space()
.expect("workspace should retain discovered git metadata");
assert_eq!(space.repo_root, root);
runtime.shutdown();
std::fs::remove_dir_all(root).expect("remove test repo");
}
#[test]
fn display_name_reads_cached_identity_without_rechecking_filesystem() {
let stamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("clock should be after unix epoch")
.as_nanos();
let root = std::env::temp_dir().join(format!(
"herdr-workspace-label-cache-{}-{stamp}",
std::process::id()
));
let cwd = root.join("deep/nested");
std::fs::create_dir_all(&cwd).expect("create nested cwd");
let mut ws = Workspace::test_new("ignored");
ws.custom_name = None;
ws.identity_cwd = cwd.clone();
ws.tabs.clear();
ws.cached_identity_cwd = cwd;
ws.cached_auto_label = "cached-repo".into();
std::fs::remove_dir_all(root).expect("remove cwd after cache admission");
assert_eq!(ws.display_name(), "cached-repo");
}
#[test]
fn terminal_aware_display_name_uses_latest_admitted_identity_cache() {
let mut ws = Workspace::test_new("ignored");
let root_pane = ws.tabs[0].root_pane;
let terminal_id = ws.tabs[0].terminal_id(root_pane).unwrap().clone();
ws.custom_name = None;
ws.identity_cwd = PathBuf::from("/old/workspace");
ws.cached_identity_cwd = PathBuf::from("/new/repo/deep");
ws.cached_auto_label = "repo".into();
let terminals = HashMap::from([(
terminal_id.clone(),
TerminalState::new(terminal_id, PathBuf::from("/new/repo/deep")),
)]);
assert_eq!(ws.display_name_from_terminals(&terminals), "repo");
assert_eq!(ws.display_name(), "workspace");
}
#[test]
fn workspace_identity_follows_first_tab_root_pane_cwd() {
let mut ws = Workspace::test_new("ignored");

View File

@ -25,6 +25,10 @@ pub fn derive_label_from_cwd(cwd: &Path) -> String {
}
}
fallback_label_from_cwd(cwd)
}
pub fn fallback_label_from_cwd(cwd: &Path) -> String {
if let Ok(home) = std::env::var("HOME") {
let home = Path::new(&home);
if cwd == home {
@ -56,9 +60,11 @@ pub fn git_worktree_info(cwd: &Path) -> Option<GitWorktreeInfo> {
}
pub fn git_space_metadata(cwd: &Path) -> Option<GitSpaceMetadata> {
git_repo_root(cwd)?;
let info = git_worktree_info(cwd)?;
Some(git_space_metadata_from_info(&info))
}
pub(super) fn git_space_metadata_from_info(info: &GitWorktreeInfo) -> GitSpaceMetadata {
let key = canonicalize_best_effort_path(&info.git_common_dir)
.display()
.to_string();
@ -80,13 +86,13 @@ pub fn git_space_metadata(cwd: &Path) -> Option<GitSpaceMetadata> {
.and_then(|name| name.to_str())
.unwrap_or("repo")
.to_string();
Some(GitSpaceMetadata {
GitSpaceMetadata {
key,
checkout_key,
label,
repo_root: info.repo_root,
repo_root: info.repo_root.clone(),
is_linked_worktree: info.is_linked_worktree,
})
}
}
pub(super) fn canonicalize_best_effort_path(path: &Path) -> PathBuf {

View File

@ -7,8 +7,14 @@ mod status;
mod test_support;
pub use self::{
discovery::{derive_label_from_cwd, git_branch, git_space_metadata, GitSpaceMetadata},
status::{git_status_cache_key, git_status_snapshot_for_cwd, GitStatusCacheEntry},
discovery::{
derive_label_from_cwd, fallback_label_from_cwd, git_branch, git_space_metadata,
GitSpaceMetadata,
},
status::{
git_status_cache_key, git_status_cache_key_for_space,
git_status_snapshot_for_cwd_with_demand, GitStatusCacheEntry, GitStatusRefreshDemand,
},
};
#[cfg(test)]

View File

@ -1,19 +1,39 @@
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use crate::workspace::WorkspaceGitStatusSnapshot;
use crate::workspace::{GitSpaceMetadata, WorkspaceGitStatusSnapshot};
use super::{
config::{read_branch_config, upstream_full_ref},
discovery::{
canonicalize_best_effort_path, git_branch, git_ref_storage_is_reftable,
git_rev_parse_verify, git_space_metadata, git_symbolic_head_full, git_worktree_info,
read_ref_oid, GitWorktreeInfo,
canonicalize_best_effort_path, fallback_label_from_cwd, git_ref_storage_is_reftable,
git_rev_parse_verify, git_space_metadata_from_info, git_symbolic_head_full,
git_worktree_info, read_ref_oid, GitWorktreeInfo,
},
};
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct GitStatusRefreshDemand {
pub branch: bool,
pub ahead_behind: bool,
}
impl GitStatusRefreshDemand {
#[cfg(test)]
pub const ALL: Self = Self {
branch: true,
ahead_behind: true,
};
pub fn is_empty(self) -> bool {
!self.branch && !self.ahead_behind
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GitStatusCacheEntry {
pub fingerprint: GitStatusFingerprint,
pub fingerprint: Option<GitStatusFingerprint>,
pub retry_after: Option<Instant>,
pub snapshot: WorkspaceGitStatusSnapshot,
}
@ -49,33 +69,97 @@ pub fn git_status_cache_key(cwd: &Path) -> Option<PathBuf> {
git_worktree_info(cwd).map(|info| canonicalize_best_effort_path(&info.repo_root))
}
pub fn git_status_cache_key_for_space(space: &GitSpaceMetadata) -> PathBuf {
canonicalize_best_effort_path(&space.repo_root)
}
#[cfg(test)]
pub fn git_status_snapshot_for_cwd(
cwd: &Path,
cached: Option<&GitStatusCacheEntry>,
) -> (WorkspaceGitStatusSnapshot, Option<GitStatusCacheEntry>) {
let space = git_space_metadata(cwd);
let Some(fingerprint) = git_status_fingerprint(cwd) else {
git_status_snapshot_for_cwd_with_demand(cwd, cached, GitStatusRefreshDemand::ALL)
}
pub fn git_status_snapshot_for_cwd_with_demand(
cwd: &Path,
cached: Option<&GitStatusCacheEntry>,
demand: GitStatusRefreshDemand,
) -> (WorkspaceGitStatusSnapshot, Option<GitStatusCacheEntry>) {
if let Some(cached) = cached.filter(|entry| {
entry.fingerprint.is_none()
&& entry
.retry_after
.is_some_and(|retry_after| retry_after > Instant::now())
}) {
return (cached.snapshot.clone(), Some(cached.clone()));
}
let Some(info) = git_worktree_info(cwd) else {
let snapshot = WorkspaceGitStatusSnapshot {
auto_label: fallback_label_from_cwd(cwd),
branch: None,
ahead_behind: None,
space: None,
};
return (
snapshot.clone(),
Some(GitStatusCacheEntry {
fingerprint: None,
retry_after: Some(Instant::now() + Duration::from_secs(30)),
snapshot,
}),
);
};
let space = git_space_metadata_from_info(&info);
let auto_label = space.label.clone();
if !demand.ahead_behind {
let branch = demand
.branch
.then(|| {
read_head_identity(&info).and_then(|head| match head {
GitHeadIdentity::Branch { short_name, .. } => Some(short_name),
GitHeadIdentity::Detached { .. } => None,
})
})
.flatten();
return (
WorkspaceGitStatusSnapshot {
branch: git_branch(cwd),
auto_label,
branch,
ahead_behind: None,
space,
space: Some(space),
},
None,
);
}
let Some(fingerprint) = git_status_fingerprint_from_info(&info) else {
return (
WorkspaceGitStatusSnapshot {
auto_label,
branch: None,
ahead_behind: None,
space: Some(space),
},
None,
);
};
let branch = fingerprint.branch_name().map(str::to_string);
if let Some(cached) = cached.filter(|entry| entry.fingerprint == fingerprint) {
if let Some(cached) = cached.filter(|entry| entry.fingerprint.as_ref() == Some(&fingerprint)) {
let snapshot = WorkspaceGitStatusSnapshot {
auto_label,
branch,
ahead_behind: cached.snapshot.ahead_behind,
space,
space: Some(space),
};
return (
snapshot.clone(),
Some(GitStatusCacheEntry {
fingerprint,
fingerprint: Some(fingerprint),
retry_after: None,
snapshot,
}),
);
@ -86,24 +170,31 @@ pub fn git_status_snapshot_for_cwd(
.zip(fingerprint.upstream_oid())
.and_then(|(head_oid, upstream_oid)| git_ahead_behind_between(cwd, head_oid, upstream_oid));
let snapshot = WorkspaceGitStatusSnapshot {
auto_label,
branch,
ahead_behind,
space,
space: Some(space),
};
(
snapshot.clone(),
Some(GitStatusCacheEntry {
fingerprint,
fingerprint: Some(fingerprint),
retry_after: None,
snapshot,
}),
)
}
#[cfg(test)]
pub(super) fn git_status_fingerprint(cwd: &Path) -> Option<GitStatusFingerprint> {
let info = git_worktree_info(cwd)?;
let head = read_head_identity(&info)?;
git_status_fingerprint_from_info(&info)
}
fn git_status_fingerprint_from_info(info: &GitWorktreeInfo) -> Option<GitStatusFingerprint> {
let head = read_head_identity(info)?;
let upstream = match &head {
GitHeadIdentity::Branch { short_name, .. } => read_upstream_identity(&info, short_name),
GitHeadIdentity::Branch { short_name, .. } => read_upstream_identity(info, short_name),
GitHeadIdentity::Detached { .. } => None,
};
@ -243,7 +334,30 @@ fn parse_git_ahead_behind_output(stdout: &str) -> Option<(usize, usize)> {
#[cfg(test)]
mod tests {
use super::*;
use crate::workspace::git::test_support::{run_git, temp_test_dir, write_fake_tracked_repo};
use crate::workspace::git::{
git_space_metadata,
test_support::{run_git, temp_test_dir, write_fake_tracked_repo},
};
#[cfg(target_os = "linux")]
#[test]
fn cache_key_from_space_preserves_non_utf8_checkout_path() {
use std::os::unix::ffi::OsStringExt;
let base = temp_test_dir("non-utf8-key");
let root = base.join(std::ffi::OsString::from_vec(vec![
b'r', b'e', b'p', b'o', 0x80,
]));
write_fake_tracked_repo(&root);
let space = git_space_metadata(&root).expect("Git metadata");
assert_eq!(
git_status_cache_key_for_space(&space),
std::fs::canonicalize(&root).unwrap()
);
std::fs::remove_dir_all(base).unwrap();
}
#[test]
fn git_status_cache_key_ignores_invalid_git_marker() {
@ -257,14 +371,68 @@ mod tests {
std::fs::remove_dir_all(base).unwrap();
}
#[test]
fn non_git_refresh_reuses_cached_miss_without_rechecking_filesystem() {
let root = temp_test_dir("cached-miss");
let cwd = root.join("deep/nested");
std::fs::create_dir_all(&cwd).unwrap();
let (initial, cache_entry) = git_status_snapshot_for_cwd(&cwd, None);
let cache_entry = cache_entry.expect("non-Git result should be cached");
std::fs::remove_dir_all(&root).unwrap();
let (cached, update) = git_status_snapshot_for_cwd(&cwd, Some(&cache_entry));
assert_eq!(cached, initial);
assert_eq!(update, Some(cache_entry));
}
#[test]
fn expired_non_git_cache_detects_repository_created_in_place() {
let root = temp_test_dir("expired-miss");
let (_, cache_entry) = git_status_snapshot_for_cwd(&root, None);
let mut cache_entry = cache_entry.expect("non-Git result should be cached");
cache_entry.retry_after = Some(Instant::now() - Duration::from_secs(1));
write_fake_tracked_repo(&root);
let (snapshot, update) = git_status_snapshot_for_cwd(&root, Some(&cache_entry));
assert_eq!(snapshot.branch.as_deref(), Some("main"));
assert!(update.is_some_and(|entry| entry.fingerprint.is_some()));
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn branch_only_refresh_skips_ahead_behind_cache_work() {
let root = temp_test_dir("branch-only");
write_fake_tracked_repo(&root);
let (snapshot, update) = git_status_snapshot_for_cwd_with_demand(
&root,
None,
GitStatusRefreshDemand {
branch: true,
ahead_behind: false,
},
);
assert_eq!(snapshot.branch.as_deref(), Some("main"));
assert_eq!(snapshot.ahead_behind, None);
assert_eq!(update, None);
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn git_status_reuses_cached_ahead_behind_when_fingerprint_matches() {
let root = temp_test_dir("cache-hit");
write_fake_tracked_repo(&root);
let fingerprint = git_status_fingerprint(&root).unwrap();
let cached = GitStatusCacheEntry {
fingerprint,
fingerprint: Some(fingerprint),
retry_after: None,
snapshot: WorkspaceGitStatusSnapshot {
auto_label: "repo".into(),
branch: Some("main".into()),
ahead_behind: Some((2, 1)),
space: git_space_metadata(&root),
@ -286,8 +454,10 @@ mod tests {
write_fake_tracked_repo(&root);
let fingerprint = git_status_fingerprint(&root).unwrap();
let cached = GitStatusCacheEntry {
fingerprint,
fingerprint: Some(fingerprint),
retry_after: None,
snapshot: WorkspaceGitStatusSnapshot {
auto_label: "repo".into(),
branch: Some("main".into()),
ahead_behind: Some((4, 0)),
space: git_space_metadata(&root),
@ -319,8 +489,10 @@ mod tests {
write_fake_tracked_repo(&root);
let fingerprint = git_status_fingerprint(&root).unwrap();
let cached = GitStatusCacheEntry {
fingerprint,
fingerprint: Some(fingerprint),
retry_after: None,
snapshot: WorkspaceGitStatusSnapshot {
auto_label: "repo".into(),
branch: Some("main".into()),
ahead_behind: Some((0, 3)),
space: git_space_metadata(&root),