perf: reduce sidebar git polling overhead

refs #353
This commit is contained in:
Ogulcan Celik
2026-05-30 23:58:34 +03:00
parent d477341e6a
commit fc4e1bbf6b
25 changed files with 2656 additions and 229 deletions
+5 -1
View File
@@ -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(),
+17 -2
View File
@@ -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)
+11 -2
View File
@@ -97,6 +97,8 @@ pub struct App {
pub(crate) copy_feedback_deadline: Option<Instant>,
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<std::path::PathBuf, crate::workspace::GitStatusCacheEntry>,
pub(crate) last_sidebar_divider_click: Option<Instant>,
pub(crate) last_pane_click: Option<PaneClickState>,
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]
+256 -21
View File
@@ -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<GitStatusCacheEntry>,
pub(crate) targets: Vec<WorkspaceGitRefreshTarget>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct WorkspaceGitRefreshOutput {
pub(crate) results: Vec<WorkspaceGitStatus>,
pub(crate) cache_updates: Vec<(std::path::PathBuf, GitStatusCacheEntry)>,
}
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::<Vec<WorkspaceGitStatus>>();
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<Instant> {
(!self.git_refresh_in_flight && !self.state.workspaces.is_empty())
.then_some(self.last_git_remote_status_refresh + GIT_REMOTE_STATUS_REFRESH_INTERVAL)
}
pub(crate) fn next_loop_deadline(&self, now: Instant, needs_render: bool) -> Option<Instant> {
self.next_loop_deadline_with_resize_poll(now, needs_render, true)
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<Instant> {
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<Instant> {
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<WorkspaceGitRefreshItem> {
self.state
.workspaces
.iter()
.filter_map(|ws| {
let cwd =
ws.resolved_identity_cwd_from(&self.state.terminals, &self.terminal_runtimes)?;
let git_key = crate::workspace::git_status_cache_key(&cwd);
let cache_key = git_key.unwrap_or_else(|| cwd.clone());
Some(WorkspaceGitRefreshItem {
workspace_id: ws.id.clone(),
resolved_identity_cwd: cwd,
cache_key,
})
})
.collect()
}
pub(crate) fn drain_internal_events(&mut self) -> bool {
self.drain_internal_events_up_to(super::APP_EVENT_DRAIN_LIMIT)
}
@@ -497,11 +549,69 @@ impl App {
}
}
pub(crate) fn deduplicate_git_refresh_items(
items: Vec<WorkspaceGitRefreshItem>,
cache: &HashMap<std::path::PathBuf, GitStatusCacheEntry>,
) -> Vec<WorkspaceGitRefreshJob> {
let mut indexes = HashMap::<std::path::PathBuf, usize>::new();
let mut jobs = Vec::<WorkspaceGitRefreshJob>::new();
for item in items {
let target = WorkspaceGitRefreshTarget {
workspace_id: item.workspace_id,
resolved_identity_cwd: item.resolved_identity_cwd.clone(),
};
if let Some(&index) = indexes.get(&item.cache_key) {
jobs[index].targets.push(target);
continue;
}
let status_cwd = item.cache_key.clone();
let cached = cache.get(&item.cache_key).cloned();
indexes.insert(item.cache_key, jobs.len());
jobs.push(WorkspaceGitRefreshJob {
cache_key: status_cwd.clone(),
status_cwd,
cached,
targets: vec![target],
});
}
jobs
}
pub(crate) fn refresh_workspace_git_statuses_with_cache(
items: Vec<WorkspaceGitRefreshItem>,
cache: &HashMap<std::path::PathBuf, GitStatusCacheEntry>,
) -> WorkspaceGitRefreshOutput {
let mut results = Vec::new();
let mut cache_updates = Vec::new();
for job in deduplicate_git_refresh_items(items, cache) {
let (snapshot, cache_entry) =
Workspace::git_status_snapshot_for_cwd_with_cache(&job.status_cwd, job.cached.as_ref());
if let Some(cache_entry) = cache_entry {
cache_updates.push((job.cache_key.clone(), cache_entry));
}
results.extend(job.targets.into_iter().map(move |target| {
snapshot
.clone()
.into_workspace_status(target.workspace_id, target.resolved_identity_cwd)
}));
}
WorkspaceGitRefreshOutput {
results,
cache_updates,
}
}
#[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.
+10 -3
View File
@@ -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<RenderEncoding, ClientError> {
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) => {
+5 -2
View File
@@ -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<u8> },
/// Background git status refresh completed for workspaces.
GitStatusRefreshed { results: Vec<WorkspaceGitStatus> },
GitStatusRefreshed {
results: Vec<WorkspaceGitStatus>,
cache_updates: Vec<(std::path::PathBuf, GitStatusCacheEntry)>,
},
/// Background `git worktree add` completed.
WorktreeAddFinished(WorktreeAddResult),
/// Background `git worktree remove` completed.
+17 -1
View File
@@ -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(),
+137 -60
View File
@@ -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<Box<crate::config::LiveKeybindConfig>>,
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:?}"),
+15 -2
View File
@@ -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<Box<crate::config::LiveKeybindConfig>>,
/// 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<bool>,
last_activity: u64,
render_encoding: RenderEncoding,
pending_terminal_attach: bool,
writer: Option<ClientWriter>,
) -> 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<u64, ClientConnection>) -> Option<u64> {
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<RenderTarget> {
let mut targets: Vec<RenderTarget> = 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,
+149 -4
View File
@@ -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<u64> = 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!(
+33 -12
View File
@@ -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<GitSpaceMetadata>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorkspaceGitStatusSnapshot {
pub branch: Option<String>,
pub ahead_behind: Option<(usize, usize)>,
pub space: Option<GitSpaceMetadata>,
}
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<GitStatusCacheEntry>) {
self::git::git_status_snapshot_for_cwd(resolved_identity_cwd, cached)
}
pub fn find_tab_index_for_pane(&self, pane_id: PaneId) -> Option<usize> {
+659
View File
@@ -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<BranchConfig> {
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<PathBuf>,
) -> Option<BranchConfig> {
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::<Vec<_>>();
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<PathBuf> {
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 &section {
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<PathBuf>,
) {
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 &section {
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<PathBuf>,
) {
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 &section {
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<PathBuf>,
) -> 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 &section {
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 = &section[..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<String> {
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::<Vec<_>>();
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<String> {
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,
}
}
+733
View File
@@ -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();
}
@@ -40,44 +40,23 @@ pub fn derive_label_from_cwd(cwd: &Path) -> String {
}
pub fn git_worktree_info(cwd: &Path) -> Option<GitWorktreeInfo> {
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<String> {
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<GitSpaceMetadata> {
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<GitSpaceMetadata> {
})
}
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<String> {
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<String> {
parse_git_head_branch(&head)
}
fn git_dir_for_repo_root(repo_root: &Path) -> Option<PathBuf> {
pub(super) fn git_dir_for_repo_root(repo_root: &Path) -> Option<PathBuf> {
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<String> {
(!branch.is_empty()).then(|| branch.to_string())
}
fn git_repo_root(start: &Path) -> Option<PathBuf> {
pub(super) fn git_repo_root(start: &Path) -> Option<PathBuf> {
let mut current = if start.is_dir() {
start.to_path_buf()
} else {
@@ -152,7 +144,10 @@ fn git_repo_root(start: &Path) -> Option<PathBuf> {
};
loop {
if current.join(".git").exists() {
if git_dir_for_repo_root(&current)
.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<PathBuf> {
}
}
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<String> {
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();
}
}
+15
View File
@@ -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;
+386
View File
@@ -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<GitUpstreamIdentity>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GitHeadIdentity {
Branch {
full_ref: String,
short_name: String,
oid: Option<String>,
},
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<String>,
}
pub fn git_status_cache_key(cwd: &Path) -> Option<PathBuf> {
git_worktree_info(cwd).map(|info| canonicalize_best_effort_path(&info.repo_root))
}
pub fn git_status_snapshot_for_cwd(
cwd: &Path,
cached: Option<&GitStatusCacheEntry>,
) -> (WorkspaceGitStatusSnapshot, Option<GitStatusCacheEntry>) {
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<GitStatusFingerprint> {
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<GitHeadIdentity> {
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<GitUpstreamIdentity> {
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();
}
}
+51
View File
@@ -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)
);
}
+1 -1
View File
@@ -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);
}
+6 -6
View File
@@ -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()));
+18 -18
View File
@@ -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).
+9 -8
View File
@@ -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"
+22 -22
View File
@@ -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.
+2 -1
View File
@@ -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
}
+8 -7
View File
@@ -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.
+1
View File
@@ -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);