diff --git a/src/app/actions.rs b/src/app/actions.rs index 40c8e74c..981212b1 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -2044,8 +2044,12 @@ impl AppState { // Intercepted in App::handle_internal_event before reaching this // dispatch; never touches AppState. AppEvent::ClipboardWrite { .. } => Vec::new(), - AppEvent::GitStatusRefreshed { results } => { + AppEvent::GitStatusRefreshed { + results, + cache_updates, + } => { let _ = results; + let _ = cache_updates; Vec::new() } AppEvent::WorktreeAddFinished(_) => Vec::new(), diff --git a/src/app/api.rs b/src/app/api.rs index a26b2856..d8224506 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -21,14 +21,29 @@ enum RuntimeExitAction { impl App { pub(crate) fn handle_internal_event(&mut self, ev: AppEvent) { if let AppEvent::ClipboardWrite { content } = ev { + #[cfg(not(test))] crate::selection::write_osc52_bytes(&content); + #[cfg(test)] + let _ = content; self.show_clipboard_feedback(); return; } - if let AppEvent::GitStatusRefreshed { results } = ev { + if let AppEvent::GitStatusRefreshed { + results, + cache_updates, + } = ev + { self.git_refresh_in_flight = false; - self.last_git_remote_status_refresh = Instant::now(); + 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) diff --git a/src/app/mod.rs b/src/app/mod.rs index d8071c4d..273016db 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -97,6 +97,8 @@ pub struct App { pub(crate) copy_feedback_deadline: Option, pub(crate) last_git_remote_status_refresh: Instant, pub(crate) git_refresh_in_flight: bool, + pub(crate) git_refresh_due_after_in_flight: bool, + pub(crate) git_status_cache: HashMap, pub(crate) last_sidebar_divider_click: Option, pub(crate) last_pane_click: Option, pub(crate) next_resize_poll: Instant, @@ -563,6 +565,8 @@ impl App { event_rx, last_git_remote_status_refresh: Instant::now() - GIT_REMOTE_STATUS_REFRESH_INTERVAL, git_refresh_in_flight: false, + git_refresh_due_after_in_flight: false, + git_status_cache: HashMap::new(), last_sidebar_divider_click: None, last_pane_click: None, next_resize_poll: Instant::now() + RESIZE_POLL_INTERVAL, @@ -1459,6 +1463,7 @@ mod tests { app.handle_internal_event(AppEvent::GitStatusRefreshed { results: Vec::new(), + cache_updates: Vec::new(), }); assert!(!app.git_refresh_in_flight); @@ -1481,6 +1486,7 @@ mod tests { ahead_behind: Some((1, 0)), space: None, }], + cache_updates: Vec::new(), }); assert!(app.render_dirty.load(Ordering::Acquire)); @@ -2915,7 +2921,7 @@ mod tests { app.next_auto_update_check = Some(now + Duration::from_secs(6)); assert_eq!( - app.next_headless_loop_deadline(now, false), + app.next_headless_loop_deadline_with_git_refresh(now, false, true), app.session_save_deadline ); } @@ -2932,7 +2938,10 @@ mod tests { app.session_save_deadline = None; app.state.workspaces.clear(); - assert_eq!(app.next_headless_loop_deadline(now, false), None); + assert_eq!( + app.next_headless_loop_deadline_with_git_refresh(now, false, true), + None + ); } #[test] diff --git a/src/app/runtime.rs b/src/app/runtime.rs index 62b803f3..ad90a267 100644 --- a/src/app/runtime.rs +++ b/src/app/runtime.rs @@ -8,7 +8,35 @@ use super::{ RESIZE_POLL_INTERVAL, SELECTION_AUTOSCROLL_INTERVAL, }; use crate::events::AppEvent; -use crate::workspace::{Workspace, WorkspaceGitStatus}; +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, + pub(crate) targets: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct WorkspaceGitRefreshOutput { + pub(crate) results: Vec, + pub(crate) cache_updates: Vec<(std::path::PathBuf, GitStatusCacheEntry)>, +} impl App { pub(crate) fn shutdown_detached_terminal_runtimes(&mut self) { @@ -394,15 +422,7 @@ impl App { return; } - let workspaces: Vec<_> = self - .state - .workspaces - .iter() - .filter_map(|ws| { - ws.resolved_identity_cwd_from(&self.state.terminals, &self.terminal_runtimes) - .map(|cwd| (ws.id.clone(), cwd)) - }) - .collect(); + let workspaces = self.workspace_git_refresh_items(); if workspaces.is_empty() { self.last_git_remote_status_refresh = now; @@ -411,32 +431,43 @@ impl App { 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 results = workspaces - .into_iter() - .map(|(workspace_id, resolved_identity_cwd)| { - Workspace::git_status_for_cwd(workspace_id, resolved_identity_cwd) - }) - .collect::>(); - let _ = event_tx.blocking_send(AppEvent::GitStatusRefreshed { results }); + 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 { (!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 { - self.next_loop_deadline_with_resize_poll(now, needs_render, true) + self.next_loop_deadline_with_resize_poll(now, needs_render, true, true) } - pub(crate) fn next_headless_loop_deadline( + pub(crate) fn next_headless_loop_deadline_with_git_refresh( &self, now: Instant, needs_render: bool, + include_git_refresh: bool, ) -> Option { - self.next_loop_deadline_with_resize_poll(now, needs_render, false) + self.next_loop_deadline_with_resize_poll(now, needs_render, false, include_git_refresh) } fn next_loop_deadline_with_resize_poll( @@ -444,6 +475,7 @@ impl App { now: Instant, needs_render: bool, include_resize_poll: bool, + include_git_refresh: bool, ) -> Option { let render_deadline = if needs_render { self.last_render_at @@ -459,7 +491,9 @@ impl App { self.toast_deadline, self.copy_feedback_deadline, self.next_animation_tick, - self.git_refresh_deadline(), + include_git_refresh + .then(|| self.git_refresh_deadline()) + .flatten(), self.next_auto_update_check, self.agent_metadata_deadline, self.session_save_deadline, @@ -472,6 +506,24 @@ impl App { .min() } + fn workspace_git_refresh_items(&self) -> Vec { + 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) } @@ -497,11 +549,69 @@ impl App { } } +pub(crate) fn deduplicate_git_refresh_items( + items: Vec, + cache: &HashMap, +) -> Vec { + let mut indexes = HashMap::::new(); + let mut jobs = Vec::::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, + cache: &HashMap, +) -> 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, + } +} + #[cfg(test)] mod tests { use super::*; use crate::app::state; use crate::workspace::Workspace; + use std::path::PathBuf; fn test_app_with_pane() -> (super::super::App, crate::layout::PaneId) { let mut app = super::super::App::new( @@ -525,6 +635,131 @@ 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. diff --git a/src/client/mod.rs b/src/client/mod.rs index 3e146f47..0a1b9507 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -31,9 +31,9 @@ use tracing::{debug, info, warn}; use crate::protocol::render_ansi; use crate::protocol::{ - self, AttachScrollDirection, AttachScrollSource, ClientKeybindings, ClientMessage, NotifyKind, - RenderEncoding, ServerMessage, MAX_CLIPBOARD_IMAGE_PAYLOAD, MAX_FRAME_SIZE, - MAX_GRAPHICS_FRAME_SIZE, PROTOCOL_VERSION, + self, AttachScrollDirection, AttachScrollSource, ClientKeybindings, ClientLaunchMode, + ClientMessage, NotifyKind, RenderEncoding, ServerMessage, MAX_CLIPBOARD_IMAGE_PAYLOAD, + MAX_FRAME_SIZE, MAX_GRAPHICS_FRAME_SIZE, PROTOCOL_VERSION, }; use crate::server::socket_paths::client_socket_path; @@ -420,6 +420,7 @@ fn do_handshake( cell_width_px: u32, cell_height_px: u32, requested_encoding: RenderEncoding, + direct_attach_requested: bool, ) -> Result { stream .set_nonblocking(false) @@ -434,6 +435,11 @@ fn do_handshake( cell_height_px, requested_encoding, keybindings: requested_keybindings(), + launch_mode: if direct_attach_requested { + ClientLaunchMode::TerminalAttach + } else { + ClientLaunchMode::App + }, }; protocol::write_message(stream, &hello) .map_err(|e| ClientError::ConnectionFailed(io::Error::other(e.to_string())))?; @@ -549,6 +555,7 @@ fn run_client_with_mode( cell_width_px, cell_height_px, requested_encoding, + direct_attach_requested, ) { Ok(encoding) => encoding, Err(err) => { diff --git a/src/events.rs b/src/events.rs index 4bbfb219..84378b65 100644 --- a/src/events.rs +++ b/src/events.rs @@ -7,7 +7,7 @@ use std::time::Instant; use crate::detect::{Agent, AgentState}; use crate::layout::PaneId; -use crate::workspace::WorkspaceGitStatus; +use crate::workspace::{GitStatusCacheEntry, WorkspaceGitStatus}; #[derive(Debug)] pub struct WorktreeAddResult { @@ -89,7 +89,10 @@ pub enum AppEvent { /// re-emits it through herdr's own clipboard writer. ClipboardWrite { content: Vec }, /// Background git status refresh completed for workspaces. - GitStatusRefreshed { results: Vec }, + GitStatusRefreshed { + results: Vec, + cache_updates: Vec<(std::path::PathBuf, GitStatusCacheEntry)>, + }, /// Background `git worktree add` completed. WorktreeAddFinished(WorktreeAddResult), /// Background `git worktree remove` completed. diff --git a/src/protocol/wire.rs b/src/protocol/wire.rs index e6c5624c..576c4135 100644 --- a/src/protocol/wire.rs +++ b/src/protocol/wire.rs @@ -13,7 +13,7 @@ use serde::{Deserialize, Serialize}; // --------------------------------------------------------------------------- /// Current protocol version. Bumped when wire format changes incompatibly. -pub const PROTOCOL_VERSION: u32 = 11; +pub const PROTOCOL_VERSION: u32 = 12; /// Maximum allowed frame payload size (2 MB). Frames larger than this are /// rejected to prevent denial-of-service via oversized length prefixes. @@ -52,6 +52,15 @@ pub enum ClientKeybindings { Local { keys_toml: String }, } +/// Client behavior requested at connection time. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ClientLaunchMode { + /// Full app client. + App, + /// Direct terminal attach client. + TerminalAttach, +} + /// Messages sent from the client to the server over the client protocol socket. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum ClientMessage { @@ -71,6 +80,8 @@ pub enum ClientMessage { requested_encoding: RenderEncoding, /// Keybinding profile requested by the client. keybindings: ClientKeybindings, + /// Whether this connection will render the full app or attach directly to a pane terminal. + launch_mode: ClientLaunchMode, }, /// Raw input bytes read from the client's stdin. @@ -651,6 +662,7 @@ mod tests { cell_height_px: 16, requested_encoding: RenderEncoding::SemanticFrame, keybindings: ClientKeybindings::Server, + launch_mode: ClientLaunchMode::App, }; let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); let (decoded, _): (ClientMessage, _) = @@ -952,6 +964,7 @@ mod tests { cell_height_px: 16, requested_encoding: RenderEncoding::SemanticFrame, keybindings: ClientKeybindings::Server, + launch_mode: ClientLaunchMode::App, }; let mut buf = Vec::new(); write_message(&mut buf, &msg).unwrap(); @@ -1025,6 +1038,7 @@ mod tests { cell_height_px: 16, requested_encoding: RenderEncoding::SemanticFrame, keybindings: ClientKeybindings::Server, + launch_mode: ClientLaunchMode::App, }, 1 => ClientMessage::Input { data: vec![(i % 256) as u8; (i as usize % 50) + 1], @@ -1460,6 +1474,7 @@ mod tests { cell_height_px: 16, requested_encoding: RenderEncoding::SemanticFrame, keybindings: ClientKeybindings::Server, + launch_mode: ClientLaunchMode::App, }; let mut buf = Vec::new(); write_message(&mut buf, &msg).unwrap(); @@ -1494,6 +1509,7 @@ mod tests { cell_height_px: 16, requested_encoding: RenderEncoding::SemanticFrame, keybindings: ClientKeybindings::Server, + launch_mode: ClientLaunchMode::App, }, ClientMessage::Input { data: b"hello world".to_vec(), diff --git a/src/server/client_transport.rs b/src/server/client_transport.rs index 36334fda..931f1100 100644 --- a/src/server/client_transport.rs +++ b/src/server/client_transport.rs @@ -14,8 +14,8 @@ use tokio::sync::mpsc; use tracing::{debug, warn}; use crate::protocol::{ - self, AttachScrollDirection, AttachScrollSource, ClientKeybindings, ClientMessage, - RenderEncoding, ServerMessage, MAX_CLIPBOARD_IMAGE_PAYLOAD, MAX_FRAME_SIZE, + self, AttachScrollDirection, AttachScrollSource, ClientKeybindings, ClientLaunchMode, + ClientMessage, RenderEncoding, ServerMessage, MAX_CLIPBOARD_IMAGE_PAYLOAD, MAX_FRAME_SIZE, MAX_GRAPHICS_FRAME_SIZE, PROTOCOL_VERSION, }; @@ -57,6 +57,7 @@ pub(crate) enum ServerEvent { cell_height_px: u32, render_encoding: RenderEncoding, keybindings: Option>, + direct_attach_requested: bool, writer: ClientWriter, }, /// A client sent an input message. @@ -159,68 +160,77 @@ pub(crate) fn handle_client_handshake( } }; - let (client_cols, client_rows, cell_width_px, cell_height_px, render_encoding, keybindings) = - match hello { - ClientMessage::Hello { - version, - cols, - rows, + let ( + client_cols, + client_rows, + cell_width_px, + cell_height_px, + render_encoding, + keybindings, + direct_attach_requested, + ) = match hello { + ClientMessage::Hello { + version, + cols, + rows, + cell_width_px, + cell_height_px, + requested_encoding, + keybindings, + launch_mode, + } => { + // Version check. + match protocol::check_client_version(version) { + protocol::VersionCheck::Compatible => {} + protocol::VersionCheck::Incompatible(reason) => { + // Send rejection Welcome. + let welcome = ServerMessage::Welcome { + version: PROTOCOL_VERSION, + encoding: RenderEncoding::SemanticFrame, + error: Some(reason), + }; + let _ = protocol::write_message(&mut stream, &welcome); + return Ok(()); + } + } + + let keybindings = match parse_client_keybindings(keybindings) { + Ok(keybindings) => keybindings, + Err(error) => { + let welcome = ServerMessage::Welcome { + version: PROTOCOL_VERSION, + encoding: RenderEncoding::SemanticFrame, + error: Some(error), + }; + let _ = protocol::write_message(&mut stream, &welcome); + return Ok(()); + } + }; + + // Clamp size. + let (clamped_cols, clamped_rows) = clamp_terminal_size(cols, rows); + ( + clamped_cols, + clamped_rows, cell_width_px, cell_height_px, requested_encoding, keybindings, - } => { - // Version check. - match protocol::check_client_version(version) { - protocol::VersionCheck::Compatible => {} - protocol::VersionCheck::Incompatible(reason) => { - // Send rejection Welcome. - let welcome = ServerMessage::Welcome { - version: PROTOCOL_VERSION, - encoding: RenderEncoding::SemanticFrame, - error: Some(reason), - }; - let _ = protocol::write_message(&mut stream, &welcome); - return Ok(()); - } - } - - let keybindings = match parse_client_keybindings(keybindings) { - Ok(keybindings) => keybindings, - Err(error) => { - let welcome = ServerMessage::Welcome { - version: PROTOCOL_VERSION, - encoding: RenderEncoding::SemanticFrame, - error: Some(error), - }; - let _ = protocol::write_message(&mut stream, &welcome); - return Ok(()); - } - }; - - // Clamp size. - let (clamped_cols, clamped_rows) = clamp_terminal_size(cols, rows); - ( - clamped_cols, - clamped_rows, - cell_width_px, - cell_height_px, - requested_encoding, - keybindings, - ) - } - _ => { - // First message must be Hello. - debug!(client_id, "first message was not Hello, closing"); - let welcome = ServerMessage::Welcome { - version: PROTOCOL_VERSION, - encoding: RenderEncoding::SemanticFrame, - error: Some("expected Hello as first message".to_owned()), - }; - let _ = protocol::write_message(&mut stream, &welcome); - return Ok(()); - } - }; + launch_mode == ClientLaunchMode::TerminalAttach, + ) + } + _ => { + // First message must be Hello. + debug!(client_id, "first message was not Hello, closing"); + let welcome = ServerMessage::Welcome { + version: PROTOCOL_VERSION, + encoding: RenderEncoding::SemanticFrame, + error: Some("expected Hello as first message".to_owned()), + }; + let _ = protocol::write_message(&mut stream, &welcome); + return Ok(()); + } + }; // Send Welcome. let welcome = ServerMessage::Welcome { @@ -250,6 +260,7 @@ pub(crate) fn handle_client_handshake( cell_height_px, render_encoding, keybindings, + direct_attach_requested, writer, }); @@ -576,6 +587,7 @@ new_tab = "ctrl+notakey" cell_height_px: 16, requested_encoding: RenderEncoding::TerminalAnsi, keybindings: ClientKeybindings::Server, + launch_mode: ClientLaunchMode::App, }, ) .expect("write hello"); @@ -607,6 +619,7 @@ new_tab = "ctrl+notakey" cell_height_px, render_encoding, keybindings, + direct_attach_requested, writer, } => { assert_eq!(client_id, 42); @@ -614,6 +627,70 @@ new_tab = "ctrl+notakey" assert_eq!((cell_width_px, cell_height_px), (8, 16)); assert_eq!(render_encoding, RenderEncoding::TerminalAnsi); assert!(keybindings.is_none()); + assert!(!direct_attach_requested); + drop(writer); + } + other => panic!("expected ClientConnected, got {other:?}"), + } + + drop(client_stream); + should_quit.store(true, Ordering::Release); + handle + .join() + .expect("handshake thread join") + .expect("handshake thread result"); + } + + #[test] + fn handshake_marks_terminal_attach_launch_mode() { + let (mut client_stream, server_stream) = UnixStream::pair().expect("socket pair"); + let (server_event_tx, mut server_event_rx) = mpsc::channel(4); + let should_quit = Arc::new(AtomicBool::new(false)); + let handshake_quit = should_quit.clone(); + let handle = std::thread::spawn(move || { + handle_client_handshake(server_stream, 42, &server_event_tx, &handshake_quit) + }); + + protocol::write_message( + &mut client_stream, + &ClientMessage::Hello { + version: PROTOCOL_VERSION, + cols: 100, + rows: 30, + cell_width_px: 8, + cell_height_px: 16, + requested_encoding: RenderEncoding::TerminalAnsi, + keybindings: ClientKeybindings::Server, + launch_mode: ClientLaunchMode::TerminalAttach, + }, + ) + .expect("write hello"); + + let welcome: ServerMessage = + protocol::read_message(&mut client_stream, MAX_FRAME_SIZE).expect("read welcome"); + match welcome { + ServerMessage::Welcome { + version, + encoding, + error, + } => { + assert_eq!(version, PROTOCOL_VERSION); + assert_eq!(encoding, RenderEncoding::TerminalAnsi); + assert_eq!(error, None); + } + other => panic!("expected Welcome, got {other:?}"), + } + + match server_event_rx + .blocking_recv() + .expect("client connected event") + { + ServerEvent::ClientConnected { + direct_attach_requested, + writer, + .. + } => { + assert!(direct_attach_requested); drop(writer); } other => panic!("expected ClientConnected, got {other:?}"), diff --git a/src/server/clients.rs b/src/server/clients.rs index 026dd378..611d87eb 100644 --- a/src/server/clients.rs +++ b/src/server/clients.rs @@ -23,6 +23,8 @@ pub(crate) type RenderTarget = ( pub(crate) struct ClientConnection { /// Whether this connection is the full app client or a direct terminal attach. pub(crate) mode: ClientConnectionMode, + /// True after the handshake for clients that will switch into direct terminal attach mode. + pub(crate) pending_terminal_attach: bool, /// Client-local app keybindings. None means use the server's keybindings. pub(crate) keybindings: Option>, /// The client's terminal size after clamping. @@ -73,6 +75,7 @@ impl ClientConnection { outer_terminal_focus, last_activity, render_encoding, + false, writer, ) } @@ -86,10 +89,12 @@ impl ClientConnection { outer_terminal_focus: Option, last_activity: u64, render_encoding: RenderEncoding, + pending_terminal_attach: bool, writer: Option, ) -> Self { Self { mode, + pending_terminal_attach, keybindings, terminal_size, cell_size, @@ -112,6 +117,10 @@ impl ClientConnection { self.graphics_surface_reset_pending = true; } + pub(crate) fn is_full_app_client(&self) -> bool { + matches!(self.mode, ClientConnectionMode::App) && !self.pending_terminal_attach + } + pub(crate) fn request_semantic_redraw_after_input(&mut self) { self.render_state.reset_semantic_input_baseline(); } @@ -168,7 +177,7 @@ pub(crate) fn events_include_interaction(events: &[crate::raw_input::RawInputEve pub(crate) fn latest_app_client(clients: &HashMap) -> Option { clients .iter() - .filter(|(_, client)| matches!(client.mode, ClientConnectionMode::App)) + .filter(|(_, client)| client.is_full_app_client()) .max_by_key(|(_, client)| client.last_activity) .map(|(&client_id, _)| client_id) } @@ -194,7 +203,11 @@ pub(crate) fn render_targets( ) -> Vec { let mut targets: Vec = clients .iter() - .filter(|(_, client)| client.writer.is_some()) + .filter(|(_, client)| { + client.writer.is_some() + && (client.is_full_app_client() + || matches!(client.mode, ClientConnectionMode::TerminalAttach { .. })) + }) .map(|(&client_id, client)| { ( client_id, diff --git a/src/server/headless.rs b/src/server/headless.rs index f59201e0..3d273b1c 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -423,7 +423,11 @@ impl HeadlessServer { // 8. Wait for next event. let next_deadline = self .app - .next_headless_loop_deadline(now, needs_render) + .next_headless_loop_deadline_with_git_refresh( + now, + needs_render, + self.has_app_client(), + ) .map(|deadline| deadline.min(now + CLIENT_ACCEPT_POLL_INTERVAL)) .or(Some(now + CLIENT_ACCEPT_POLL_INTERVAL)); let event = { @@ -901,6 +905,17 @@ impl HeadlessServer { changed } + fn app_client_count(&self) -> usize { + self.clients + .values() + .filter(|client| client.is_full_app_client() && client.writer.is_some()) + .count() + } + + fn has_app_client(&self) -> bool { + self.app_client_count() > 0 + } + fn remove_client(&mut self, client_id: u64) -> bool { let was_foreground = self.foreground_client_id == Some(client_id); self.send_client_graphics_cleanup(client_id); @@ -1619,6 +1634,7 @@ impl HeadlessServer { client.mode = ClientConnectionMode::TerminalAttach { terminal_id: terminal_id.clone(), }; + client.pending_terminal_attach = false; client.render_state.reset_baseline(); client.last_activity = stamp; let was_foreground = self.foreground_client_id == Some(client_id); @@ -1655,6 +1671,7 @@ impl HeadlessServer { keybindings, writer, render_encoding, + direct_attach_requested, } => { if self.handoff_in_progress { if let Ok(message) = @@ -1669,6 +1686,7 @@ impl HeadlessServer { } return false; } + let first_app_client = !direct_attach_requested && self.app_client_count() == 0; info!( client_id, cols, @@ -1693,10 +1711,16 @@ impl HeadlessServer { None, last_activity, render_encoding, + direct_attach_requested, Some(writer), ), ); - self.foreground_client_id = Some(client_id); + if !direct_attach_requested { + self.foreground_client_id = Some(client_id); + } + if first_app_client { + self.app.mark_git_status_refresh_due(Instant::now()); + } self.sync_foreground_client_state(); self.resize_shared_runtime_to_effective_size(); self.nudge_handoff_panes_on_first_client_attach(); @@ -2190,7 +2214,7 @@ impl HeadlessServer { let mut broken_clients: Vec = Vec::new(); for (&client_id, client) in &mut self.clients { - if !matches!(client.mode, ClientConnectionMode::App) { + if !client.is_full_app_client() { continue; } if client.host_mouse_capture_active == Some(enabled) { @@ -2484,7 +2508,9 @@ impl HeadlessServer { changed |= self.app.clear_due_selection_highlight(now); - self.app.start_git_status_refresh_if_due(now); + if self.has_app_client() { + self.app.start_git_status_refresh_if_due(now); + } if self .app @@ -3011,6 +3037,7 @@ new_tab = "prefix+t" cell_height_px: 0, render_encoding: RenderEncoding::SemanticFrame, keybindings: Some(Box::new(local_keybindings)), + direct_attach_requested: false, writer: writer_a, })); assert_eq!( @@ -3034,6 +3061,7 @@ new_tab = "prefix+t" cell_height_px: 0, render_encoding: RenderEncoding::SemanticFrame, keybindings: None, + direct_attach_requested: false, writer: writer_b, })); assert_eq!( @@ -3073,6 +3101,7 @@ new_tab = "prefix+t" cell_height_px: 0, render_encoding: RenderEncoding::SemanticFrame, keybindings: Some(Box::new(local_keybindings)), + direct_attach_requested: false, writer: writer_a, })); assert_eq!(server.app.state.config_diagnostic, without_keybindings); @@ -3085,6 +3114,7 @@ new_tab = "prefix+t" cell_height_px: 0, render_encoding: RenderEncoding::SemanticFrame, keybindings: None, + direct_attach_requested: false, writer: writer_b, })); assert_eq!( @@ -3127,6 +3157,7 @@ next_tab = "" cell_height_px: 0, render_encoding: RenderEncoding::SemanticFrame, keybindings: Some(Box::new(local_keybindings)), + direct_attach_requested: false, writer, })); server.app.state.mode = crate::app::Mode::Settings; @@ -3200,6 +3231,7 @@ next_tab = "" cell_height_px: 0, render_encoding: RenderEncoding::SemanticFrame, keybindings: Some(Box::new(local_config.live_keybinds().unwrap())), + direct_attach_requested: false, writer: writer_a, })); server.app.state.mode = crate::app::Mode::Settings; @@ -3219,6 +3251,7 @@ next_tab = "" cell_height_px: 0, render_encoding: RenderEncoding::SemanticFrame, keybindings: None, + direct_attach_requested: false, writer: writer_b, })); assert_eq!( @@ -3251,6 +3284,7 @@ next_tab = "" cell_height_px: 0, render_encoding: RenderEncoding::TerminalAnsi, keybindings: None, + direct_attach_requested: true, writer, })); assert!(server.clients.contains_key(&7)); @@ -3270,6 +3304,115 @@ next_tab = "" ); } + fn app_client_marks_git_refresh_due_on_first_attach(render_encoding: RenderEncoding) { + let mut server = test_headless_server(); + server + .app + .state + .workspaces + .push(crate::workspace::Workspace::test_new("test")); + let future = Instant::now() + Duration::from_secs(60); + server.app.last_git_remote_status_refresh = future; + let (writer, _control_rx, _render_rx) = test_client_writer(); + + assert!(server.handle_server_event(ServerEvent::ClientConnected { + client_id: 7, + cols: 80, + rows: 24, + cell_width_px: 0, + cell_height_px: 0, + render_encoding, + keybindings: None, + direct_attach_requested: false, + writer, + })); + + assert!(server.has_app_client()); + assert!(server + .app + .git_refresh_deadline() + .is_some_and(|deadline| deadline <= Instant::now())); + } + + #[test] + fn terminal_ansi_app_client_enables_headless_git_refresh() { + app_client_marks_git_refresh_due_on_first_attach(RenderEncoding::TerminalAnsi); + } + + #[test] + fn pending_terminal_attach_client_does_not_enable_headless_git_refresh() { + let mut server = test_headless_server(); + server + .app + .state + .workspaces + .push(crate::workspace::Workspace::test_new("test")); + let (writer, _control_rx, _render_rx) = test_client_writer(); + + assert!(server.handle_server_event(ServerEvent::ClientConnected { + client_id: 7, + cols: 80, + rows: 24, + cell_width_px: 0, + cell_height_px: 0, + render_encoding: RenderEncoding::TerminalAnsi, + keybindings: None, + direct_attach_requested: true, + writer, + })); + + assert!(!server.has_app_client()); + assert_eq!( + server.app.next_headless_loop_deadline_with_git_refresh( + Instant::now(), + false, + server.has_app_client() + ), + None + ); + } + + #[test] + fn writerless_app_client_does_not_enable_headless_git_refresh() { + let mut server = test_headless_server(); + server + .app + .state + .workspaces + .push(crate::workspace::Workspace::test_new("test")); + let (writer, _control_rx, _render_rx) = test_client_writer(); + + assert!(server.handle_server_event(ServerEvent::ClientConnected { + client_id: 7, + cols: 80, + rows: 24, + cell_width_px: 0, + cell_height_px: 0, + render_encoding: RenderEncoding::SemanticFrame, + keybindings: None, + direct_attach_requested: false, + writer, + })); + assert!(server.has_app_client()); + + server.clients.get_mut(&7).expect("client").writer = None; + + assert!(!server.has_app_client()); + assert_eq!( + server.app.next_headless_loop_deadline_with_git_refresh( + Instant::now(), + false, + server.has_app_client() + ), + None + ); + } + + #[test] + fn semantic_app_client_marks_git_refresh_due_on_first_attach() { + app_client_marks_git_refresh_due_on_first_attach(RenderEncoding::SemanticFrame); + } + #[test] fn terminal_attach_client_exits_when_attached_pane_dies() { let mut server = test_headless_server(); @@ -3292,6 +3435,7 @@ next_tab = "" cell_height_px: 0, render_encoding: RenderEncoding::TerminalAnsi, keybindings: None, + direct_attach_requested: true, writer, })); assert!( @@ -4334,6 +4478,7 @@ next_tab = "" cell_height_px: 0, render_encoding: RenderEncoding::TerminalAnsi, keybindings: None, + direct_attach_requested: true, writer, })); assert!( diff --git a/src/workspace.rs b/src/workspace.rs index 4bab61a7..8a660b02 100644 --- a/src/workspace.rs +++ b/src/workspace.rs @@ -19,9 +19,13 @@ mod aggregate; mod git; mod tab; +#[cfg(test)] use self::git::git_ahead_behind; pub use self::{ - git::{derive_label_from_cwd, git_branch, git_space_metadata, GitSpaceMetadata}, + git::{ + derive_label_from_cwd, git_branch, git_space_metadata, git_status_cache_key, + GitSpaceMetadata, GitStatusCacheEntry, + }, tab::Tab, }; @@ -43,6 +47,29 @@ pub struct WorkspaceGitStatus { pub space: Option, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorkspaceGitStatusSnapshot { + pub branch: Option, + pub ahead_behind: Option<(usize, usize)>, + pub space: Option, +} + +impl WorkspaceGitStatusSnapshot { + pub fn into_workspace_status( + self, + workspace_id: String, + resolved_identity_cwd: PathBuf, + ) -> WorkspaceGitStatus { + WorkspaceGitStatus { + workspace_id, + resolved_identity_cwd, + branch: self.branch, + ahead_behind: self.ahead_behind, + space: self.space, + } + } +} + static NEXT_WORKSPACE_ID: AtomicU64 = AtomicU64::new(1); pub(crate) fn generate_workspace_id() -> String { @@ -608,17 +635,11 @@ impl Workspace { self.cached_git_space = cwd.as_deref().and_then(git_space_metadata); } - pub fn git_status_for_cwd( - workspace_id: String, - resolved_identity_cwd: PathBuf, - ) -> WorkspaceGitStatus { - WorkspaceGitStatus { - branch: git_branch(&resolved_identity_cwd), - ahead_behind: git_ahead_behind(&resolved_identity_cwd), - space: git_space_metadata(&resolved_identity_cwd), - workspace_id, - resolved_identity_cwd, - } + pub fn git_status_snapshot_for_cwd_with_cache( + resolved_identity_cwd: &std::path::Path, + cached: Option<&GitStatusCacheEntry>, + ) -> (WorkspaceGitStatusSnapshot, Option) { + self::git::git_status_snapshot_for_cwd(resolved_identity_cwd, cached) } pub fn find_tab_index_for_pane(&self, pane_id: PaneId) -> Option { diff --git a/src/workspace/git/config.rs b/src/workspace/git/config.rs new file mode 100644 index 00000000..9783387c --- /dev/null +++ b/src/workspace/git/config.rs @@ -0,0 +1,659 @@ +use std::path::{Path, PathBuf}; + +use super::discovery::{canonicalize_best_effort_path, GitWorktreeInfo}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct BranchConfig { + pub(super) remote: String, + pub(super) merge_ref: String, + fetch_refspecs: Vec<(String, String)>, + remote_urls: Vec<(String, String)>, +} + +pub(super) fn read_branch_config(info: &GitWorktreeInfo, branch: &str) -> Option { + read_branch_config_with_user_paths(info, branch, git_user_config_paths()) +} + +pub(super) fn read_branch_config_with_user_paths( + info: &GitWorktreeInfo, + branch: &str, + user_config_paths: Vec, +) -> Option { + let worktree_config_enabled = + worktree_config_enabled(&info.git_common_dir.join("config"), info); + let config_paths = user_config_paths + .into_iter() + .chain(std::iter::once(info.git_common_dir.join("config"))) + .collect::>(); + let mut remote_urls = Vec::new(); + for path in &config_paths { + let mut include_stack = Vec::new(); + collect_remote_urls(path, info, branch, &mut remote_urls, &mut include_stack); + } + let mut config = BranchConfig { + remote: String::new(), + merge_ref: String::new(), + fetch_refspecs: Vec::new(), + remote_urls, + }; + for path in config_paths { + let mut include_stack = Vec::new(); + merge_git_config(&mut config, &path, branch, info, true, &mut include_stack); + } + if worktree_config_enabled { + let mut include_stack = Vec::new(); + merge_git_config( + &mut config, + &info.git_dir.join("config.worktree"), + branch, + info, + false, + &mut include_stack, + ); + } + (!config.remote.is_empty() && !config.merge_ref.is_empty()).then_some(config) +} + +fn git_user_config_paths() -> Vec { + let mut paths = Vec::new(); + if let Some(xdg_config_home) = std::env::var_os("XDG_CONFIG_HOME") { + paths.push(PathBuf::from(xdg_config_home).join("git/config")); + } else if let Some(home) = std::env::var_os("HOME") { + paths.push(PathBuf::from(home).join(".config/git/config")); + } + if let Some(home) = std::env::var_os("HOME") { + paths.push(PathBuf::from(home).join(".gitconfig")); + } + paths +} + +fn worktree_config_enabled(path: &Path, info: &GitWorktreeInfo) -> bool { + let Ok(contents) = std::fs::read_to_string(path) else { + return false; + }; + let mut section = ConfigSection::Other; + let mut enabled = false; + for raw_line in contents.lines() { + let line = raw_line.trim(); + if let Some(section_name) = extract_config_section(line) { + let is_extensions = section_name.eq_ignore_ascii_case("extensions"); + section = if is_extensions { + ConfigSection::Extensions + } else { + parse_config_section( + section_name, + "", + info, + path, + &BranchConfig { + remote: String::new(), + merge_ref: String::new(), + fetch_refspecs: Vec::new(), + remote_urls: Vec::new(), + }, + ) + }; + continue; + } + if let Some((key, value)) = line.split_once('=') { + let key = key.trim(); + let value = normalize_config_value(value); + match §ion { + ConfigSection::Extensions if key.eq_ignore_ascii_case("worktreeConfig") => { + enabled = matches!( + value.to_ascii_lowercase().as_str(), + "true" | "1" | "yes" | "on" + ); + } + _ => {} + } + continue; + } + if matches!(section, ConfigSection::Extensions) + && line.eq_ignore_ascii_case("worktreeConfig") + { + enabled = true; + } + } + enabled +} + +fn collect_remote_urls( + path: &Path, + info: &GitWorktreeInfo, + branch: &str, + remote_urls: &mut Vec<(String, String)>, + include_stack: &mut Vec, +) { + let path = canonicalize_best_effort_path(path); + if include_stack.contains(&path) { + return; + } + include_stack.push(path.clone()); + let Ok(contents) = std::fs::read_to_string(&path) else { + include_stack.pop(); + return; + }; + let mut section = ConfigSection::Other; + let dummy_config = BranchConfig { + remote: String::new(), + merge_ref: String::new(), + fetch_refspecs: Vec::new(), + remote_urls: remote_urls.clone(), + }; + for raw_line in contents.lines() { + let line = raw_line.trim(); + if let Some(section_name) = extract_config_section(line) { + section = parse_config_section(section_name, branch, info, &path, &dummy_config); + continue; + } + let Some((key, value)) = line.split_once('=') else { + continue; + }; + let key = key.trim(); + let value = normalize_config_value(value); + match §ion { + ConfigSection::Remote(remote) if key.eq_ignore_ascii_case("url") => { + remote_urls.push((remote.clone(), value)); + } + ConfigSection::Include if key.eq_ignore_ascii_case("path") => { + collect_remote_urls( + &resolve_include_path(&path, &value), + info, + branch, + remote_urls, + include_stack, + ); + } + ConfigSection::IncludeIf(IncludeIfMode::Enabled) + if key.eq_ignore_ascii_case("path") => + { + collect_remote_urls( + &resolve_include_path(&path, &value), + info, + branch, + remote_urls, + include_stack, + ); + } + _ => {} + } + } + include_stack.pop(); +} + +fn merge_git_config( + config: &mut BranchConfig, + path: &Path, + branch: &str, + info: &GitWorktreeInfo, + collect_hasconfig_urls: bool, + include_stack: &mut Vec, +) { + let path = canonicalize_best_effort_path(path); + if include_stack.contains(&path) { + return; + } + include_stack.push(path.clone()); + let Ok(contents) = std::fs::read_to_string(&path) else { + include_stack.pop(); + return; + }; + let mut section = ConfigSection::Other; + + for raw_line in contents.lines() { + let line = raw_line.trim(); + if let Some(section_name) = extract_config_section(line) { + section = parse_config_section(section_name, branch, info, &path, config); + continue; + } + let Some((key, value)) = line.split_once('=') else { + continue; + }; + let key = key.trim(); + let value = normalize_config_value(value); + match §ion { + ConfigSection::Branch if key.eq_ignore_ascii_case("remote") => config.remote = value, + ConfigSection::Branch if key.eq_ignore_ascii_case("merge") => config.merge_ref = value, + ConfigSection::Remote(remote) if key.eq_ignore_ascii_case("fetch") => { + config.fetch_refspecs.push((remote.clone(), value)); + } + ConfigSection::Remote(remote) + if collect_hasconfig_urls && key.eq_ignore_ascii_case("url") => + { + config.remote_urls.push((remote.clone(), value)); + } + ConfigSection::Include if key.eq_ignore_ascii_case("path") => { + let include_path = resolve_include_path(&path, &value); + merge_git_config( + config, + &include_path, + branch, + info, + collect_hasconfig_urls, + include_stack, + ); + } + ConfigSection::IncludeIf(IncludeIfMode::Enabled) + if key.eq_ignore_ascii_case("path") => + { + let include_path = resolve_include_path(&path, &value); + merge_git_config( + config, + &include_path, + branch, + info, + collect_hasconfig_urls, + include_stack, + ); + } + ConfigSection::IncludeIf(IncludeIfMode::HasConfig) + if key.eq_ignore_ascii_case("path") => + { + let include_path = resolve_include_path(&path, &value); + if !included_config_defines_remote_url( + &include_path, + branch, + info, + config, + include_stack, + ) { + merge_git_config( + config, + &include_path, + branch, + info, + collect_hasconfig_urls, + include_stack, + ); + } + } + _ => {} + } + } + include_stack.pop(); +} + +enum ConfigSection { + Branch, + Extensions, + Include, + IncludeIf(IncludeIfMode), + Remote(String), + Other, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum IncludeIfMode { + Disabled, + Enabled, + HasConfig, +} + +fn extract_config_section(line: &str) -> Option<&str> { + if !line.starts_with('[') { + return None; + } + let mut in_quotes = false; + let mut escaped = false; + for (index, ch) in line.char_indices().skip(1) { + if escaped { + escaped = false; + continue; + } + match ch { + '\\' if in_quotes => escaped = true, + '"' => in_quotes = !in_quotes, + ']' if !in_quotes => { + let rest = line[index + 1..].trim(); + if rest.is_empty() || rest.starts_with('#') || rest.starts_with(';') { + return Some(&line[1..index]); + } + return None; + } + _ => {} + } + } + None +} + +fn parse_config_section( + section: &str, + branch: &str, + info: &GitWorktreeInfo, + config_path: &Path, + config: &BranchConfig, +) -> ConfigSection { + if let Some(name) = quoted_config_subsection(section, "branch") { + return if name == branch { + ConfigSection::Branch + } else { + ConfigSection::Other + }; + } + if let Some(name) = quoted_config_subsection(section, "remote") { + return ConfigSection::Remote(name.to_string()); + } + if section.eq_ignore_ascii_case("include") { + return ConfigSection::Include; + } + if let Some(condition) = quoted_config_subsection(section, "includeIf") { + return ConfigSection::IncludeIf(include_if_mode( + condition, + info, + config_path, + branch, + config, + )); + } + ConfigSection::Other +} + +fn include_if_mode( + condition: &str, + info: &GitWorktreeInfo, + config_path: &Path, + branch: &str, + config: &BranchConfig, +) -> IncludeIfMode { + let (case_insensitive, pattern) = if let Some(pattern) = condition.strip_prefix("gitdir/i:") { + (true, pattern) + } else if let Some(pattern) = condition.strip_prefix("gitdir:") { + (false, pattern) + } else if let Some(pattern) = condition.strip_prefix("onbranch:") { + let pattern = normalize_branch_include_pattern(pattern); + return if wildcard_match(&pattern, branch, false) { + IncludeIfMode::Enabled + } else { + IncludeIfMode::Disabled + }; + } else if let Some(pattern) = condition.strip_prefix("hasconfig:remote.*.url:") { + return if config + .remote_urls + .iter() + .any(|(_, url)| wildcard_match(pattern, url, false)) + { + IncludeIfMode::HasConfig + } else { + IncludeIfMode::Disabled + }; + } else { + return IncludeIfMode::Disabled; + }; + let pattern = normalize_gitdir_include_pattern(pattern, config_path); + let candidates = [ + info.git_dir.display().to_string(), + info.git_common_dir.display().to_string(), + info.repo_root.join(".git").display().to_string(), + ]; + if candidates + .iter() + .any(|candidate| wildcard_match(&pattern, candidate, case_insensitive)) + { + IncludeIfMode::Enabled + } else { + IncludeIfMode::Disabled + } +} + +fn included_config_defines_remote_url( + path: &Path, + branch: &str, + info: &GitWorktreeInfo, + config: &BranchConfig, + include_stack: &mut Vec, +) -> bool { + let path = canonicalize_best_effort_path(path); + if include_stack.contains(&path) { + return false; + } + include_stack.push(path.clone()); + let Ok(contents) = std::fs::read_to_string(&path) else { + include_stack.pop(); + return false; + }; + let mut section = ConfigSection::Other; + let mut defines_remote_url = false; + for raw_line in contents.lines() { + let line = raw_line.trim(); + if let Some(section_name) = extract_config_section(line) { + section = parse_config_section(section_name, branch, info, &path, config); + continue; + } + let Some((key, value)) = line.split_once('=') else { + continue; + }; + let key = key.trim(); + if matches!(section, ConfigSection::Remote(_)) && key.eq_ignore_ascii_case("url") { + defines_remote_url = true; + break; + } + let value = normalize_config_value(value); + match §ion { + ConfigSection::Include if key.eq_ignore_ascii_case("path") => { + let include_path = resolve_include_path(&path, &value); + if !included_config_defines_remote_url( + &include_path, + branch, + info, + config, + include_stack, + ) { + continue; + } + defines_remote_url = true; + break; + } + ConfigSection::IncludeIf(IncludeIfMode::Enabled | IncludeIfMode::HasConfig) + if key.eq_ignore_ascii_case("path") => + { + let include_path = resolve_include_path(&path, &value); + if !included_config_defines_remote_url( + &include_path, + branch, + info, + config, + include_stack, + ) { + continue; + } + defines_remote_url = true; + break; + } + _ => {} + } + } + include_stack.pop(); + defines_remote_url +} + +fn normalize_branch_include_pattern(pattern: &str) -> String { + if pattern.ends_with('/') { + format!("{pattern}**") + } else { + pattern.to_string() + } +} + +fn normalize_gitdir_include_pattern(pattern: &str, config_path: &Path) -> String { + let mut pattern = if let Some(rest) = pattern.strip_prefix("~/") { + std::env::var_os("HOME") + .map(PathBuf::from) + .unwrap_or_default() + .join(rest) + .display() + .to_string() + } else if let Some(rest) = pattern.strip_prefix("./") { + config_path + .parent() + .unwrap_or_else(|| Path::new(".")) + .join(rest) + .display() + .to_string() + } else if Path::new(pattern).is_absolute() { + pattern.to_string() + } else { + format!("**/{pattern}") + }; + if pattern.ends_with('/') { + pattern.push_str("**"); + } + pattern +} + +fn wildcard_match(pattern: &str, value: &str, case_insensitive: bool) -> bool { + let pattern = if case_insensitive { + pattern.to_ascii_lowercase() + } else { + pattern.to_string() + }; + let value = if case_insensitive { + value.to_ascii_lowercase() + } else { + value.to_string() + }; + wildcard_match_bytes(pattern.as_bytes(), value.as_bytes()) +} + +fn wildcard_match_bytes(pattern: &[u8], value: &[u8]) -> bool { + match pattern.split_first() { + None => value.is_empty(), + Some((&b'*', rest)) => { + wildcard_match_bytes(rest, value) + || (!value.is_empty() && wildcard_match_bytes(pattern, &value[1..])) + } + Some((&expected, rest)) => value.split_first().is_some_and(|(&actual, value_rest)| { + actual == expected && wildcard_match_bytes(rest, value_rest) + }), + } +} + +fn quoted_config_subsection<'a>(section: &'a str, name: &str) -> Option<&'a str> { + let prefix_len = name.len() + 2; + if section.len() <= prefix_len { + return None; + } + let prefix = §ion[..prefix_len]; + if !prefix.eq_ignore_ascii_case(&format!("{name} \"")) { + return None; + } + section[prefix_len..].strip_suffix('"') +} + +fn resolve_include_path(config_path: &Path, include_path: &str) -> PathBuf { + let include_path = include_path.strip_prefix("~/").map_or_else( + || PathBuf::from(include_path), + |rest| { + std::env::var_os("HOME") + .map(PathBuf::from) + .unwrap_or_default() + .join(rest) + }, + ); + if include_path.is_absolute() { + include_path + } else { + config_path + .parent() + .unwrap_or_else(|| Path::new(".")) + .join(include_path) + } +} + +fn normalize_config_value(value: &str) -> String { + let value = value.trim(); + let mut in_quotes = false; + let mut escaped = false; + for (index, ch) in value.char_indices() { + if escaped { + escaped = false; + continue; + } + match ch { + '\\' if in_quotes => escaped = true, + '"' => in_quotes = !in_quotes, + '#' | ';' + if !in_quotes + && value[..index] + .chars() + .next_back() + .is_some_and(char::is_whitespace) => + { + return unquote_config_value(value[..index].trim()); + } + _ => {} + } + } + unquote_config_value(value) +} + +fn unquote_config_value(value: &str) -> String { + value + .strip_prefix('"') + .and_then(|value| value.strip_suffix('"')) + .unwrap_or(value) + .to_string() +} + +pub(super) fn upstream_full_ref(config: &BranchConfig) -> Option { + if config.remote == "." { + return Some(config.merge_ref.clone()); + } + let default_refspec = format!("+refs/heads/*:refs/remotes/{}/*", config.remote); + let remote_refspecs = config + .fetch_refspecs + .iter() + .filter(|(remote, _)| remote == &config.remote) + .map(|(_, refspec)| refspec); + let refspecs = remote_refspecs.collect::>(); + if refspecs.is_empty() { + return map_fetch_refspec(&default_refspec, &config.merge_ref).into_ref(); + } + refspecs + .into_iter() + .find_map(|refspec| map_fetch_refspec(refspec, &config.merge_ref).into_ref()) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum FetchRefspecMatch { + Ref(String), + NoMatch, +} + +impl FetchRefspecMatch { + fn into_ref(self) -> Option { + match self { + FetchRefspecMatch::Ref(value) => Some(value), + FetchRefspecMatch::NoMatch => None, + } + } +} + +fn map_fetch_refspec(refspec: &str, merge_ref: &str) -> FetchRefspecMatch { + let refspec = refspec.strip_prefix('+').unwrap_or(refspec); + if refspec.starts_with('^') { + return FetchRefspecMatch::NoMatch; + } + let Some((source, destination)) = refspec.split_once(':') else { + return FetchRefspecMatch::NoMatch; + }; + match (source.split_once('*'), destination.split_once('*')) { + (None, None) => { + if source == merge_ref { + FetchRefspecMatch::Ref(destination.to_string()) + } else { + FetchRefspecMatch::NoMatch + } + } + (Some((source_prefix, source_suffix)), Some((destination_prefix, destination_suffix))) => { + let Some(matched) = merge_ref + .strip_prefix(source_prefix) + .and_then(|matched| matched.strip_suffix(source_suffix)) + else { + return FetchRefspecMatch::NoMatch; + }; + FetchRefspecMatch::Ref(format!("{destination_prefix}{matched}{destination_suffix}")) + } + _ => FetchRefspecMatch::NoMatch, + } +} diff --git a/src/workspace/git/config_tests.rs b/src/workspace/git/config_tests.rs new file mode 100644 index 00000000..e789e1db --- /dev/null +++ b/src/workspace/git/config_tests.rs @@ -0,0 +1,733 @@ +use super::config::*; +use crate::workspace::git::{ + discovery::git_worktree_info, + status::git_status_fingerprint, + test_support::{temp_test_dir, write_fake_tracked_repo}, +}; + +#[test] +fn git_status_fingerprint_honors_remote_fetch_refspec() { + let root = temp_test_dir("custom-fetch-refspec"); + write_fake_tracked_repo(&root); + std::fs::remove_dir_all(root.join(".git/refs/remotes/origin")).unwrap(); + std::fs::create_dir_all(root.join(".git/refs/remotes/upstream")).unwrap(); + std::fs::write( + root.join(".git/refs/remotes/upstream/main"), + "2222222222222222222222222222222222222222\n", + ) + .unwrap(); + std::fs::write( + root.join(".git/config"), + "[remote \"origin\"]\n\tfetch = +refs/heads/*:refs/remotes/upstream/*\n[branch \"main\"]\n\tremote = origin\n\tmerge = refs/heads/main\n", + ) + .unwrap(); + + let fingerprint = git_status_fingerprint(&root).unwrap(); + + let upstream = fingerprint.upstream.unwrap(); + assert_eq!(upstream.full_ref, "refs/remotes/upstream/main"); + + std::fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn git_status_fingerprint_reads_included_config() { + let root = temp_test_dir("included-config"); + write_fake_tracked_repo(&root); + std::fs::remove_dir_all(root.join(".git/refs/remotes/origin")).unwrap(); + std::fs::create_dir_all(root.join(".git/refs/remotes/included")).unwrap(); + std::fs::write( + root.join(".git/refs/remotes/included/main"), + "2222222222222222222222222222222222222222\n", + ) + .unwrap(); + std::fs::write( + root.join(".git/config"), + "[include]\n\tpath = included.cfg\n", + ) + .unwrap(); + std::fs::write( + root.join(".git/included.cfg"), + "[remote \"included\"]\n\tfetch = +refs/heads/*:refs/remotes/included/*\n[branch \"main\"]\n\tremote = included\n\tmerge = refs/heads/main\n", + ) + .unwrap(); + + let fingerprint = git_status_fingerprint(&root).unwrap(); + + let upstream = fingerprint.upstream.unwrap(); + assert_eq!(upstream.remote, "included"); + assert_eq!(upstream.full_ref, "refs/remotes/included/main"); + + std::fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn git_status_branch_config_reads_user_config_before_repo_config() { + let root = temp_test_dir("user-config"); + write_fake_tracked_repo(&root); + let user_config = root.join("user.gitconfig"); + std::fs::write(root.join(".git/config"), "").unwrap(); + std::fs::write( + &user_config, + "[remote \"global\"]\n\tfetch = +refs/heads/*:refs/remotes/global/*\n[branch \"main\"]\n\tremote = global\n\tmerge = refs/heads/main\n", + ) + .unwrap(); + + let info = git_worktree_info(&root).unwrap(); + let config = read_branch_config_with_user_paths(&info, "main", vec![user_config]).unwrap(); + + assert_eq!(config.remote, "global"); + assert_eq!( + upstream_full_ref(&config).as_deref(), + Some("refs/remotes/global/main") + ); + + std::fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn git_status_branch_config_repo_config_overrides_user_config() { + let root = temp_test_dir("repo-overrides-user-config"); + write_fake_tracked_repo(&root); + let user_config = root.join("user.gitconfig"); + std::fs::write( + &user_config, + "[remote \"global\"]\n\tfetch = +refs/heads/*:refs/remotes/global/*\n[branch \"main\"]\n\tremote = global\n\tmerge = refs/heads/main\n", + ) + .unwrap(); + + let info = git_worktree_info(&root).unwrap(); + let config = read_branch_config_with_user_paths(&info, "main", vec![user_config]).unwrap(); + + assert_eq!(config.remote, "origin"); + assert_eq!( + upstream_full_ref(&config).as_deref(), + Some("refs/remotes/origin/main") + ); + + std::fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn git_status_fingerprint_applies_repeated_includes_in_order() { + let root = temp_test_dir("repeated-include"); + write_fake_tracked_repo(&root); + std::fs::remove_dir_all(root.join(".git/refs/remotes/origin")).unwrap(); + std::fs::create_dir_all(root.join(".git/refs/remotes/included")).unwrap(); + std::fs::write( + root.join(".git/refs/remotes/included/main"), + "2222222222222222222222222222222222222222\n", + ) + .unwrap(); + std::fs::write( + root.join(".git/config"), + "[include]\n\tpath = included.cfg\n[branch \"main\"]\n\tremote = middle\n[include]\n\tpath = included.cfg\n", + ) + .unwrap(); + std::fs::write( + root.join(".git/included.cfg"), + "[remote \"included\"]\n\tfetch = +refs/heads/*:refs/remotes/included/*\n[branch \"main\"]\n\tremote = included\n\tmerge = refs/heads/main\n", + ) + .unwrap(); + + let fingerprint = git_status_fingerprint(&root).unwrap(); + + let upstream = fingerprint.upstream.unwrap(); + assert_eq!(upstream.remote, "included"); + assert_eq!(upstream.full_ref, "refs/remotes/included/main"); + + std::fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn git_status_fingerprint_reads_matching_include_if_config() { + let root = temp_test_dir("include-if-config"); + write_fake_tracked_repo(&root); + std::fs::remove_dir_all(root.join(".git/refs/remotes/origin")).unwrap(); + std::fs::create_dir_all(root.join(".git/refs/remotes/included")).unwrap(); + std::fs::write( + root.join(".git/refs/remotes/included/main"), + "2222222222222222222222222222222222222222\n", + ) + .unwrap(); + std::fs::write( + root.join(".git/config"), + format!( + "[includeIf \"gitdir:{}\"]\n\tpath = included.cfg\n", + root.join(".git").display() + ), + ) + .unwrap(); + std::fs::write( + root.join(".git/included.cfg"), + "[remote \"included\"]\n\tfetch = +refs/heads/*:refs/remotes/included/*\n[branch \"main\"]\n\tremote = included\n\tmerge = refs/heads/main\n", + ) + .unwrap(); + + let fingerprint = git_status_fingerprint(&root).unwrap(); + + let upstream = fingerprint.upstream.unwrap(); + assert_eq!(upstream.remote, "included"); + assert_eq!(upstream.full_ref, "refs/remotes/included/main"); + + std::fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn git_status_fingerprint_matches_gitdir_include_if_directory_pattern() { + let base = temp_test_dir("include-if-dir"); + let root = base.join("work/repo"); + std::fs::create_dir_all(&root).unwrap(); + write_fake_tracked_repo(&root); + std::fs::remove_dir_all(root.join(".git/refs/remotes/origin")).unwrap(); + std::fs::create_dir_all(root.join(".git/refs/remotes/included")).unwrap(); + std::fs::write( + root.join(".git/refs/remotes/included/main"), + "2222222222222222222222222222222222222222\n", + ) + .unwrap(); + std::fs::write( + root.join(".git/config"), + format!( + "[includeIf \"gitdir:{}/\"]\n\tpath = included.cfg\n", + base.join("work").display() + ), + ) + .unwrap(); + std::fs::write( + root.join(".git/included.cfg"), + "[remote \"included\"]\n\tfetch = +refs/heads/*:refs/remotes/included/*\n[branch \"main\"]\n\tremote = included\n\tmerge = refs/heads/main\n", + ) + .unwrap(); + + let fingerprint = git_status_fingerprint(&root).unwrap(); + + let upstream = fingerprint.upstream.unwrap(); + assert_eq!(upstream.remote, "included"); + assert_eq!(upstream.full_ref, "refs/remotes/included/main"); + + std::fs::remove_dir_all(base).unwrap(); +} + +#[test] +fn git_status_fingerprint_reads_case_insensitive_config_keys() { + let root = temp_test_dir("case-insensitive-config"); + write_fake_tracked_repo(&root); + std::fs::write( + root.join(".git/config"), + "[Remote \"origin\"] # remote section\n\tFetch = +refs/heads/*:refs/remotes/origin/*\n[Branch \"main\"] ; branch section\n\tRemote = origin\n\tMerge = refs/heads/main\n", + ) + .unwrap(); + + let fingerprint = git_status_fingerprint(&root).unwrap(); + + let upstream = fingerprint.upstream.unwrap(); + assert_eq!(upstream.remote, "origin"); + assert_eq!(upstream.full_ref, "refs/remotes/origin/main"); + + std::fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn git_status_fingerprint_keeps_refspecs_for_later_remote_override() { + let root = temp_test_dir("worktree-remote-override"); + write_fake_tracked_repo(&root); + std::fs::remove_dir_all(root.join(".git/refs/remotes/origin")).unwrap(); + std::fs::create_dir_all(root.join(".git/refs/remotes/fork")).unwrap(); + std::fs::write( + root.join(".git/refs/remotes/fork/main"), + "2222222222222222222222222222222222222222\n", + ) + .unwrap(); + std::fs::write( + root.join(".git/config"), + "[extensions]\n\tworktreeConfig = true\n[remote \"fork\"]\n\tfetch = +refs/heads/*:refs/remotes/fork/*\n[branch \"main\"]\n\tremote = origin\n\tmerge = refs/heads/main\n", + ) + .unwrap(); + std::fs::write( + root.join(".git/config.worktree"), + "[branch \"main\"]\n\tremote = fork\n", + ) + .unwrap(); + + let fingerprint = git_status_fingerprint(&root).unwrap(); + + let upstream = fingerprint.upstream.unwrap(); + assert_eq!(upstream.remote, "fork"); + assert_eq!(upstream.full_ref, "refs/remotes/fork/main"); + + std::fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn git_status_fingerprint_ignores_worktree_config_when_extension_disabled() { + let root = temp_test_dir("worktree-config-disabled"); + write_fake_tracked_repo(&root); + std::fs::create_dir_all(root.join(".git/refs/remotes/fork")).unwrap(); + std::fs::write( + root.join(".git/refs/remotes/fork/main"), + "3333333333333333333333333333333333333333\n", + ) + .unwrap(); + std::fs::write( + root.join(".git/config"), + "[remote \"fork\"]\n\tfetch = +refs/heads/*:refs/remotes/fork/*\n[branch \"main\"]\n\tremote = origin\n\tmerge = refs/heads/main\n", + ) + .unwrap(); + std::fs::write( + root.join(".git/config.worktree"), + "[branch \"main\"]\n\tremote = fork\n", + ) + .unwrap(); + + let fingerprint = git_status_fingerprint(&root).unwrap(); + + let upstream = fingerprint.upstream.unwrap(); + assert_eq!(upstream.remote, "origin"); + assert_eq!(upstream.full_ref, "refs/remotes/origin/main"); + + std::fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn git_status_fingerprint_accepts_git_boolean_worktree_config() { + let root = temp_test_dir("worktree-config-boolean"); + write_fake_tracked_repo(&root); + std::fs::create_dir_all(root.join(".git/refs/remotes/fork")).unwrap(); + std::fs::write( + root.join(".git/refs/remotes/fork/main"), + "3333333333333333333333333333333333333333\n", + ) + .unwrap(); + std::fs::write( + root.join(".git/config"), + "[extensions]\n\tworktreeConfig\n[remote \"fork\"]\n\tfetch = +refs/heads/*:refs/remotes/fork/*\n[branch \"main\"]\n\tremote = origin\n\tmerge = refs/heads/main\n", + ) + .unwrap(); + std::fs::write( + root.join(".git/config.worktree"), + "[branch \"main\"]\n\tremote = fork\n", + ) + .unwrap(); + + let fingerprint = git_status_fingerprint(&root).unwrap(); + + let upstream = fingerprint.upstream.unwrap(); + assert_eq!(upstream.remote, "fork"); + assert_eq!(upstream.full_ref, "refs/remotes/fork/main"); + + std::fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn git_status_fingerprint_uses_last_worktree_config_boolean() { + let root = temp_test_dir("worktree-config-duplicate-boolean"); + write_fake_tracked_repo(&root); + std::fs::create_dir_all(root.join(".git/refs/remotes/fork")).unwrap(); + std::fs::write( + root.join(".git/refs/remotes/fork/main"), + "3333333333333333333333333333333333333333\n", + ) + .unwrap(); + std::fs::write( + root.join(".git/config"), + "[extensions]\n\tworktreeConfig = false\n\tworktreeConfig = true\n[remote \"fork\"]\n\tfetch = +refs/heads/*:refs/remotes/fork/*\n[branch \"main\"]\n\tremote = origin\n\tmerge = refs/heads/main\n", + ) + .unwrap(); + std::fs::write( + root.join(".git/config.worktree"), + "[branch \"main\"]\n\tremote = fork\n", + ) + .unwrap(); + + let fingerprint = git_status_fingerprint(&root).unwrap(); + + let upstream = fingerprint.upstream.unwrap(); + assert_eq!(upstream.remote, "fork"); + assert_eq!(upstream.full_ref, "refs/remotes/fork/main"); + + std::fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn git_status_fingerprint_ignores_included_worktree_config_extension() { + let root = temp_test_dir("worktree-config-included-extension"); + write_fake_tracked_repo(&root); + std::fs::create_dir_all(root.join(".git/refs/remotes/fork")).unwrap(); + std::fs::write( + root.join(".git/refs/remotes/fork/main"), + "3333333333333333333333333333333333333333\n", + ) + .unwrap(); + std::fs::write( + root.join(".git/config"), + "[include]\n\tpath = extension.cfg\n[remote \"fork\"]\n\tfetch = +refs/heads/*:refs/remotes/fork/*\n[branch \"main\"]\n\tremote = origin\n\tmerge = refs/heads/main\n", + ) + .unwrap(); + std::fs::write( + root.join(".git/extension.cfg"), + "[extensions]\n\tworktreeConfig = true\n", + ) + .unwrap(); + std::fs::write( + root.join(".git/config.worktree"), + "[branch \"main\"]\n\tremote = fork\n", + ) + .unwrap(); + + let fingerprint = git_status_fingerprint(&root).unwrap(); + + let upstream = fingerprint.upstream.unwrap(); + assert_eq!(upstream.remote, "origin"); + assert_eq!(upstream.full_ref, "refs/remotes/origin/main"); + + std::fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn git_status_fingerprint_reads_onbranch_include_if_config() { + let root = temp_test_dir("include-if-onbranch"); + write_fake_tracked_repo(&root); + std::fs::remove_dir_all(root.join(".git/refs/remotes/origin")).unwrap(); + std::fs::create_dir_all(root.join(".git/refs/remotes/included")).unwrap(); + std::fs::write( + root.join(".git/refs/remotes/included/main"), + "2222222222222222222222222222222222222222\n", + ) + .unwrap(); + std::fs::write( + root.join(".git/config"), + "[includeIf \"onbranch:main\"]\n\tpath = included.cfg\n", + ) + .unwrap(); + std::fs::write( + root.join(".git/included.cfg"), + "[remote \"included\"]\n\tfetch = +refs/heads/*:refs/remotes/included/*\n[branch \"main\"]\n\tremote = included\n\tmerge = refs/heads/main\n", + ) + .unwrap(); + + let fingerprint = git_status_fingerprint(&root).unwrap(); + + let upstream = fingerprint.upstream.unwrap(); + assert_eq!(upstream.remote, "included"); + assert_eq!(upstream.full_ref, "refs/remotes/included/main"); + + std::fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn git_status_fingerprint_reads_hasconfig_include_if_config() { + let root = temp_test_dir("include-if-hasconfig"); + write_fake_tracked_repo(&root); + std::fs::remove_dir_all(root.join(".git/refs/remotes/origin")).unwrap(); + std::fs::create_dir_all(root.join(".git/refs/remotes/included")).unwrap(); + std::fs::write( + root.join(".git/refs/remotes/included/main"), + "2222222222222222222222222222222222222222\n", + ) + .unwrap(); + std::fs::write( + root.join(".git/config"), + "[remote \"fork\"]\n\turl = https://example.test/fork.git\n[includeIf \"hasconfig:remote.*.url:*fork.git\"]\n\tpath = included.cfg\n", + ) + .unwrap(); + std::fs::write( + root.join(".git/included.cfg"), + "[remote \"included\"]\n\tfetch = +refs/heads/*:refs/remotes/included/*\n[branch \"main\"]\n\tremote = included\n\tmerge = refs/heads/main\n", + ) + .unwrap(); + + let fingerprint = git_status_fingerprint(&root).unwrap(); + + let upstream = fingerprint.upstream.unwrap(); + assert_eq!(upstream.remote, "included"); + assert_eq!(upstream.full_ref, "refs/remotes/included/main"); + + std::fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn git_status_fingerprint_matches_user_hasconfig_against_repo_remote_url() { + let root = temp_test_dir("include-if-hasconfig-user-repo-url"); + let user_config = root.join("user.gitconfig"); + write_fake_tracked_repo(&root); + std::fs::remove_dir_all(root.join(".git/refs/remotes/origin")).unwrap(); + std::fs::create_dir_all(root.join(".git/refs/remotes/included")).unwrap(); + std::fs::write( + root.join(".git/refs/remotes/included/main"), + "2222222222222222222222222222222222222222\n", + ) + .unwrap(); + std::fs::write( + root.join(".git/config"), + "[remote \"fork\"]\n\turl = https://example.test/fork.git\n", + ) + .unwrap(); + std::fs::write( + &user_config, + "[includeIf \"hasconfig:remote.*.url:*fork.git\"]\n\tpath = user-included.cfg\n", + ) + .unwrap(); + std::fs::write( + root.join("user-included.cfg"), + "[remote \"included\"]\n\tfetch = +refs/heads/*:refs/remotes/included/*\n[branch \"main\"]\n\tremote = included\n\tmerge = refs/heads/main\n", + ) + .unwrap(); + + let info = git_worktree_info(&root).unwrap(); + let config = read_branch_config_with_user_paths(&info, "main", vec![user_config]).unwrap(); + + assert_eq!(config.remote, "included"); + assert_eq!(config.merge_ref, "refs/heads/main"); + + std::fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn git_status_fingerprint_skips_hasconfig_include_that_defines_remote_url() { + let root = temp_test_dir("include-if-hasconfig-rejects-remote-url"); + let user_config = root.join("user.gitconfig"); + write_fake_tracked_repo(&root); + std::fs::create_dir_all(root.join(".git/refs/remotes/included")).unwrap(); + std::fs::write( + root.join(".git/refs/remotes/included/main"), + "3333333333333333333333333333333333333333\n", + ) + .unwrap(); + std::fs::write( + root.join(".git/config"), + "[remote \"fork\"]\n\turl = https://example.test/fork.git\n[branch \"main\"]\n\tremote = origin\n\tmerge = refs/heads/main\n", + ) + .unwrap(); + std::fs::write( + &user_config, + "[includeIf \"hasconfig:remote.*.url:*fork.git\"]\n\tpath = user-included.cfg\n", + ) + .unwrap(); + std::fs::write( + root.join("user-included.cfg"), + "[remote \"included\"]\n\turl = https://example.test/included.git\n\tfetch = +refs/heads/*:refs/remotes/included/*\n[branch \"main\"]\n\tremote = included\n\tmerge = refs/heads/main\n", + ) + .unwrap(); + + let info = git_worktree_info(&root).unwrap(); + let config = read_branch_config_with_user_paths(&info, "main", vec![user_config]).unwrap(); + + assert_eq!(config.remote, "origin"); + assert_eq!(config.merge_ref, "refs/heads/main"); + + std::fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn git_status_fingerprint_skips_hasconfig_include_chain_that_defines_remote_url() { + let root = temp_test_dir("include-if-hasconfig-rejects-nested-remote-url"); + let user_config = root.join("user.gitconfig"); + write_fake_tracked_repo(&root); + std::fs::create_dir_all(root.join(".git/refs/remotes/included")).unwrap(); + std::fs::write( + root.join(".git/refs/remotes/included/main"), + "3333333333333333333333333333333333333333\n", + ) + .unwrap(); + std::fs::write( + root.join(".git/config"), + "[remote \"fork\"]\n\turl = https://example.test/fork.git\n[branch \"main\"]\n\tremote = origin\n\tmerge = refs/heads/main\n", + ) + .unwrap(); + std::fs::write( + &user_config, + "[includeIf \"hasconfig:remote.*.url:*fork.git\"]\n\tpath = user-included.cfg\n", + ) + .unwrap(); + std::fs::write( + root.join("user-included.cfg"), + "[include]\n\tpath = nested-remote.cfg\n[branch \"main\"]\n\tremote = included\n\tmerge = refs/heads/main\n", + ) + .unwrap(); + std::fs::write( + root.join("nested-remote.cfg"), + "[remote \"included\"]\n\turl = https://example.test/included.git\n\tfetch = +refs/heads/*:refs/remotes/included/*\n", + ) + .unwrap(); + + let info = git_worktree_info(&root).unwrap(); + let config = read_branch_config_with_user_paths(&info, "main", vec![user_config]).unwrap(); + + assert_eq!(config.remote, "origin"); + assert_eq!(config.merge_ref, "refs/heads/main"); + + std::fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn git_status_fingerprint_ignores_worktree_urls_for_hasconfig() { + let root = temp_test_dir("include-if-hasconfig-worktree-url"); + write_fake_tracked_repo(&root); + std::fs::write( + root.join(".git/config"), + "[extensions]\n\tworktreeConfig = true\n[branch \"main\"]\n\tremote = origin\n\tmerge = refs/heads/main\n", + ) + .unwrap(); + std::fs::write( + root.join(".git/config.worktree"), + "[remote \"fork\"]\n\turl = https://example.test/fork.git\n[includeIf \"hasconfig:remote.*.url:*fork.git\"]\n\tpath = included.cfg\n", + ) + .unwrap(); + std::fs::write( + root.join(".git/included.cfg"), + "[branch \"main\"]\n\tremote = included\n", + ) + .unwrap(); + + let fingerprint = git_status_fingerprint(&root).unwrap(); + + let upstream = fingerprint.upstream.unwrap(); + assert_eq!(upstream.remote, "origin"); + assert_eq!(upstream.full_ref, "refs/remotes/origin/main"); + + std::fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn git_status_fingerprint_stops_recursive_include_cycles() { + let root = temp_test_dir("include-cycle"); + write_fake_tracked_repo(&root); + std::fs::remove_dir_all(root.join(".git/refs/remotes/origin")).unwrap(); + std::fs::create_dir_all(root.join(".git/refs/remotes/included")).unwrap(); + std::fs::write( + root.join(".git/refs/remotes/included/main"), + "2222222222222222222222222222222222222222\n", + ) + .unwrap(); + std::fs::write(root.join(".git/config"), "[include]\n\tpath = a.cfg\n").unwrap(); + std::fs::write(root.join(".git/a.cfg"), "[include]\n\tpath = b.cfg\n").unwrap(); + std::fs::write( + root.join(".git/b.cfg"), + "[include]\n\tpath = a.cfg\n[remote \"included\"]\n\tfetch = +refs/heads/*:refs/remotes/included/*\n[branch \"main\"]\n\tremote = included\n\tmerge = refs/heads/main\n", + ) + .unwrap(); + + let fingerprint = git_status_fingerprint(&root).unwrap(); + + let upstream = fingerprint.upstream.unwrap(); + assert_eq!(upstream.remote, "included"); + assert_eq!(upstream.full_ref, "refs/remotes/included/main"); + + std::fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn git_status_fingerprint_reads_linked_worktree_config() { + let base = temp_test_dir("linked-worktree-config"); + let common_dir = base.join("repo/.git"); + let worktree = base.join("linked"); + let git_dir = common_dir.join("worktrees/linked"); + std::fs::create_dir_all(common_dir.join("refs/heads")).unwrap(); + std::fs::create_dir_all(common_dir.join("refs/remotes/fork")).unwrap(); + std::fs::create_dir_all(&git_dir).unwrap(); + std::fs::create_dir_all(&worktree).unwrap(); + std::fs::write( + worktree.join(".git"), + format!("gitdir: {}\n", git_dir.display()), + ) + .unwrap(); + std::fs::write(git_dir.join("commondir"), "../..\n").unwrap(); + std::fs::write(git_dir.join("HEAD"), "ref: refs/heads/main\n").unwrap(); + std::fs::write( + common_dir.join("refs/heads/main"), + "1111111111111111111111111111111111111111\n", + ) + .unwrap(); + std::fs::write( + common_dir.join("refs/remotes/fork/main"), + "2222222222222222222222222222222222222222\n", + ) + .unwrap(); + std::fs::write( + common_dir.join("config"), + "[extensions]\n\tworktreeConfig = TRUE\n[remote \"fork\"]\n\tfetch = +refs/heads/*:refs/remotes/fork/*\n[branch \"main\"]\n\tremote = origin\n\tmerge = refs/heads/main\n", + ) + .unwrap(); + std::fs::write( + git_dir.join("config.worktree"), + "[branch \"main\"]\n\tremote = fork\n", + ) + .unwrap(); + + let fingerprint = git_status_fingerprint(&worktree).unwrap(); + + let upstream = fingerprint.upstream.unwrap(); + assert_eq!(upstream.remote, "fork"); + assert_eq!(upstream.full_ref, "refs/remotes/fork/main"); + + std::fs::remove_dir_all(base).unwrap(); +} + +#[test] +fn git_status_fingerprint_ignores_inline_fetch_refspec_comment() { + let root = temp_test_dir("commented-fetch-refspec"); + write_fake_tracked_repo(&root); + std::fs::remove_dir_all(root.join(".git/refs/remotes/origin")).unwrap(); + std::fs::create_dir_all(root.join(".git/refs/remotes/upstream")).unwrap(); + std::fs::write( + root.join(".git/refs/remotes/upstream/main"), + "2222222222222222222222222222222222222222\n", + ) + .unwrap(); + std::fs::write( + root.join(".git/config"), + "[remote \"origin\"]\n\tfetch = +refs/heads/*:refs/remotes/upstream/* # custom map\n[branch \"main\"]\n\tremote = origin\n\tmerge = refs/heads/main\n", + ) + .unwrap(); + + let fingerprint = git_status_fingerprint(&root).unwrap(); + + let upstream = fingerprint.upstream.unwrap(); + assert_eq!(upstream.full_ref, "refs/remotes/upstream/main"); + assert_eq!( + upstream.oid.as_deref(), + Some("2222222222222222222222222222222222222222") + ); + + std::fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn git_status_fingerprint_clears_upstream_for_unmapped_refspec() { + let root = temp_test_dir("unmapped-fetch-refspec"); + write_fake_tracked_repo(&root); + std::fs::write( + root.join(".git/config"), + "[remote \"origin\"]\n\tfetch = +refs/pull/*:refs/remotes/origin/pr/*\n[branch \"main\"]\n\tremote = origin\n\tmerge = refs/heads/main\n", + ) + .unwrap(); + + let fingerprint = git_status_fingerprint(&root).unwrap(); + + assert_eq!(fingerprint.upstream, None); + + std::fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn git_status_fingerprint_honors_negative_fetch_refspec() { + let root = temp_test_dir("negative-fetch-refspec"); + write_fake_tracked_repo(&root); + std::fs::write( + root.join(".git/config"), + "[remote \"origin\"]\n\tfetch = +refs/heads/*:refs/remotes/origin/*\n\tfetch = ^refs/heads/main\n[branch \"main\"]\n\tremote = origin\n\tmerge = refs/heads/main\n", + ) + .unwrap(); + + let fingerprint = git_status_fingerprint(&root).unwrap(); + + let upstream = fingerprint.upstream.unwrap(); + assert_eq!(upstream.full_ref, "refs/remotes/origin/main"); + assert_eq!( + upstream.oid.as_deref(), + Some("2222222222222222222222222222222222222222") + ); + + std::fs::remove_dir_all(root).unwrap(); +} diff --git a/src/workspace/git.rs b/src/workspace/git/discovery.rs similarity index 62% rename from src/workspace/git.rs rename to src/workspace/git/discovery.rs index d06c8538..de4b090c 100644 --- a/src/workspace/git.rs +++ b/src/workspace/git/discovery.rs @@ -40,44 +40,23 @@ pub fn derive_label_from_cwd(cwd: &Path) -> String { } pub fn git_worktree_info(cwd: &Path) -> Option { - let repo_root = git_rev_parse(cwd, &["--show-toplevel"])?; - let git_dir = git_rev_parse(cwd, &["--path-format=absolute", "--git-dir"])?; - let git_common_dir = git_rev_parse(cwd, &["--path-format=absolute", "--git-common-dir"])?; - let is_bare = git_rev_parse(cwd, &["--is-bare-repository"])? == "true"; - let is_linked_worktree = - canonicalize_best_effort(&git_dir) != canonicalize_best_effort(&git_common_dir); + let repo_root = git_repo_root(cwd)?; + let git_dir = canonicalize_best_effort_path(&git_dir_for_repo_root(&repo_root)?); + let git_common_dir = canonicalize_best_effort_path(&git_common_dir_for_git_dir(&git_dir)); + let is_linked_worktree = git_dir != git_common_dir; Some(GitWorktreeInfo { - repo_root: PathBuf::from(repo_root), - git_dir: PathBuf::from(git_dir), - git_common_dir: PathBuf::from(git_common_dir), - is_bare, + repo_root, + git_dir, + git_common_dir, + is_bare: false, is_linked_worktree, }) } -fn git_rev_parse(cwd: &Path, args: &[&str]) -> Option { - let output = std::process::Command::new("git") - .arg("-C") - .arg(cwd) - .arg("rev-parse") - .args(args) - .output() - .ok()?; - - if !output.status.success() { - return None; - } - - let stdout = String::from_utf8(output.stdout).ok()?; - Some(stdout.trim().to_string()) -} - -fn canonicalize_best_effort(path: &str) -> PathBuf { - std::fs::canonicalize(path).unwrap_or_else(|_| PathBuf::from(path)) -} - pub fn git_space_metadata(cwd: &Path) -> Option { + git_repo_root(cwd)?; + let info = git_worktree_info(cwd)?; if info.is_bare { return None; @@ -112,10 +91,23 @@ pub fn git_space_metadata(cwd: &Path) -> Option { }) } -fn canonicalize_best_effort_path(path: &Path) -> PathBuf { +pub(super) fn canonicalize_best_effort_path(path: &Path) -> PathBuf { std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()) } +fn git_common_dir_for_git_dir(git_dir: &Path) -> PathBuf { + let commondir = git_dir.join("commondir"); + let Ok(contents) = std::fs::read_to_string(commondir) else { + return git_dir.to_path_buf(); + }; + let path = Path::new(contents.trim()); + if path.is_absolute() { + path.to_path_buf() + } else { + git_dir.join(path) + } +} + pub fn git_branch(cwd: &Path) -> Option { let repo_root = git_repo_root(cwd)?; let git_dir = git_dir_for_repo_root(&repo_root)?; @@ -123,7 +115,7 @@ pub fn git_branch(cwd: &Path) -> Option { parse_git_head_branch(&head) } -fn git_dir_for_repo_root(repo_root: &Path) -> Option { +pub(super) fn git_dir_for_repo_root(repo_root: &Path) -> Option { let git_path = repo_root.join(".git"); if git_path.is_dir() { return Some(git_path); @@ -144,7 +136,7 @@ fn parse_git_head_branch(head: &str) -> Option { (!branch.is_empty()).then(|| branch.to_string()) } -fn git_repo_root(start: &Path) -> Option { +pub(super) fn git_repo_root(start: &Path) -> Option { let mut current = if start.is_dir() { start.to_path_buf() } else { @@ -152,7 +144,10 @@ fn git_repo_root(start: &Path) -> Option { }; loop { - if current.join(".git").exists() { + if git_dir_for_repo_root(¤t) + .map(|git_dir| git_dir.join("HEAD").is_file()) + .unwrap_or(false) + { return Some(current); } if !current.pop() { @@ -161,33 +156,34 @@ fn git_repo_root(start: &Path) -> Option { } } -pub(super) fn git_ahead_behind(cwd: &Path) -> Option<(usize, usize)> { - git_repo_root(cwd)?; - - let output = std::process::Command::new("git") - .arg("-C") - .arg(cwd) - .args(["rev-list", "--left-right", "--count", "HEAD...@{upstream}"]) - .output() - .ok()?; - - if !output.status.success() { - return None; +pub(super) fn read_ref_oid(common_dir: &Path, full_ref: &str) -> Option { + let loose_ref = common_dir.join(full_ref); + if let Ok(contents) = std::fs::read_to_string(loose_ref) { + let oid = contents.trim(); + if !oid.is_empty() { + return Some(oid.to_string()); + } } - let stdout = String::from_utf8(output.stdout).ok()?; - parse_git_ahead_behind_output(&stdout) -} - -fn parse_git_ahead_behind_output(stdout: &str) -> Option<(usize, usize)> { - let mut parts = stdout.split_whitespace(); - let ahead = parts.next()?.parse().ok()?; - let behind = parts.next()?.parse().ok()?; - Some((ahead, behind)) + let packed_refs = std::fs::read_to_string(common_dir.join("packed-refs")).ok()?; + for line in packed_refs.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') || line.starts_with('^') { + continue; + } + let mut parts = line.split_whitespace(); + let oid = parts.next()?; + let name = parts.next()?; + if name == full_ref { + return Some(oid.to_string()); + } + } + None } #[cfg(test)] mod tests { + use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; use super::*; @@ -241,4 +237,42 @@ mod tests { std::fs::remove_dir_all(root).unwrap(); } + + #[test] + fn git_repo_root_ignores_invalid_git_marker() { + let base = temp_test_dir("invalid-git-root"); + let cwd = base.join("workspace"); + std::fs::create_dir_all(base.join(".git")).unwrap(); + std::fs::create_dir_all(&cwd).unwrap(); + + assert_eq!(git_repo_root(&cwd), None); + + std::fs::remove_dir_all(base).unwrap(); + } + + #[test] + fn derive_label_prefers_repo_root_name() { + let root = temp_test_dir("label-repo"); + let nested = root.join("nested"); + std::fs::create_dir_all(root.join(".git")).unwrap(); + std::fs::write(root.join(".git/HEAD"), "ref: refs/heads/main\n").unwrap(); + std::fs::create_dir_all(&nested).unwrap(); + + assert_eq!( + derive_label_from_cwd(&nested), + root.file_name().and_then(|name| name.to_str()).unwrap() + ); + + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn derive_label_uses_path_name_outside_git() { + let root = temp_test_dir("label-plain"); + let label = root.file_name().and_then(|name| name.to_str()).unwrap(); + + assert_eq!(derive_label_from_cwd(Path::new(&root)), label); + + std::fs::remove_dir_all(root).unwrap(); + } } diff --git a/src/workspace/git/mod.rs b/src/workspace/git/mod.rs new file mode 100644 index 00000000..76dc4eb4 --- /dev/null +++ b/src/workspace/git/mod.rs @@ -0,0 +1,15 @@ +mod config; +#[cfg(test)] +mod config_tests; +mod discovery; +mod status; +#[cfg(test)] +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}, +}; + +#[cfg(test)] +pub(super) use self::status::git_ahead_behind; diff --git a/src/workspace/git/status.rs b/src/workspace/git/status.rs new file mode 100644 index 00000000..24e59e57 --- /dev/null +++ b/src/workspace/git/status.rs @@ -0,0 +1,386 @@ +use std::path::{Path, PathBuf}; + +use crate::workspace::WorkspaceGitStatusSnapshot; + +use super::{ + config::{read_branch_config, upstream_full_ref}, + discovery::{ + canonicalize_best_effort_path, git_branch, git_space_metadata, git_worktree_info, + read_ref_oid, GitWorktreeInfo, + }, +}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GitStatusCacheEntry { + pub fingerprint: GitStatusFingerprint, + pub snapshot: WorkspaceGitStatusSnapshot, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GitStatusFingerprint { + pub git_dir: PathBuf, + pub git_common_dir: PathBuf, + pub head: GitHeadIdentity, + pub upstream: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum GitHeadIdentity { + Branch { + full_ref: String, + short_name: String, + oid: Option, + }, + Detached { + oid: String, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GitUpstreamIdentity { + pub remote: String, + pub merge_ref: String, + pub full_ref: String, + pub oid: Option, +} + +pub fn git_status_cache_key(cwd: &Path) -> Option { + git_worktree_info(cwd).map(|info| canonicalize_best_effort_path(&info.repo_root)) +} + +pub fn git_status_snapshot_for_cwd( + cwd: &Path, + cached: Option<&GitStatusCacheEntry>, +) -> (WorkspaceGitStatusSnapshot, Option) { + let branch = git_branch(cwd); + let space = git_space_metadata(cwd); + let Some(fingerprint) = git_status_fingerprint(cwd) else { + return ( + WorkspaceGitStatusSnapshot { + branch, + ahead_behind: None, + space, + }, + None, + ); + }; + + if let Some(cached) = cached.filter(|entry| entry.fingerprint == fingerprint) { + let snapshot = WorkspaceGitStatusSnapshot { + branch, + ahead_behind: cached.snapshot.ahead_behind, + space, + }; + return ( + snapshot.clone(), + Some(GitStatusCacheEntry { + fingerprint, + snapshot, + }), + ); + } + + let ahead_behind = fingerprint + .head_oid() + .zip(fingerprint.upstream_oid()) + .and_then(|(head_oid, upstream_oid)| git_ahead_behind_between(cwd, head_oid, upstream_oid)); + let snapshot = WorkspaceGitStatusSnapshot { + branch, + ahead_behind, + space, + }; + ( + snapshot.clone(), + Some(GitStatusCacheEntry { + fingerprint, + snapshot, + }), + ) +} + +pub(super) fn git_status_fingerprint(cwd: &Path) -> Option { + let info = git_worktree_info(cwd)?; + let head = read_head_identity(&info)?; + let upstream = match &head { + GitHeadIdentity::Branch { short_name, .. } => read_upstream_identity(&info, short_name), + GitHeadIdentity::Detached { .. } => None, + }; + + Some(GitStatusFingerprint { + git_dir: canonicalize_best_effort_path(&info.git_dir), + git_common_dir: canonicalize_best_effort_path(&info.git_common_dir), + head, + upstream, + }) +} + +impl GitStatusFingerprint { + fn head_oid(&self) -> Option<&str> { + match &self.head { + GitHeadIdentity::Branch { oid, .. } => oid.as_deref(), + GitHeadIdentity::Detached { oid } => Some(oid.as_str()), + } + } + + fn upstream_oid(&self) -> Option<&str> { + self.upstream + .as_ref() + .and_then(|upstream| upstream.oid.as_deref()) + } +} + +fn read_head_identity(info: &GitWorktreeInfo) -> Option { + let head = std::fs::read_to_string(info.git_dir.join("HEAD")).ok()?; + let head = head.trim(); + if let Some(full_ref) = head.strip_prefix("ref: ") { + let short_name = full_ref.strip_prefix("refs/heads/")?.to_string(); + let oid = read_ref_oid(&info.git_common_dir, full_ref); + return Some(GitHeadIdentity::Branch { + full_ref: full_ref.to_string(), + short_name, + oid, + }); + } + + (!head.is_empty()).then(|| GitHeadIdentity::Detached { + oid: head.to_string(), + }) +} + +fn read_upstream_identity(info: &GitWorktreeInfo, branch: &str) -> Option { + let config = read_branch_config(info, branch)?; + let full_ref = upstream_full_ref(&config)?; + let oid = read_ref_oid(&info.git_common_dir, &full_ref); + Some(GitUpstreamIdentity { + remote: config.remote, + merge_ref: config.merge_ref, + full_ref, + oid, + }) +} + +#[cfg(test)] +pub(crate) fn git_ahead_behind(cwd: &Path) -> Option<(usize, usize)> { + super::discovery::git_repo_root(cwd)?; + + let output = std::process::Command::new("git") + .arg("-C") + .arg(cwd) + .args(["rev-list", "--left-right", "--count", "HEAD...@{upstream}"]) + .output() + .ok()?; + + if !output.status.success() { + return None; + } + + let stdout = String::from_utf8(output.stdout).ok()?; + parse_git_ahead_behind_output(&stdout) +} + +fn git_ahead_behind_between( + cwd: &Path, + head_oid: &str, + upstream_oid: &str, +) -> Option<(usize, usize)> { + let range = format!("{head_oid}...{upstream_oid}"); + let output = std::process::Command::new("git") + .arg("-C") + .arg(cwd) + .args(["rev-list", "--left-right", "--count", &range]) + .output() + .ok()?; + + if !output.status.success() { + return None; + } + + let stdout = String::from_utf8(output.stdout).ok()?; + parse_git_ahead_behind_output(&stdout) +} + +fn parse_git_ahead_behind_output(stdout: &str) -> Option<(usize, usize)> { + let mut parts = stdout.split_whitespace(); + let ahead = parts.next()?.parse().ok()?; + let behind = parts.next()?.parse().ok()?; + Some((ahead, behind)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::workspace::git::test_support::{run_git, temp_test_dir, write_fake_tracked_repo}; + + #[test] + fn git_status_cache_key_ignores_invalid_git_marker() { + let base = temp_test_dir("invalid-git-root"); + let cwd = base.join("workspace"); + std::fs::create_dir_all(base.join(".git")).unwrap(); + std::fs::create_dir_all(&cwd).unwrap(); + + assert_eq!(git_status_cache_key(&cwd), None); + + std::fs::remove_dir_all(base).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, + snapshot: WorkspaceGitStatusSnapshot { + branch: Some("main".into()), + ahead_behind: Some((2, 1)), + space: git_space_metadata(&root), + }, + }; + + let (snapshot, update) = git_status_snapshot_for_cwd(&root, Some(&cached)); + + assert_eq!(snapshot.branch.as_deref(), Some("main")); + assert_eq!(snapshot.ahead_behind, Some((2, 1))); + assert_eq!(update.unwrap().snapshot.ahead_behind, Some((2, 1))); + + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn git_status_does_not_reuse_cache_when_branch_changes_at_same_oid() { + let root = temp_test_dir("branch-switch"); + write_fake_tracked_repo(&root); + let fingerprint = git_status_fingerprint(&root).unwrap(); + let cached = GitStatusCacheEntry { + fingerprint, + snapshot: WorkspaceGitStatusSnapshot { + branch: Some("main".into()), + ahead_behind: Some((4, 0)), + space: git_space_metadata(&root), + }, + }; + std::fs::write(root.join(".git/HEAD"), "ref: refs/heads/feature\n").unwrap(); + std::fs::write( + root.join(".git/refs/heads/feature"), + "1111111111111111111111111111111111111111\n", + ) + .unwrap(); + std::fs::write( + root.join(".git/config"), + "[branch \"feature\"]\n\tremote = origin\n\tmerge = refs/heads/main\n", + ) + .unwrap(); + + let (snapshot, _) = git_status_snapshot_for_cwd(&root, Some(&cached)); + + assert_eq!(snapshot.branch.as_deref(), Some("feature")); + assert_eq!(snapshot.ahead_behind, None); + + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn git_status_clears_ahead_behind_when_upstream_is_unset() { + let root = temp_test_dir("upstream-unset"); + write_fake_tracked_repo(&root); + let fingerprint = git_status_fingerprint(&root).unwrap(); + let cached = GitStatusCacheEntry { + fingerprint, + snapshot: WorkspaceGitStatusSnapshot { + branch: Some("main".into()), + ahead_behind: Some((0, 3)), + space: git_space_metadata(&root), + }, + }; + std::fs::write(root.join(".git/config"), "").unwrap(); + + let (snapshot, _) = git_status_snapshot_for_cwd(&root, Some(&cached)); + + assert_eq!(snapshot.branch.as_deref(), Some("main")); + assert_eq!(snapshot.ahead_behind, None); + + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn git_status_fingerprint_reads_packed_refs() { + let root = temp_test_dir("packed-refs"); + write_fake_tracked_repo(&root); + std::fs::remove_file(root.join(".git/refs/remotes/origin/main")).unwrap(); + std::fs::write( + root.join(".git/packed-refs"), + "# pack-refs with: peeled fully-peeled sorted\n2222222222222222222222222222222222222222 refs/remotes/origin/main\n", + ) + .unwrap(); + + let fingerprint = git_status_fingerprint(&root).unwrap(); + + assert_eq!( + fingerprint.upstream.unwrap().oid.as_deref(), + Some("2222222222222222222222222222222222222222") + ); + + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn git_status_cache_key_is_per_linked_worktree_checkout() { + let base = temp_test_dir("linked-worktree-keys"); + let common_dir = base.join("repo/.git"); + let worktree_one = base.join("one"); + let worktree_two = base.join("two"); + let git_dir_one = common_dir.join("worktrees/one"); + let git_dir_two = common_dir.join("worktrees/two"); + std::fs::create_dir_all(&git_dir_one).unwrap(); + std::fs::create_dir_all(&git_dir_two).unwrap(); + std::fs::create_dir_all(&worktree_one).unwrap(); + std::fs::create_dir_all(&worktree_two).unwrap(); + std::fs::write( + worktree_one.join(".git"), + format!("gitdir: {}\n", git_dir_one.display()), + ) + .unwrap(); + std::fs::write( + worktree_two.join(".git"), + format!("gitdir: {}\n", git_dir_two.display()), + ) + .unwrap(); + std::fs::write(git_dir_one.join("HEAD"), "ref: refs/heads/one\n").unwrap(); + std::fs::write(git_dir_two.join("HEAD"), "ref: refs/heads/two\n").unwrap(); + + assert_ne!( + git_status_cache_key(&worktree_one), + git_status_cache_key(&worktree_two) + ); + + std::fs::remove_dir_all(base).unwrap(); + } + + #[test] + fn git_status_recomputes_ahead_behind_when_head_moves() { + let base = temp_test_dir("head-moves"); + let remote = base.join("remote.git"); + let repo = base.join("repo"); + std::fs::create_dir_all(&repo).unwrap(); + let remote_arg = remote.to_string_lossy().to_string(); + run_git(&base, &["init", "--bare", &remote_arg]); + run_git(&repo, &["init"]); + run_git(&repo, &["config", "user.email", "herdr@example.invalid"]); + run_git(&repo, &["config", "user.name", "Herdr Test"]); + run_git(&repo, &["commit", "--allow-empty", "-m", "initial"]); + run_git(&repo, &["branch", "-M", "main"]); + run_git(&repo, &["remote", "add", "origin", &remote_arg]); + run_git(&repo, &["push", "-u", "origin", "main"]); + + let (initial, cache_entry) = git_status_snapshot_for_cwd(&repo, None); + assert_eq!(initial.ahead_behind, Some((0, 0))); + run_git(&repo, &["commit", "--allow-empty", "-m", "ahead"]); + + let (updated, _) = git_status_snapshot_for_cwd(&repo, cache_entry.as_ref()); + + assert_eq!(updated.branch.as_deref(), Some("main")); + assert_eq!(updated.ahead_behind, Some((1, 0))); + + std::fs::remove_dir_all(base).unwrap(); + } +} diff --git a/src/workspace/git/test_support.rs b/src/workspace/git/test_support.rs new file mode 100644 index 00000000..feba4b3a --- /dev/null +++ b/src/workspace/git/test_support.rs @@ -0,0 +1,51 @@ +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +pub(super) fn temp_test_dir(name: &str) -> PathBuf { + let unique = format!( + "herdr-workspace-tests-{}-{}-{}", + name, + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + ); + let path = std::env::temp_dir().join(unique); + std::fs::create_dir_all(&path).unwrap(); + path +} + +pub(super) fn write_fake_tracked_repo(root: &Path) { + let head_oid = "1111111111111111111111111111111111111111"; + let upstream_oid = "2222222222222222222222222222222222222222"; + std::fs::create_dir_all(root.join(".git/refs/heads")).unwrap(); + std::fs::create_dir_all(root.join(".git/refs/remotes/origin")).unwrap(); + std::fs::write(root.join(".git/HEAD"), "ref: refs/heads/main\n").unwrap(); + std::fs::write(root.join(".git/refs/heads/main"), format!("{head_oid}\n")).unwrap(); + std::fs::write( + root.join(".git/refs/remotes/origin/main"), + format!("{upstream_oid}\n"), + ) + .unwrap(); + std::fs::write( + root.join(".git/config"), + "[branch \"main\"]\n\tremote = origin\n\tmerge = refs/heads/main\n", + ) + .unwrap(); +} + +pub(super) fn run_git(cwd: &Path, args: &[&str]) { + let output = std::process::Command::new("git") + .arg("-C") + .arg(cwd) + .args(args) + .output() + .unwrap(); + assert!( + output.status.success(), + "git {:?} failed: {}", + args, + String::from_utf8_lossy(&output.stderr) + ); +} diff --git a/tests/api_ping.rs b/tests/api_ping.rs index 22834615..b3fc4632 100644 --- a/tests/api_ping.rs +++ b/tests/api_ping.rs @@ -273,7 +273,7 @@ fn ping_over_socket_returns_version() { assert_eq!(value["result"]["version"], env!("CARGO_PKG_VERSION")); // Intentionally hardcoded so wire protocol bumps require updating this test. // Changing this value means old clients/servers are no longer compatible. - assert_eq!(value["result"]["protocol"], 11); + assert_eq!(value["result"]["protocol"], 12); cleanup_spawned_herdr(child, base); } diff --git a/tests/cli_wrapper.rs b/tests/cli_wrapper.rs index a2bbfdcc..0c81e7e4 100644 --- a/tests/cli_wrapper.rs +++ b/tests/cli_wrapper.rs @@ -1238,7 +1238,7 @@ fn status_commands_report_client_and_server_versions() { "stdout: {full_stdout}" ); assert!( - full_stdout.contains(" protocol: 11"), + full_stdout.contains(" protocol: 12"), "stdout: {full_stdout}" ); assert!(full_stdout.contains("server:\n"), "stdout: {full_stdout}"); @@ -1271,7 +1271,7 @@ fn status_commands_report_client_and_server_versions() { "stdout: {server_stdout}" ); assert!( - server_stdout.contains("protocol: 11"), + server_stdout.contains("protocol: 12"), "stdout: {server_stdout}" ); @@ -1283,7 +1283,7 @@ fn status_commands_report_client_and_server_versions() { "stdout: {client_stdout}" ); assert!( - client_stdout.contains("protocol: 11"), + client_stdout.contains("protocol: 12"), "stdout: {client_stdout}" ); assert!( @@ -1293,7 +1293,7 @@ fn status_commands_report_client_and_server_versions() { let full_json = run_cli_json(&socket_path, &["status", "--json"]); assert_eq!(full_json["client"]["version"], env!("CARGO_PKG_VERSION")); - assert_eq!(full_json["client"]["protocol"], 11); + assert_eq!(full_json["client"]["protocol"], 12); assert_eq!(full_json["server"]["status"], "running"); assert_eq!(full_json["server"]["running"], true); assert_eq!(full_json["server"]["compatible"], true); @@ -1307,12 +1307,12 @@ fn status_commands_report_client_and_server_versions() { let server_json = run_cli_json(&socket_path, &["status", "server", "--json"]); assert_eq!(server_json["status"], "running"); assert_eq!(server_json["version"], env!("CARGO_PKG_VERSION")); - assert_eq!(server_json["protocol"], 11); + assert_eq!(server_json["protocol"], 12); assert_eq!(server_json["compatible"], true); let client_json = run_cli_json(&socket_path, &["status", "client", "--json"]); assert_eq!(client_json["version"], env!("CARGO_PKG_VERSION")); - assert_eq!(client_json["protocol"], 11); + assert_eq!(client_json["protocol"], 12); assert!(client_json["binary"] .as_str() .is_some_and(|path| !path.is_empty())); diff --git a/tests/client_mode.rs b/tests/client_mode.rs index b7504aca..13dca5ef 100644 --- a/tests/client_mode.rs +++ b/tests/client_mode.rs @@ -274,8 +274,8 @@ fn client_connects_and_receives_frame() { // Connect and handshake. let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket"); let (version, error) = - client_handshake(&mut stream, 11, 80, 24).expect("handshake should succeed"); - assert_eq!(version, 11, "server should report protocol version 11"); + client_handshake(&mut stream, 12, 80, 24).expect("handshake should succeed"); + assert_eq!(version, 12, "server should report protocol version 12"); assert!( error.is_none(), "handshake should not have error: {:?}", @@ -342,8 +342,8 @@ fn client_sees_headless_startup_config_diagnostic() { let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket"); let (version, error) = - client_handshake(&mut stream, 11, 80, 24).expect("handshake should succeed"); - assert_eq!(version, 11); + client_handshake(&mut stream, 12, 80, 24).expect("handshake should succeed"); + assert_eq!(version, 12); assert!(error.is_none(), "{:?}", error); stream @@ -391,8 +391,8 @@ fn client_input_forwarded_to_pane() { // Connect and handshake. let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket"); let (version, error) = - client_handshake(&mut stream, 11, 80, 24).expect("handshake should succeed"); - assert_eq!(version, 11); + client_handshake(&mut stream, 12, 80, 24).expect("handshake should succeed"); + assert_eq!(version, 12); assert!(error.is_none(), "{:?}", error); // Send an Input message containing "echo hello\n". @@ -445,8 +445,8 @@ fn client_resize_sends_message() { // Connect and handshake. let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket"); let (version, error) = - client_handshake(&mut stream, 11, 80, 24).expect("handshake should succeed"); - assert_eq!(version, 11); + client_handshake(&mut stream, 12, 80, 24).expect("handshake should succeed"); + assert_eq!(version, 12); assert!(error.is_none(), "{:?}", error); // Drain the initial frame(s). @@ -504,8 +504,8 @@ fn server_shutdown_sends_message_to_client() { // Connect and handshake. let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket"); let (version, error) = - client_handshake(&mut stream, 11, 80, 24).expect("handshake should succeed"); - assert_eq!(version, 11); + client_handshake(&mut stream, 12, 80, 24).expect("handshake should succeed"); + assert_eq!(version, 12); assert!(error.is_none(), "{:?}", error); // Send SIGINT so the server takes the graceful shutdown path and @@ -736,8 +736,8 @@ fn client_receives_frame_after_pane_output() { // Connect and handshake. let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket"); let (version, error) = - client_handshake(&mut stream, 11, 80, 24).expect("handshake should succeed"); - assert_eq!(version, 11); + client_handshake(&mut stream, 12, 80, 24).expect("handshake should succeed"); + assert_eq!(version, 12); assert!(error.is_none(), "{:?}", error); read_next_frame_payload(&mut stream, Duration::from_secs(10)) @@ -783,8 +783,8 @@ fn navigate_mode_keybind_dispatch_in_server() { // Connect and handshake. let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket"); let (version, error) = - client_handshake(&mut stream, 11, 80, 24).expect("handshake should succeed"); - assert_eq!(version, 11); + client_handshake(&mut stream, 12, 80, 24).expect("handshake should succeed"); + assert_eq!(version, 12); assert!(error.is_none(), "{:?}", error); // Drain initial frames. @@ -901,8 +901,8 @@ fn graceful_shutdown_sends_server_shutdown_to_client() { // Connect and handshake. let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket"); let (version, error) = - client_handshake(&mut stream, 11, 80, 24).expect("handshake should succeed"); - assert_eq!(version, 11); + client_handshake(&mut stream, 12, 80, 24).expect("handshake should succeed"); + assert_eq!(version, 12); assert!(error.is_none(), "{:?}", error); // Drain initial frame(s). @@ -1000,8 +1000,8 @@ fn client_receives_notify_on_agent_state_change() { // Connect as a client and perform handshake. let mut stream = UnixStream::connect(&client_socket).expect("should connect"); let (version, error) = - client_handshake(&mut stream, 11, 80, 24).expect("handshake should succeed"); - assert_eq!(version, 11); + client_handshake(&mut stream, 12, 80, 24).expect("handshake should succeed"); + assert_eq!(version, 12); assert!(error.is_none(), "{:?}", error); // Drain initial frame(s). diff --git a/tests/cross_area.rs b/tests/cross_area.rs index 008358d0..38c22192 100644 --- a/tests/cross_area.rs +++ b/tests/cross_area.rs @@ -428,6 +428,7 @@ fn client_handshake(stream: &mut UnixStream, version: u32, cols: u16, rows: u16) payload.extend_from_slice(&encode_varint_u32(16)); // cell_height_px payload.extend_from_slice(&encode_varint_u32(0)); // RenderEncoding::SemanticFrame payload.extend_from_slice(&encode_varint_u32(0)); // ClientKeybindings::Server + payload.extend_from_slice(&encode_varint_u32(0)); // ClientLaunchMode::App stream .write_all(&frame_message(&payload)) @@ -686,7 +687,7 @@ fn cross_area_detach_and_reattach_preserves_state() { // Local attach (client A). let mut client_a = UnixStream::connect(&client_socket).expect("client A should connect"); - client_handshake(&mut client_a, 11, 100, 30); + client_handshake(&mut client_a, 12, 100, 30); assert!(wait_for_frame(&mut client_a, Duration::from_secs(2))); // Use herdr: create a workspace and write output into its pane. @@ -723,7 +724,7 @@ fn cross_area_detach_and_reattach_preserves_state() { // Reattach from another terminal/session (client B). let mut client_b = UnixStream::connect(&client_socket).expect("client B should connect"); - client_handshake(&mut client_b, 11, 80, 24); + client_handshake(&mut client_b, 12, 80, 24); assert!( wait_for_frame(&mut client_b, Duration::from_secs(5)), "reattached client should receive frame" @@ -779,7 +780,7 @@ fn cross_area_agent_process_survives_detach_and_reattach() { wait_for_socket(&client_socket, Duration::from_secs(10)); let mut client_a = UnixStream::connect(&client_socket).expect("client A should connect"); - client_handshake(&mut client_a, 11, 100, 30); + client_handshake(&mut client_a, 12, 100, 30); assert!(wait_for_frame(&mut client_a, Duration::from_secs(2))); let created = workspace_create(&api_socket, "agent-persist"); @@ -832,7 +833,7 @@ fn cross_area_agent_process_survives_detach_and_reattach() { // Reattach and ensure client-side state reflects the persisted working status. let mut client_b = UnixStream::connect(&client_socket).expect("client B should connect"); - client_handshake(&mut client_b, 11, 80, 24); + client_handshake(&mut client_b, 12, 80, 24); let saw_working_on_client = wait_for_frame_matching(&mut client_b, Duration::from_secs(5), |frame| { frame_contains_text(frame, "working") @@ -877,7 +878,7 @@ fn cross_area_client_and_api_workspace_views_are_consistent() { wait_for_socket(&client_socket, Duration::from_secs(10)); let mut client = UnixStream::connect(&client_socket).expect("client should connect"); - client_handshake(&mut client, 11, 100, 30); + client_handshake(&mut client, 12, 100, 30); assert!(wait_for_frame(&mut client, Duration::from_secs(2))); drain_server_messages(&mut client, Duration::from_millis(300)); @@ -940,9 +941,9 @@ fn cross_area_two_clients_shared_view_and_single_detach_stability() { wait_for_socket(&client_socket, Duration::from_secs(10)); let mut client_a = UnixStream::connect(&client_socket).expect("client A should connect"); - client_handshake(&mut client_a, 11, 110, 30); + client_handshake(&mut client_a, 12, 110, 30); let mut client_b = UnixStream::connect(&client_socket).expect("client B should connect"); - client_handshake(&mut client_b, 11, 100, 30); + client_handshake(&mut client_b, 12, 100, 30); assert!(wait_for_frame(&mut client_a, Duration::from_secs(2))); assert!(wait_for_frame(&mut client_b, Duration::from_secs(2))); @@ -1111,7 +1112,7 @@ fn cross_area_server_kill_then_restart_and_reconnect() { let mut reconnect_client = UnixStream::connect(&client_socket).expect("new client should connect after restart"); - client_handshake(&mut reconnect_client, 11, 80, 24); + client_handshake(&mut reconnect_client, 12, 80, 24); assert!( wait_for_frame(&mut reconnect_client, Duration::from_secs(5)), "new client should receive frame after restart" diff --git a/tests/detach_reattach.rs b/tests/detach_reattach.rs index 1e90d94b..18b0e6fc 100644 --- a/tests/detach_reattach.rs +++ b/tests/detach_reattach.rs @@ -276,8 +276,8 @@ fn navigate_q_detaches_client_and_server_persists() { // Connect and handshake. let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket"); let (version, error) = - client_handshake(&mut stream, 11, 80, 24).expect("handshake should succeed"); - assert_eq!(version, 11); + client_handshake(&mut stream, 12, 80, 24).expect("handshake should succeed"); + assert_eq!(version, 12); assert!(error.is_none(), "{:?}", error); // Drain initial frames. @@ -338,8 +338,8 @@ fn explicit_detach_message_causes_clean_disconnect() { // Connect and handshake. let mut stream = UnixStream::connect(&client_socket).expect("should connect"); let (version, error) = - client_handshake(&mut stream, 11, 80, 24).expect("handshake should succeed"); - assert_eq!(version, 11); + client_handshake(&mut stream, 12, 80, 24).expect("handshake should succeed"); + assert_eq!(version, 12); assert!(error.is_none(), "{:?}", error); // Drain initial frames. @@ -397,8 +397,8 @@ fn reattach_after_detach_shows_current_state() { // --- Client A --- let mut stream_a = UnixStream::connect(&client_socket).expect("client A should connect"); let (version, error) = - client_handshake(&mut stream_a, 11, 80, 24).expect("handshake should succeed"); - assert_eq!(version, 11); + client_handshake(&mut stream_a, 12, 80, 24).expect("handshake should succeed"); + assert_eq!(version, 12); assert!(error.is_none(), "{:?}", error); // Drain initial frames. @@ -436,8 +436,8 @@ fn reattach_after_detach_shows_current_state() { // --- Client B (reattach) --- let mut stream_b = UnixStream::connect(&client_socket).expect("client B should connect"); let (version, error) = - client_handshake(&mut stream_b, 11, 80, 24).expect("handshake should succeed"); - assert_eq!(version, 11); + client_handshake(&mut stream_b, 12, 80, 24).expect("handshake should succeed"); + assert_eq!(version, 12); assert!( error.is_none(), "reattach handshake should succeed: {:?}", @@ -516,8 +516,8 @@ fn processes_survive_during_and_after_detach() { // Connect and handshake. let mut stream = UnixStream::connect(&client_socket).expect("should connect"); let (version, error) = - client_handshake(&mut stream, 11, 80, 24).expect("handshake should succeed"); - assert_eq!(version, 11); + client_handshake(&mut stream, 12, 80, 24).expect("handshake should succeed"); + assert_eq!(version, 12); assert!(error.is_none(), "{:?}", error); // Drain initial frames. @@ -555,8 +555,8 @@ fn processes_survive_during_and_after_detach() { // Reattach — verify we can connect and receive a frame. let mut stream_b = UnixStream::connect(&client_socket).expect("should reattach"); let (version, error) = - client_handshake(&mut stream_b, 11, 80, 24).expect("reattach handshake should succeed"); - assert_eq!(version, 11); + client_handshake(&mut stream_b, 12, 80, 24).expect("reattach handshake should succeed"); + assert_eq!(version, 12); assert!(error.is_none(), "{:?}", error); // Verify the reattached client receives a frame. @@ -604,8 +604,8 @@ fn server_persists_after_client_connection_drop() { // Connect and handshake. let mut stream = UnixStream::connect(&client_socket).expect("should connect"); let (version, error) = - client_handshake(&mut stream, 11, 80, 24).expect("handshake should succeed"); - assert_eq!(version, 11); + client_handshake(&mut stream, 12, 80, 24).expect("handshake should succeed"); + assert_eq!(version, 12); assert!(error.is_none(), "{:?}", error); // Drain initial frames. @@ -631,8 +631,8 @@ fn server_persists_after_client_connection_drop() { // Reattach — verify we can connect and handshake again. let mut stream_b = UnixStream::connect(&client_socket).expect("should reattach"); let (version, error) = - client_handshake(&mut stream_b, 11, 80, 24).expect("reattach handshake should succeed"); - assert_eq!(version, 11); + client_handshake(&mut stream_b, 12, 80, 24).expect("reattach handshake should succeed"); + assert_eq!(version, 12); assert!(error.is_none(), "reattach should succeed: {:?}", error); cleanup_spawned_herdr(spawned, base); @@ -653,8 +653,8 @@ fn detached_output_preserves_last_attached_pty_size() { let mut stream = UnixStream::connect(&client_socket).expect("client should connect"); let (version, error) = - client_handshake(&mut stream, 11, 120, 40).expect("handshake should succeed"); - assert_eq!(version, 11); + client_handshake(&mut stream, 12, 120, 40).expect("handshake should succeed"); + assert_eq!(version, 12); assert!(error.is_none(), "{:?}", error); drain_messages(&mut stream); @@ -722,8 +722,8 @@ fn output_accumulated_while_detached_visible_on_reattach() { // Connect and handshake client A. let mut stream_a = UnixStream::connect(&client_socket).expect("client A should connect"); let (version, error) = - client_handshake(&mut stream_a, 11, 80, 24).expect("handshake should succeed"); - assert_eq!(version, 11); + client_handshake(&mut stream_a, 12, 80, 24).expect("handshake should succeed"); + assert_eq!(version, 12); assert!(error.is_none(), "{:?}", error); // Detach client A immediately. @@ -780,8 +780,8 @@ fn output_accumulated_while_detached_visible_on_reattach() { // --- Client B (reattach) --- let mut stream_b = UnixStream::connect(&client_socket).expect("client B should connect"); let (version, error) = - client_handshake(&mut stream_b, 11, 80, 24).expect("reattach handshake should succeed"); - assert_eq!(version, 11); + client_handshake(&mut stream_b, 12, 80, 24).expect("reattach handshake should succeed"); + assert_eq!(version, 12); assert!(error.is_none(), "{:?}", error); // Client B should receive a frame with the current state. diff --git a/tests/multi_client.rs b/tests/multi_client.rs index ca525e69..3080b677 100644 --- a/tests/multi_client.rs +++ b/tests/multi_client.rs @@ -500,6 +500,7 @@ fn client_handshake( &encode_varint_u32(16), // cell_height_px &encode_varint_u32(0), // RenderEncoding::SemanticFrame &encode_varint_u32(0), // ClientKeybindings::Server + &encode_varint_u32(0), // ClientLaunchMode::App ], ); stream @@ -550,7 +551,7 @@ fn client_handshake( fn connect_raw_client(client_socket: &Path, cols: u16, rows: u16) -> UnixStream { let mut stream = UnixStream::connect(client_socket).expect("should connect to client socket"); - client_handshake(&mut stream, 11, cols, rows).expect("handshake should succeed"); + client_handshake(&mut stream, 12, cols, rows).expect("handshake should succeed"); stream } diff --git a/tests/server_headless.rs b/tests/server_headless.rs index 332398e0..08d4f5a5 100644 --- a/tests/server_headless.rs +++ b/tests/server_headless.rs @@ -180,6 +180,7 @@ fn client_handshake( &encode_varint_u32(16), // cell_height_px &encode_varint_u32(0), // RenderEncoding::SemanticFrame &encode_varint_u32(0), // ClientKeybindings::Server + &encode_varint_u32(0), // ClientLaunchMode::App ], ); let framed = frame_message(&hello_payload); @@ -594,9 +595,9 @@ fn client_handshake_succeeds() { // Send Hello with the current protocol version, 80 cols, 24 rows. let (version, error) = - client_handshake(&mut stream, 11, 80, 24).expect("handshake should succeed"); + client_handshake(&mut stream, 12, 80, 24).expect("handshake should succeed"); - assert_eq!(version, 11, "server should report protocol version 11"); + assert_eq!(version, 12, "server should report protocol version 12"); assert!( error.is_none(), "handshake should not have an error: {:?}", @@ -625,7 +626,7 @@ fn client_handshake_rejects_incompatible_version() { let (version, error) = client_handshake(&mut stream, 0, 80, 24) .expect("should read Welcome response even on rejection"); - assert_eq!(version, 11, "server should report its version 11"); + assert_eq!(version, 12, "server should report its version 12"); assert!( error.is_some(), "version 0 should be rejected with an error" @@ -650,10 +651,10 @@ fn client_handshake_clamps_small_terminal_size() { // Send Hello with 0x0 terminal size — should be clamped. let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket"); - let (version, error) = client_handshake(&mut stream, 11, 0, 0) + let (version, error) = client_handshake(&mut stream, 12, 0, 0) .expect("handshake with 0x0 should succeed (server clamps)"); - assert_eq!(version, 11); + assert_eq!(version, 12); assert!( error.is_none(), "0x0 size should be accepted (clamped): {:?}", @@ -713,9 +714,9 @@ fn no_hello_client_closed_within_five_seconds() { // Verify the server is still healthy — a proper client can still connect. let mut good_stream = UnixStream::connect(&client_socket).expect("should connect after no-hello client"); - let (version, error) = client_handshake(&mut good_stream, 11, 80, 24) + let (version, error) = client_handshake(&mut good_stream, 12, 80, 24) .expect("proper handshake should still work after no-hello client"); - assert_eq!(version, 11); + assert_eq!(version, 12); assert!(error.is_none()); // API should still work. diff --git a/tests/support/mod.rs b/tests/support/mod.rs index cae140c9..0e357ff7 100644 --- a/tests/support/mod.rs +++ b/tests/support/mod.rs @@ -224,6 +224,7 @@ pub fn client_handshake( &encode_varint_u32(16), // cell_height_px &encode_varint_u32(0), // RenderEncoding::SemanticFrame &encode_varint_u32(0), // ClientKeybindings::Server + &encode_varint_u32(0), // ClientLaunchMode::App ], ); let framed = frame_message(&hello_payload);