aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-05-30 23:58:15 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-05-30 23:58:34 +0300
commitfc4e1bbf6b0fa2ec37445a5b72356514c7c6be6a (patch)
tree93af04af44a4dcc062a7886c51d70641c1903144
parentd477341e6af816ff4396042b084a0378ef437b4a (diff)
perf: reduce sidebar git polling overhead
refs #353
-rw-r--r--src/app/actions.rs6
-rw-r--r--src/app/api.rs19
-rw-r--r--src/app/mod.rs13
-rw-r--r--src/app/runtime.rs277
-rw-r--r--src/client/mod.rs13
-rw-r--r--src/events.rs7
-rw-r--r--src/protocol/wire.rs18
-rw-r--r--src/server/client_transport.rs197
-rw-r--r--src/server/clients.rs17
-rw-r--r--src/server/headless.rs153
-rw-r--r--src/workspace.rs45
-rw-r--r--src/workspace/git/config.rs659
-rw-r--r--src/workspace/git/config_tests.rs733
-rw-r--r--src/workspace/git/discovery.rs (renamed from src/workspace/git.rs)146
-rw-r--r--src/workspace/git/mod.rs15
-rw-r--r--src/workspace/git/status.rs386
-rw-r--r--src/workspace/git/test_support.rs51
-rw-r--r--tests/api_ping.rs2
-rw-r--r--tests/cli_wrapper.rs12
-rw-r--r--tests/client_mode.rs36
-rw-r--r--tests/cross_area.rs17
-rw-r--r--tests/detach_reattach.rs44
-rw-r--r--tests/multi_client.rs3
-rw-r--r--tests/server_headless.rs15
-rw-r--r--tests/support/mod.rs1
25 files changed, 2656 insertions, 229 deletions
diff --git a/src/app/actions.rs b/src/app/actions.rs
index 40c8e74c..981212b1 100644
--- a/src/app/actions.rs
+++ b/src/app/actions.rs
@@ -2044,8 +2044,12 @@ impl AppState {
// Intercepted in App::handle_internal_event before reaching this
// dispatch; never touches AppState.
AppEvent::ClipboardWrite { .. } => Vec::new(),
- AppEvent::GitStatusRefreshed { results } => {
+ AppEvent::GitStatusRefreshed {
+ results,
+ cache_updates,
+ } => {
let _ = results;
+ let _ = cache_updates;
Vec::new()
}
AppEvent::WorktreeAddFinished(_) => Vec::new(),
diff --git a/src/app/api.rs b/src/app/api.rs
index a26b2856..d8224506 100644
--- a/src/app/api.rs
+++ b/src/app/api.rs
@@ -21,14 +21,29 @@ enum RuntimeExitAction {
impl App {
pub(crate) fn handle_internal_event(&mut self, ev: AppEvent) {
if let AppEvent::ClipboardWrite { content } = ev {
+ #[cfg(not(test))]
crate::selection::write_osc52_bytes(&content);
+ #[cfg(test)]
+ let _ = content;
self.show_clipboard_feedback();
return;
}
- if let AppEvent::GitStatusRefreshed { results } = ev {
+ if let AppEvent::GitStatusRefreshed {
+ results,
+ cache_updates,
+ } = ev
+ {
self.git_refresh_in_flight = false;
- self.last_git_remote_status_refresh = Instant::now();
+ for (key, entry) in cache_updates {
+ self.git_status_cache.insert(key, entry);
+ }
+ if self.git_refresh_due_after_in_flight {
+ self.mark_git_status_refresh_due(Instant::now());
+ self.git_refresh_due_after_in_flight = false;
+ } else {
+ self.last_git_remote_status_refresh = Instant::now();
+ }
if self
.state
.apply_workspace_git_statuses(&self.terminal_runtimes, results)
diff --git a/src/app/mod.rs b/src/app/mod.rs
index d8071c4d..273016db 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -97,6 +97,8 @@ pub struct App {
pub(crate) copy_feedback_deadline: Option<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]
diff --git a/src/app/runtime.rs b/src/app/runtime.rs
index 62b803f3..ad90a267 100644
--- a/src/app/runtime.rs
+++ b/src/app/runtime.rs
@@ -8,7 +8,35 @@ use super::{
RESIZE_POLL_INTERVAL, SELECTION_AUTOSCROLL_INTERVAL,
};
use crate::events::AppEvent;
-use crate::workspace::{Workspace, WorkspaceGitStatus};
+use crate::workspace::{GitStatusCacheEntry, Workspace, WorkspaceGitStatus};
+use std::collections::HashMap;
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub(crate) struct WorkspaceGitRefreshItem {
+ pub(crate) workspace_id: String,
+ pub(crate) resolved_identity_cwd: std::path::PathBuf,
+ pub(crate) cache_key: std::path::PathBuf,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub(crate) struct WorkspaceGitRefreshTarget {
+ pub(crate) workspace_id: String,
+ pub(crate) resolved_identity_cwd: std::path::PathBuf,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub(crate) struct WorkspaceGitRefreshJob {
+ pub(crate) cache_key: std::path::PathBuf,
+ pub(crate) status_cwd: std::path::PathBuf,
+ pub(crate) cached: Option<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(
@@ -526,6 +636,131 @@ mod tests {
}
#[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.
// Fail-closed: stop autoscroll instead of rescheduling forever.
diff --git a/src/client/mod.rs b/src/client/mod.rs
index 3e146f47..0a1b9507 100644
--- a/src/client/mod.rs
+++ b/src/client/mod.rs
@@ -31,9 +31,9 @@ use tracing::{debug, info, warn};
use crate::protocol::render_ansi;
use crate::protocol::{
- self, AttachScrollDirection, AttachScrollSource, ClientKeybindings, ClientMessage, NotifyKind,
- RenderEncoding, ServerMessage, MAX_CLIPBOARD_IMAGE_PAYLOAD, MAX_FRAME_SIZE,
- MAX_GRAPHICS_FRAME_SIZE, PROTOCOL_VERSION,
+ self, AttachScrollDirection, AttachScrollSource, ClientKeybindings, ClientLaunchMode,
+ ClientMessage, NotifyKind, RenderEncoding, ServerMessage, MAX_CLIPBOARD_IMAGE_PAYLOAD,
+ MAX_FRAME_SIZE, MAX_GRAPHICS_FRAME_SIZE, PROTOCOL_VERSION,
};
use crate::server::socket_paths::client_socket_path;
@@ -420,6 +420,7 @@ fn do_handshake(
cell_width_px: u32,
cell_height_px: u32,
requested_encoding: RenderEncoding,
+ direct_attach_requested: bool,
) -> Result<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) => {
diff --git a/src/events.rs b/src/events.rs
index 4bbfb219..84378b65 100644
--- a/src/events.rs
+++ b/src/events.rs
@@ -7,7 +7,7 @@ use std::time::Instant;
use crate::detect::{Agent, AgentState};
use crate::layout::PaneId;
-use crate::workspace::WorkspaceGitStatus;
+use crate::workspace::{GitStatusCacheEntry, WorkspaceGitStatus};
#[derive(Debug)]
pub struct WorktreeAddResult {
@@ -89,7 +89,10 @@ pub enum AppEvent {
/// re-emits it through herdr's own clipboard writer.
ClipboardWrite { content: Vec<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.
diff --git a/src/protocol/wire.rs b/src/protocol/wire.rs
index e6c5624c..576c4135 100644
--- a/src/protocol/wire.rs
+++ b/src/protocol/wire.rs
@@ -13,7 +13,7 @@ use serde::{Deserialize, Serialize};
// ---------------------------------------------------------------------------
/// Current protocol version. Bumped when wire format changes incompatibly.
-pub const PROTOCOL_VERSION: u32 = 11;
+pub const PROTOCOL_VERSION: u32 = 12;
/// Maximum allowed frame payload size (2 MB). Frames larger than this are
/// rejected to prevent denial-of-service via oversized length prefixes.
@@ -52,6 +52,15 @@ pub enum ClientKeybindings {
Local { keys_toml: String },
}
+/// Client behavior requested at connection time.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+pub enum ClientLaunchMode {
+ /// Full app client.
+ App,
+ /// Direct terminal attach client.
+ TerminalAttach,
+}
+
/// Messages sent from the client to the server over the client protocol socket.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ClientMessage {
@@ -71,6 +80,8 @@ pub enum ClientMessage {
requested_encoding: RenderEncoding,
/// Keybinding profile requested by the client.
keybindings: ClientKeybindings,
+ /// Whether this connection will render the full app or attach directly to a pane terminal.
+ launch_mode: ClientLaunchMode,
},
/// Raw input bytes read from the client's stdin.
@@ -651,6 +662,7 @@ mod tests {
cell_height_px: 16,
requested_encoding: RenderEncoding::SemanticFrame,
keybindings: ClientKeybindings::Server,
+ launch_mode: ClientLaunchMode::App,
};
let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap();
let (decoded, _): (ClientMessage, _) =
@@ -952,6 +964,7 @@ mod tests {
cell_height_px: 16,
requested_encoding: RenderEncoding::SemanticFrame,
keybindings: ClientKeybindings::Server,
+ launch_mode: ClientLaunchMode::App,
};
let mut buf = Vec::new();
write_message(&mut buf, &msg).unwrap();
@@ -1025,6 +1038,7 @@ mod tests {
cell_height_px: 16,
requested_encoding: RenderEncoding::SemanticFrame,
keybindings: ClientKeybindings::Server,
+ launch_mode: ClientLaunchMode::App,
},
1 => ClientMessage::Input {
data: vec![(i % 256) as u8; (i as usize % 50) + 1],
@@ -1460,6 +1474,7 @@ mod tests {
cell_height_px: 16,
requested_encoding: RenderEncoding::SemanticFrame,
keybindings: ClientKeybindings::Server,
+ launch_mode: ClientLaunchMode::App,
};
let mut buf = Vec::new();
write_message(&mut buf, &msg).unwrap();
@@ -1494,6 +1509,7 @@ mod tests {
cell_height_px: 16,
requested_encoding: RenderEncoding::SemanticFrame,
keybindings: ClientKeybindings::Server,
+ launch_mode: ClientLaunchMode::App,
},
ClientMessage::Input {
data: b"hello world".to_vec(),
diff --git a/src/server/client_transport.rs b/src/server/client_transport.rs
index 36334fda..931f1100 100644
--- a/src/server/client_transport.rs
+++ b/src/server/client_transport.rs
@@ -14,8 +14,8 @@ use tokio::sync::mpsc;
use tracing::{debug, warn};
use crate::protocol::{
- self, AttachScrollDirection, AttachScrollSource, ClientKeybindings, ClientMessage,
- RenderEncoding, ServerMessage, MAX_CLIPBOARD_IMAGE_PAYLOAD, MAX_FRAME_SIZE,
+ self, AttachScrollDirection, AttachScrollSource, ClientKeybindings, ClientLaunchMode,
+ ClientMessage, RenderEncoding, ServerMessage, MAX_CLIPBOARD_IMAGE_PAYLOAD, MAX_FRAME_SIZE,
MAX_GRAPHICS_FRAME_SIZE, PROTOCOL_VERSION,
};
@@ -57,6 +57,7 @@ pub(crate) enum ServerEvent {
cell_height_px: u32,
render_encoding: RenderEncoding,
keybindings: Option<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:?}"),
diff --git a/src/server/clients.rs b/src/server/clients.rs
index 026dd378..611d87eb 100644
--- a/src/server/clients.rs
+++ b/src/server/clients.rs
@@ -23,6 +23,8 @@ pub(crate) type RenderTarget = (
pub(crate) struct ClientConnection {
/// Whether this connection is the full app client or a direct terminal attach.
pub(crate) mode: ClientConnectionMode,
+ /// True after the handshake for clients that will switch into direct terminal attach mode.
+ pub(crate) pending_terminal_attach: bool,
/// Client-local app keybindings. None means use the server's keybindings.
pub(crate) keybindings: Option<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,
diff --git a/src/server/headless.rs b/src/server/headless.rs
index f59201e0..3d273b1c 100644
--- a/src/server/headless.rs
+++ b/src/server/headless.rs
@@ -423,7 +423,11 @@ impl HeadlessServer {
// 8. Wait for next event.
let next_deadline = self
.app
- .next_headless_loop_deadline(now, needs_render)
+ .next_headless_loop_deadline_with_git_refresh(
+ now,
+ needs_render,
+ self.has_app_client(),
+ )
.map(|deadline| deadline.min(now + CLIENT_ACCEPT_POLL_INTERVAL))
.or(Some(now + CLIENT_ACCEPT_POLL_INTERVAL));
let event = {
@@ -901,6 +905,17 @@ impl HeadlessServer {
changed
}
+ fn app_client_count(&self) -> usize {
+ self.clients
+ .values()
+ .filter(|client| client.is_full_app_client() && client.writer.is_some())
+ .count()
+ }
+
+ fn has_app_client(&self) -> bool {
+ self.app_client_count() > 0
+ }
+
fn remove_client(&mut self, client_id: u64) -> bool {
let was_foreground = self.foreground_client_id == Some(client_id);
self.send_client_graphics_cleanup(client_id);
@@ -1619,6 +1634,7 @@ impl HeadlessServer {
client.mode = ClientConnectionMode::TerminalAttach {
terminal_id: terminal_id.clone(),
};
+ client.pending_terminal_attach = false;
client.render_state.reset_baseline();
client.last_activity = stamp;
let was_foreground = self.foreground_client_id == Some(client_id);
@@ -1655,6 +1671,7 @@ impl HeadlessServer {
keybindings,
writer,
render_encoding,
+ direct_attach_requested,
} => {
if self.handoff_in_progress {
if let Ok(message) =
@@ -1669,6 +1686,7 @@ impl HeadlessServer {
}
return false;
}
+ let first_app_client = !direct_attach_requested && self.app_client_count() == 0;
info!(
client_id,
cols,
@@ -1693,10 +1711,16 @@ impl HeadlessServer {
None,
last_activity,
render_encoding,
+ direct_attach_requested,
Some(writer),
),
);
- self.foreground_client_id = Some(client_id);
+ if !direct_attach_requested {
+ self.foreground_client_id = Some(client_id);
+ }
+ if first_app_client {
+ self.app.mark_git_status_refresh_due(Instant::now());
+ }
self.sync_foreground_client_state();
self.resize_shared_runtime_to_effective_size();
self.nudge_handoff_panes_on_first_client_attach();
@@ -2190,7 +2214,7 @@ impl HeadlessServer {
let mut broken_clients: Vec<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!(
diff --git a/src/workspace.rs b/src/workspace.rs
index 4bab61a7..8a660b02 100644
--- a/src/workspace.rs
+++ b/src/workspace.rs
@@ -19,9 +19,13 @@ mod aggregate;
mod git;
mod tab;
+#[cfg(test)]
use self::git::git_ahead_behind;
pub use self::{
- git::{derive_label_from_cwd, git_branch, git_space_metadata, GitSpaceMetadata},
+ git::{
+ derive_label_from_cwd, git_branch, git_space_metadata, git_status_cache_key,
+ GitSpaceMetadata, GitStatusCacheEntry,
+ },
tab::Tab,
};
@@ -43,6 +47,29 @@ pub struct WorkspaceGitStatus {
pub space: Option<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> {
diff --git a/src/workspace/git/config.rs b/src/workspace/git/config.rs
new file mode 100644
index 00000000..9783387c
--- /dev/null
+++ b/src/workspace/git/config.rs
@@ -0,0 +1,659 @@
+use std::path::{Path, PathBuf};
+
+use super::discovery::{canonicalize_best_effort_path, GitWorktreeInfo};
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub(super) struct BranchConfig {
+ pub(super) remote: String,
+ pub(super) merge_ref: String,
+ fetch_refspecs: Vec<(String, String)>,
+ remote_urls: Vec<(String, String)>,
+}
+
+pub(super) fn read_branch_config(info: &GitWorktreeInfo, branch: &str) -> Option<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,
+ }
+}
diff --git a/src/workspace/git/config_tests.rs b/src/workspace/git/config_tests.rs
new file mode 100644
index 00000000..e789e1db
--- /dev/null
+++ b/src/workspace/git/config_tests.rs
@@ -0,0 +1,733 @@
+use super::config::*;
+use crate::workspace::git::{
+ discovery::git_worktree_info,
+ status::git_status_fingerprint,
+ test_support::{temp_test_dir, write_fake_tracked_repo},
+};
+
+#[test]
+fn git_status_fingerprint_honors_remote_fetch_refspec() {
+ let root = temp_test_dir("custom-fetch-refspec");
+ write_fake_tracked_repo(&root);
+ std::fs::remove_dir_all(root.join(".git/refs/remotes/origin")).unwrap();
+ std::fs::create_dir_all(root.join(".git/refs/remotes/upstream")).unwrap();
+ std::fs::write(
+ root.join(".git/refs/remotes/upstream/main"),
+ "2222222222222222222222222222222222222222\n",
+ )
+ .unwrap();
+ std::fs::write(
+ root.join(".git/config"),
+ "[remote \"origin\"]\n\tfetch = +refs/heads/*:refs/remotes/upstream/*\n[branch \"main\"]\n\tremote = origin\n\tmerge = refs/heads/main\n",
+ )
+ .unwrap();
+
+ let fingerprint = git_status_fingerprint(&root).unwrap();
+
+ let upstream = fingerprint.upstream.unwrap();
+ assert_eq!(upstream.full_ref, "refs/remotes/upstream/main");
+
+ std::fs::remove_dir_all(root).unwrap();
+}
+
+#[test]
+fn git_status_fingerprint_reads_included_config() {
+ let root = temp_test_dir("included-config");
+ write_fake_tracked_repo(&root);
+ std::fs::remove_dir_all(root.join(".git/refs/remotes/origin")).unwrap();
+ std::fs::create_dir_all(root.join(".git/refs/remotes/included")).unwrap();
+ std::fs::write(
+ root.join(".git/refs/remotes/included/main"),
+ "2222222222222222222222222222222222222222\n",
+ )
+ .unwrap();
+ std::fs::write(
+ root.join(".git/config"),
+ "[include]\n\tpath = included.cfg\n",
+ )
+ .unwrap();
+ std::fs::write(
+ root.join(".git/included.cfg"),
+ "[remote \"included\"]\n\tfetch = +refs/heads/*:refs/remotes/included/*\n[branch \"main\"]\n\tremote = included\n\tmerge = refs/heads/main\n",
+ )
+ .unwrap();
+
+ let fingerprint = git_status_fingerprint(&root).unwrap();
+
+ let upstream = fingerprint.upstream.unwrap();
+ assert_eq!(upstream.remote, "included");
+ assert_eq!(upstream.full_ref, "refs/remotes/included/main");
+
+ std::fs::remove_dir_all(root).unwrap();
+}
+
+#[test]
+fn git_status_branch_config_reads_user_config_before_repo_config() {
+ let root = temp_test_dir("user-config");
+ write_fake_tracked_repo(&root);
+ let user_config = root.join("user.gitconfig");
+ std::fs::write(root.join(".git/config"), "").unwrap();
+ std::fs::write(
+ &user_config,
+ "[remote \"global\"]\n\tfetch = +refs/heads/*:refs/remotes/global/*\n[branch \"main\"]\n\tremote = global\n\tmerge = refs/heads/main\n",
+ )
+ .unwrap();
+
+ let info = git_worktree_info(&root).unwrap();
+ let config = read_branch_config_with_user_paths(&info, "main", vec![user_config]).unwrap();
+
+ assert_eq!(config.remote, "global");
+ assert_eq!(
+ upstream_full_ref(&config).as_deref(),
+ Some("refs/remotes/global/main")
+ );
+
+ std::fs::remove_dir_all(root).unwrap();
+}
+
+#[test]
+fn git_status_branch_config_repo_config_overrides_user_config() {
+ let root = temp_test_dir("repo-overrides-user-config");
+ write_fake_tracked_repo(&root);
+ let user_config = root.join("user.gitconfig");
+ std::fs::write(
+ &user_config,
+ "[remote \"global\"]\n\tfetch = +refs/heads/*:refs/remotes/global/*\n[branch \"main\"]\n\tremote = global\n\tmerge = refs/heads/main\n",
+ )
+ .unwrap();
+
+ let info = git_worktree_info(&root).unwrap();
+ let config = read_branch_config_with_user_paths(&info, "main", vec![user_config]).unwrap();
+
+ assert_eq!(config.remote, "origin");
+ assert_eq!(
+ upstream_full_ref(&config).as_deref(),
+ Some("refs/remotes/origin/main")
+ );
+
+ std::fs::remove_dir_all(root).unwrap();
+}
+
+#[test]
+fn git_status_fingerprint_applies_repeated_includes_in_order() {
+ let root = temp_test_dir("repeated-include");
+ write_fake_tracked_repo(&root);
+ std::fs::remove_dir_all(root.join(".git/refs/remotes/origin")).unwrap();
+ std::fs::create_dir_all(root.join(".git/refs/remotes/included")).unwrap();
+ std::fs::write(
+ root.join(".git/refs/remotes/included/main"),
+ "2222222222222222222222222222222222222222\n",
+ )
+ .unwrap();
+ std::fs::write(
+ root.join(".git/config"),
+ "[include]\n\tpath = included.cfg\n[branch \"main\"]\n\tremote = middle\n[include]\n\tpath = included.cfg\n",
+ )
+ .unwrap();
+ std::fs::write(
+ root.join(".git/included.cfg"),
+ "[remote \"included\"]\n\tfetch = +refs/heads/*:refs/remotes/included/*\n[branch \"main\"]\n\tremote = included\n\tmerge = refs/heads/main\n",
+ )
+ .unwrap();
+
+ let fingerprint = git_status_fingerprint(&root).unwrap();
+
+ let upstream = fingerprint.upstream.unwrap();
+ assert_eq!(upstream.remote, "included");
+ assert_eq!(upstream.full_ref, "refs/remotes/included/main");
+
+ std::fs::remove_dir_all(root).unwrap();
+}
+
+#[test]
+fn git_status_fingerprint_reads_matching_include_if_config() {
+ let root = temp_test_dir("include-if-config");
+ write_fake_tracked_repo(&root);
+ std::fs::remove_dir_all(root.join(".git/refs/remotes/origin")).unwrap();
+ std::fs::create_dir_all(root.join(".git/refs/remotes/included")).unwrap();
+ std::fs::write(
+ root.join(".git/refs/remotes/included/main"),
+ "2222222222222222222222222222222222222222\n",
+ )
+ .unwrap();
+ std::fs::write(
+ root.join(".git/config"),
+ format!(
+ "[includeIf \"gitdir:{}\"]\n\tpath = included.cfg\n",
+ root.join(".git").display()
+ ),
+ )
+ .unwrap();
+ std::fs::write(
+ root.join(".git/included.cfg"),
+ "[remote \"included\"]\n\tfetch = +refs/heads/*:refs/remotes/included/*\n[branch \"main\"]\n\tremote = included\n\tmerge = refs/heads/main\n",
+ )
+ .unwrap();
+
+ let fingerprint = git_status_fingerprint(&root).unwrap();
+
+ let upstream = fingerprint.upstream.unwrap();
+ assert_eq!(upstream.remote, "included");
+ assert_eq!(upstream.full_ref, "refs/remotes/included/main");
+
+ std::fs::remove_dir_all(root).unwrap();
+}
+
+#[test]
+fn git_status_fingerprint_matches_gitdir_include_if_directory_pattern() {
+ let base = temp_test_dir("include-if-dir");
+ let root = base.join("work/repo");
+ std::fs::create_dir_all(&root).unwrap();
+ write_fake_tracked_repo(&root);
+ std::fs::remove_dir_all(root.join(".git/refs/remotes/origin")).unwrap();
+ std::fs::create_dir_all(root.join(".git/refs/remotes/included")).unwrap();
+ std::fs::write(
+ root.join(".git/refs/remotes/included/main"),
+ "2222222222222222222222222222222222222222\n",
+ )
+ .unwrap();
+ std::fs::write(
+ root.join(".git/config"),
+ format!(
+ "[includeIf \"gitdir:{}/\"]\n\tpath = included.cfg\n",
+ base.join("work").display()
+ ),
+ )
+ .unwrap();
+ std::fs::write(
+ root.join(".git/included.cfg"),
+ "[remote \"included\"]\n\tfetch = +refs/heads/*:refs/remotes/included/*\n[branch \"main\"]\n\tremote = included\n\tmerge = refs/heads/main\n",
+ )
+ .unwrap();
+
+ let fingerprint = git_status_fingerprint(&root).unwrap();
+
+ let upstream = fingerprint.upstream.unwrap();
+ assert_eq!(upstream.remote, "included");
+ assert_eq!(upstream.full_ref, "refs/remotes/included/main");
+
+ std::fs::remove_dir_all(base).unwrap();
+}
+
+#[test]
+fn git_status_fingerprint_reads_case_insensitive_config_keys() {
+ let root = temp_test_dir("case-insensitive-config");
+ write_fake_tracked_repo(&root);
+ std::fs::write(
+ root.join(".git/config"),
+ "[Remote \"origin\"] # remote section\n\tFetch = +refs/heads/*:refs/remotes/origin/*\n[Branch \"main\"] ; branch section\n\tRemote = origin\n\tMerge = refs/heads/main\n",
+ )
+ .unwrap();
+
+ let fingerprint = git_status_fingerprint(&root).unwrap();
+
+ let upstream = fingerprint.upstream.unwrap();
+ assert_eq!(upstream.remote, "origin");
+ assert_eq!(upstream.full_ref, "refs/remotes/origin/main");
+
+ std::fs::remove_dir_all(root).unwrap();
+}
+
+#[test]
+fn git_status_fingerprint_keeps_refspecs_for_later_remote_override() {
+ let root = temp_test_dir("worktree-remote-override");
+ write_fake_tracked_repo(&root);
+ std::fs::remove_dir_all(root.join(".git/refs/remotes/origin")).unwrap();
+ std::fs::create_dir_all(root.join(".git/refs/remotes/fork")).unwrap();
+ std::fs::write(
+ root.join(".git/refs/remotes/fork/main"),
+ "2222222222222222222222222222222222222222\n",
+ )
+ .unwrap();
+ std::fs::write(
+ root.join(".git/config"),
+ "[extensions]\n\tworktreeConfig = true\n[remote \"fork\"]\n\tfetch = +refs/heads/*:refs/remotes/fork/*\n[branch \"main\"]\n\tremote = origin\n\tmerge = refs/heads/main\n",
+ )
+ .unwrap();
+ std::fs::write(
+ root.join(".git/config.worktree"),
+ "[branch \"main\"]\n\tremote = fork\n",
+ )
+ .unwrap();
+
+ let fingerprint = git_status_fingerprint(&root).unwrap();
+
+ let upstream = fingerprint.upstream.unwrap();
+ assert_eq!(upstream.remote, "fork");
+ assert_eq!(upstream.full_ref, "refs/remotes/fork/main");
+
+ std::fs::remove_dir_all(root).unwrap();
+}
+
+#[test]
+fn git_status_fingerprint_ignores_worktree_config_when_extension_disabled() {
+ let root = temp_test_dir("worktree-config-disabled");
+ write_fake_tracked_repo(&root);
+ std::fs::create_dir_all(root.join(".git/refs/remotes/fork")).unwrap();
+ std::fs::write(
+ root.join(".git/refs/remotes/fork/main"),
+ "3333333333333333333333333333333333333333\n",
+ )
+ .unwrap();
+ std::fs::write(
+ root.join(".git/config"),
+ "[remote \"fork\"]\n\tfetch = +refs/heads/*:refs/remotes/fork/*\n[branch \"main\"]\n\tremote = origin\n\tmerge = refs/heads/main\n",
+ )
+ .unwrap();
+ std::fs::write(
+ root.join(".git/config.worktree"),
+ "[branch \"main\"]\n\tremote = fork\n",
+ )
+ .unwrap();
+
+ let fingerprint = git_status_fingerprint(&root).unwrap();
+
+ let upstream = fingerprint.upstream.unwrap();
+ assert_eq!(upstream.remote, "origin");
+ assert_eq!(upstream.full_ref, "refs/remotes/origin/main");
+
+ std::fs::remove_dir_all(root).unwrap();
+}
+
+#[test]
+fn git_status_fingerprint_accepts_git_boolean_worktree_config() {
+ let root = temp_test_dir("worktree-config-boolean");
+ write_fake_tracked_repo(&root);
+ std::fs::create_dir_all(root.join(".git/refs/remotes/fork")).unwrap();
+ std::fs::write(
+ root.join(".git/refs/remotes/fork/main"),
+ "3333333333333333333333333333333333333333\n",
+ )
+ .unwrap();
+ std::fs::write(
+ root.join(".git/config"),
+ "[extensions]\n\tworktreeConfig\n[remote \"fork\"]\n\tfetch = +refs/heads/*:refs/remotes/fork/*\n[branch \"main\"]\n\tremote = origin\n\tmerge = refs/heads/main\n",
+ )
+ .unwrap();
+ std::fs::write(
+ root.join(".git/config.worktree"),
+ "[branch \"main\"]\n\tremote = fork\n",
+ )
+ .unwrap();
+
+ let fingerprint = git_status_fingerprint(&root).unwrap();
+
+ let upstream = fingerprint.upstream.unwrap();
+ assert_eq!(upstream.remote, "fork");
+ assert_eq!(upstream.full_ref, "refs/remotes/fork/main");
+
+ std::fs::remove_dir_all(root).unwrap();
+}
+
+#[test]
+fn git_status_fingerprint_uses_last_worktree_config_boolean() {
+ let root = temp_test_dir("worktree-config-duplicate-boolean");
+ write_fake_tracked_repo(&root);
+ std::fs::create_dir_all(root.join(".git/refs/remotes/fork")).unwrap();
+ std::fs::write(
+ root.join(".git/refs/remotes/fork/main"),
+ "3333333333333333333333333333333333333333\n",
+ )
+ .unwrap();
+ std::fs::write(
+ root.join(".git/config"),
+ "[extensions]\n\tworktreeConfig = false\n\tworktreeConfig = true\n[remote \"fork\"]\n\tfetch = +refs/heads/*:refs/remotes/fork/*\n[branch \"main\"]\n\tremote = origin\n\tmerge = refs/heads/main\n",
+ )
+ .unwrap();
+ std::fs::write(
+ root.join(".git/config.worktree"),
+ "[branch \"main\"]\n\tremote = fork\n",
+ )
+ .unwrap();
+
+ let fingerprint = git_status_fingerprint(&root).unwrap();
+
+ let upstream = fingerprint.upstream.unwrap();
+ assert_eq!(upstream.remote, "fork");
+ assert_eq!(upstream.full_ref, "refs/remotes/fork/main");
+
+ std::fs::remove_dir_all(root).unwrap();
+}
+
+#[test]
+fn git_status_fingerprint_ignores_included_worktree_config_extension() {
+ let root = temp_test_dir("worktree-config-included-extension");
+ write_fake_tracked_repo(&root);
+ std::fs::create_dir_all(root.join(".git/refs/remotes/fork")).unwrap();
+ std::fs::write(
+ root.join(".git/refs/remotes/fork/main"),
+ "3333333333333333333333333333333333333333\n",
+ )
+ .unwrap();
+ std::fs::write(
+ root.join(".git/config"),
+ "[include]\n\tpath = extension.cfg\n[remote \"fork\"]\n\tfetch = +refs/heads/*:refs/remotes/fork/*\n[branch \"main\"]\n\tremote = origin\n\tmerge = refs/heads/main\n",
+ )
+ .unwrap();
+ std::fs::write(
+ root.join(".git/extension.cfg"),
+ "[extensions]\n\tworktreeConfig = true\n",
+ )
+ .unwrap();
+ std::fs::write(
+ root.join(".git/config.worktree"),
+ "[branch \"main\"]\n\tremote = fork\n",
+ )
+ .unwrap();
+
+ let fingerprint = git_status_fingerprint(&root).unwrap();
+
+ let upstream = fingerprint.upstream.unwrap();
+ assert_eq!(upstream.remote, "origin");
+ assert_eq!(upstream.full_ref, "refs/remotes/origin/main");
+
+ std::fs::remove_dir_all(root).unwrap();
+}
+
+#[test]
+fn git_status_fingerprint_reads_onbranch_include_if_config() {
+ let root = temp_test_dir("include-if-onbranch");
+ write_fake_tracked_repo(&root);
+ std::fs::remove_dir_all(root.join(".git/refs/remotes/origin")).unwrap();
+ std::fs::create_dir_all(root.join(".git/refs/remotes/included")).unwrap();
+ std::fs::write(
+ root.join(".git/refs/remotes/included/main"),
+ "2222222222222222222222222222222222222222\n",
+ )
+ .unwrap();
+ std::fs::write(
+ root.join(".git/config"),
+ "[includeIf \"onbranch:main\"]\n\tpath = included.cfg\n",
+ )
+ .unwrap();
+ std::fs::write(
+ root.join(".git/included.cfg"),
+ "[remote \"included\"]\n\tfetch = +refs/heads/*:refs/remotes/included/*\n[branch \"main\"]\n\tremote = included\n\tmerge = refs/heads/main\n",
+ )
+ .unwrap();
+
+ let fingerprint = git_status_fingerprint(&root).unwrap();
+
+ let upstream = fingerprint.upstream.unwrap();
+ assert_eq!(upstream.remote, "included");
+ assert_eq!(upstream.full_ref, "refs/remotes/included/main");
+
+ std::fs::remove_dir_all(root).unwrap();
+}
+
+#[test]
+fn git_status_fingerprint_reads_hasconfig_include_if_config() {
+ let root = temp_test_dir("include-if-hasconfig");
+ write_fake_tracked_repo(&root);
+ std::fs::remove_dir_all(root.join(".git/refs/remotes/origin")).unwrap();
+ std::fs::create_dir_all(root.join(".git/refs/remotes/included")).unwrap();
+ std::fs::write(
+ root.join(".git/refs/remotes/included/main"),
+ "2222222222222222222222222222222222222222\n",
+ )
+ .unwrap();
+ std::fs::write(
+ root.join(".git/config"),
+ "[remote \"fork\"]\n\turl = https://example.test/fork.git\n[includeIf \"hasconfig:remote.*.url:*fork.git\"]\n\tpath = included.cfg\n",
+ )
+ .unwrap();
+ std::fs::write(
+ root.join(".git/included.cfg"),
+ "[remote \"included\"]\n\tfetch = +refs/heads/*:refs/remotes/included/*\n[branch \"main\"]\n\tremote = included\n\tmerge = refs/heads/main\n",
+ )
+ .unwrap();
+
+ let fingerprint = git_status_fingerprint(&root).unwrap();
+
+ let upstream = fingerprint.upstream.unwrap();
+ assert_eq!(upstream.remote, "included");
+ assert_eq!(upstream.full_ref, "refs/remotes/included/main");
+
+ std::fs::remove_dir_all(root).unwrap();
+}
+
+#[test]
+fn git_status_fingerprint_matches_user_hasconfig_against_repo_remote_url() {
+ let root = temp_test_dir("include-if-hasconfig-user-repo-url");
+ let user_config = root.join("user.gitconfig");
+ write_fake_tracked_repo(&root);
+ std::fs::remove_dir_all(root.join(".git/refs/remotes/origin")).unwrap();
+ std::fs::create_dir_all(root.join(".git/refs/remotes/included")).unwrap();
+ std::fs::write(
+ root.join(".git/refs/remotes/included/main"),
+ "2222222222222222222222222222222222222222\n",
+ )
+ .unwrap();
+ std::fs::write(
+ root.join(".git/config"),
+ "[remote \"fork\"]\n\turl = https://example.test/fork.git\n",
+ )
+ .unwrap();
+ std::fs::write(
+ &user_config,
+ "[includeIf \"hasconfig:remote.*.url:*fork.git\"]\n\tpath = user-included.cfg\n",
+ )
+ .unwrap();
+ std::fs::write(
+ root.join("user-included.cfg"),
+ "[remote \"included\"]\n\tfetch = +refs/heads/*:refs/remotes/included/*\n[branch \"main\"]\n\tremote = included\n\tmerge = refs/heads/main\n",
+ )
+ .unwrap();
+
+ let info = git_worktree_info(&root).unwrap();
+ let config = read_branch_config_with_user_paths(&info, "main", vec![user_config]).unwrap();
+
+ assert_eq!(config.remote, "included");
+ assert_eq!(config.merge_ref, "refs/heads/main");
+
+ std::fs::remove_dir_all(root).unwrap();
+}
+
+#[test]
+fn git_status_fingerprint_skips_hasconfig_include_that_defines_remote_url() {
+ let root = temp_test_dir("include-if-hasconfig-rejects-remote-url");
+ let user_config = root.join("user.gitconfig");
+ write_fake_tracked_repo(&root);
+ std::fs::create_dir_all(root.join(".git/refs/remotes/included")).unwrap();
+ std::fs::write(
+ root.join(".git/refs/remotes/included/main"),
+ "3333333333333333333333333333333333333333\n",
+ )
+ .unwrap();
+ std::fs::write(
+ root.join(".git/config"),
+ "[remote \"fork\"]\n\turl = https://example.test/fork.git\n[branch \"main\"]\n\tremote = origin\n\tmerge = refs/heads/main\n",
+ )
+ .unwrap();
+ std::fs::write(
+ &user_config,
+ "[includeIf \"hasconfig:remote.*.url:*fork.git\"]\n\tpath = user-included.cfg\n",
+ )
+ .unwrap();
+ std::fs::write(
+ root.join("user-included.cfg"),
+ "[remote \"included\"]\n\turl = https://example.test/included.git\n\tfetch = +refs/heads/*:refs/remotes/included/*\n[branch \"main\"]\n\tremote = included\n\tmerge = refs/heads/main\n",
+ )
+ .unwrap();
+
+ let info = git_worktree_info(&root).unwrap();
+ let config = read_branch_config_with_user_paths(&info, "main", vec![user_config]).unwrap();
+
+ assert_eq!(config.remote, "origin");
+ assert_eq!(config.merge_ref, "refs/heads/main");
+
+ std::fs::remove_dir_all(root).unwrap();
+}
+
+#[test]
+fn git_status_fingerprint_skips_hasconfig_include_chain_that_defines_remote_url() {
+ let root = temp_test_dir("include-if-hasconfig-rejects-nested-remote-url");
+ let user_config = root.join("user.gitconfig");
+ write_fake_tracked_repo(&root);
+ std::fs::create_dir_all(root.join(".git/refs/remotes/included")).unwrap();
+ std::fs::write(
+ root.join(".git/refs/remotes/included/main"),
+ "3333333333333333333333333333333333333333\n",
+ )
+ .unwrap();
+ std::fs::write(
+ root.join(".git/config"),
+ "[remote \"fork\"]\n\turl = https://example.test/fork.git\n[branch \"main\"]\n\tremote = origin\n\tmerge = refs/heads/main\n",
+ )
+ .unwrap();
+ std::fs::write(
+ &user_config,
+ "[includeIf \"hasconfig:remote.*.url:*fork.git\"]\n\tpath = user-included.cfg\n",
+ )
+ .unwrap();
+ std::fs::write(
+ root.join("user-included.cfg"),
+ "[include]\n\tpath = nested-remote.cfg\n[branch \"main\"]\n\tremote = included\n\tmerge = refs/heads/main\n",
+ )
+ .unwrap();
+ std::fs::write(
+ root.join("nested-remote.cfg"),
+ "[remote \"included\"]\n\turl = https://example.test/included.git\n\tfetch = +refs/heads/*:refs/remotes/included/*\n",
+ )
+ .unwrap();
+
+ let info = git_worktree_info(&root).unwrap();
+ let config = read_branch_config_with_user_paths(&info, "main", vec![user_config]).unwrap();
+
+ assert_eq!(config.remote, "origin");
+ assert_eq!(config.merge_ref, "refs/heads/main");
+
+ std::fs::remove_dir_all(root).unwrap();
+}
+
+#[test]
+fn git_status_fingerprint_ignores_worktree_urls_for_hasconfig() {
+ let root = temp_test_dir("include-if-hasconfig-worktree-url");
+ write_fake_tracked_repo(&root);
+ std::fs::write(
+ root.join(".git/config"),
+ "[extensions]\n\tworktreeConfig = true\n[branch \"main\"]\n\tremote = origin\n\tmerge = refs/heads/main\n",
+ )
+ .unwrap();
+ std::fs::write(
+ root.join(".git/config.worktree"),
+ "[remote \"fork\"]\n\turl = https://example.test/fork.git\n[includeIf \"hasconfig:remote.*.url:*fork.git\"]\n\tpath = included.cfg\n",
+ )
+ .unwrap();
+ std::fs::write(
+ root.join(".git/included.cfg"),
+ "[branch \"main\"]\n\tremote = included\n",
+ )
+ .unwrap();
+
+ let fingerprint = git_status_fingerprint(&root).unwrap();
+
+ let upstream = fingerprint.upstream.unwrap();
+ assert_eq!(upstream.remote, "origin");
+ assert_eq!(upstream.full_ref, "refs/remotes/origin/main");
+
+ std::fs::remove_dir_all(root).unwrap();
+}
+
+#[test]
+fn git_status_fingerprint_stops_recursive_include_cycles() {
+ let root = temp_test_dir("include-cycle");
+ write_fake_tracked_repo(&root);
+ std::fs::remove_dir_all(root.join(".git/refs/remotes/origin")).unwrap();
+ std::fs::create_dir_all(root.join(".git/refs/remotes/included")).unwrap();
+ std::fs::write(
+ root.join(".git/refs/remotes/included/main"),
+ "2222222222222222222222222222222222222222\n",
+ )
+ .unwrap();
+ std::fs::write(root.join(".git/config"), "[include]\n\tpath = a.cfg\n").unwrap();
+ std::fs::write(root.join(".git/a.cfg"), "[include]\n\tpath = b.cfg\n").unwrap();
+ std::fs::write(
+ root.join(".git/b.cfg"),
+ "[include]\n\tpath = a.cfg\n[remote \"included\"]\n\tfetch = +refs/heads/*:refs/remotes/included/*\n[branch \"main\"]\n\tremote = included\n\tmerge = refs/heads/main\n",
+ )
+ .unwrap();
+
+ let fingerprint = git_status_fingerprint(&root).unwrap();
+
+ let upstream = fingerprint.upstream.unwrap();
+ assert_eq!(upstream.remote, "included");
+ assert_eq!(upstream.full_ref, "refs/remotes/included/main");
+
+ std::fs::remove_dir_all(root).unwrap();
+}
+
+#[test]
+fn git_status_fingerprint_reads_linked_worktree_config() {
+ let base = temp_test_dir("linked-worktree-config");
+ let common_dir = base.join("repo/.git");
+ let worktree = base.join("linked");
+ let git_dir = common_dir.join("worktrees/linked");
+ std::fs::create_dir_all(common_dir.join("refs/heads")).unwrap();
+ std::fs::create_dir_all(common_dir.join("refs/remotes/fork")).unwrap();
+ std::fs::create_dir_all(&git_dir).unwrap();
+ std::fs::create_dir_all(&worktree).unwrap();
+ std::fs::write(
+ worktree.join(".git"),
+ format!("gitdir: {}\n", git_dir.display()),
+ )
+ .unwrap();
+ std::fs::write(git_dir.join("commondir"), "../..\n").unwrap();
+ std::fs::write(git_dir.join("HEAD"), "ref: refs/heads/main\n").unwrap();
+ std::fs::write(
+ common_dir.join("refs/heads/main"),
+ "1111111111111111111111111111111111111111\n",
+ )
+ .unwrap();
+ std::fs::write(
+ common_dir.join("refs/remotes/fork/main"),
+ "2222222222222222222222222222222222222222\n",
+ )
+ .unwrap();
+ std::fs::write(
+ common_dir.join("config"),
+ "[extensions]\n\tworktreeConfig = TRUE\n[remote \"fork\"]\n\tfetch = +refs/heads/*:refs/remotes/fork/*\n[branch \"main\"]\n\tremote = origin\n\tmerge = refs/heads/main\n",
+ )
+ .unwrap();
+ std::fs::write(
+ git_dir.join("config.worktree"),
+ "[branch \"main\"]\n\tremote = fork\n",
+ )
+ .unwrap();
+
+ let fingerprint = git_status_fingerprint(&worktree).unwrap();
+
+ let upstream = fingerprint.upstream.unwrap();
+ assert_eq!(upstream.remote, "fork");
+ assert_eq!(upstream.full_ref, "refs/remotes/fork/main");
+
+ std::fs::remove_dir_all(base).unwrap();
+}
+
+#[test]
+fn git_status_fingerprint_ignores_inline_fetch_refspec_comment() {
+ let root = temp_test_dir("commented-fetch-refspec");
+ write_fake_tracked_repo(&root);
+ std::fs::remove_dir_all(root.join(".git/refs/remotes/origin")).unwrap();
+ std::fs::create_dir_all(root.join(".git/refs/remotes/upstream")).unwrap();
+ std::fs::write(
+ root.join(".git/refs/remotes/upstream/main"),
+ "2222222222222222222222222222222222222222\n",
+ )
+ .unwrap();
+ std::fs::write(
+ root.join(".git/config"),
+ "[remote \"origin\"]\n\tfetch = +refs/heads/*:refs/remotes/upstream/* # custom map\n[branch \"main\"]\n\tremote = origin\n\tmerge = refs/heads/main\n",
+ )
+ .unwrap();
+
+ let fingerprint = git_status_fingerprint(&root).unwrap();
+
+ let upstream = fingerprint.upstream.unwrap();
+ assert_eq!(upstream.full_ref, "refs/remotes/upstream/main");
+ assert_eq!(
+ upstream.oid.as_deref(),
+ Some("2222222222222222222222222222222222222222")
+ );
+
+ std::fs::remove_dir_all(root).unwrap();
+}
+
+#[test]
+fn git_status_fingerprint_clears_upstream_for_unmapped_refspec() {
+ let root = temp_test_dir("unmapped-fetch-refspec");
+ write_fake_tracked_repo(&root);
+ std::fs::write(
+ root.join(".git/config"),
+ "[remote \"origin\"]\n\tfetch = +refs/pull/*:refs/remotes/origin/pr/*\n[branch \"main\"]\n\tremote = origin\n\tmerge = refs/heads/main\n",
+ )
+ .unwrap();
+
+ let fingerprint = git_status_fingerprint(&root).unwrap();
+
+ assert_eq!(fingerprint.upstream, None);
+
+ std::fs::remove_dir_all(root).unwrap();
+}
+
+#[test]
+fn git_status_fingerprint_honors_negative_fetch_refspec() {
+ let root = temp_test_dir("negative-fetch-refspec");
+ write_fake_tracked_repo(&root);
+ std::fs::write(
+ root.join(".git/config"),
+ "[remote \"origin\"]\n\tfetch = +refs/heads/*:refs/remotes/origin/*\n\tfetch = ^refs/heads/main\n[branch \"main\"]\n\tremote = origin\n\tmerge = refs/heads/main\n",
+ )
+ .unwrap();
+
+ let fingerprint = git_status_fingerprint(&root).unwrap();
+
+ let upstream = fingerprint.upstream.unwrap();
+ assert_eq!(upstream.full_ref, "refs/remotes/origin/main");
+ assert_eq!(
+ upstream.oid.as_deref(),
+ Some("2222222222222222222222222222222222222222")
+ );
+
+ std::fs::remove_dir_all(root).unwrap();
+}
diff --git a/src/workspace/git.rs b/src/workspace/git/discovery.rs
index d06c8538..de4b090c 100644
--- a/src/workspace/git.rs
+++ b/src/workspace/git/discovery.rs
@@ -40,44 +40,23 @@ pub fn derive_label_from_cwd(cwd: &Path) -> String {
}
pub fn git_worktree_info(cwd: &Path) -> Option<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();
+ }
}
diff --git a/src/workspace/git/mod.rs b/src/workspace/git/mod.rs
new file mode 100644
index 00000000..76dc4eb4
--- /dev/null
+++ b/src/workspace/git/mod.rs
@@ -0,0 +1,15 @@
+mod config;
+#[cfg(test)]
+mod config_tests;
+mod discovery;
+mod status;
+#[cfg(test)]
+mod test_support;
+
+pub use self::{
+ discovery::{derive_label_from_cwd, git_branch, git_space_metadata, GitSpaceMetadata},
+ status::{git_status_cache_key, git_status_snapshot_for_cwd, GitStatusCacheEntry},
+};
+
+#[cfg(test)]
+pub(super) use self::status::git_ahead_behind;
diff --git a/src/workspace/git/status.rs b/src/workspace/git/status.rs
new file mode 100644
index 00000000..24e59e57
--- /dev/null
+++ b/src/workspace/git/status.rs
@@ -0,0 +1,386 @@
+use std::path::{Path, PathBuf};
+
+use crate::workspace::WorkspaceGitStatusSnapshot;
+
+use super::{
+ config::{read_branch_config, upstream_full_ref},
+ discovery::{
+ canonicalize_best_effort_path, git_branch, git_space_metadata, git_worktree_info,
+ read_ref_oid, GitWorktreeInfo,
+ },
+};
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct GitStatusCacheEntry {
+ pub fingerprint: GitStatusFingerprint,
+ pub snapshot: WorkspaceGitStatusSnapshot,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct GitStatusFingerprint {
+ pub git_dir: PathBuf,
+ pub git_common_dir: PathBuf,
+ pub head: GitHeadIdentity,
+ pub upstream: Option<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();
+ }
+}
diff --git a/src/workspace/git/test_support.rs b/src/workspace/git/test_support.rs
new file mode 100644
index 00000000..feba4b3a
--- /dev/null
+++ b/src/workspace/git/test_support.rs
@@ -0,0 +1,51 @@
+use std::path::{Path, PathBuf};
+use std::time::{SystemTime, UNIX_EPOCH};
+
+pub(super) fn temp_test_dir(name: &str) -> PathBuf {
+ let unique = format!(
+ "herdr-workspace-tests-{}-{}-{}",
+ name,
+ std::process::id(),
+ SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .unwrap()
+ .as_nanos()
+ );
+ let path = std::env::temp_dir().join(unique);
+ std::fs::create_dir_all(&path).unwrap();
+ path
+}
+
+pub(super) fn write_fake_tracked_repo(root: &Path) {
+ let head_oid = "1111111111111111111111111111111111111111";
+ let upstream_oid = "2222222222222222222222222222222222222222";
+ std::fs::create_dir_all(root.join(".git/refs/heads")).unwrap();
+ std::fs::create_dir_all(root.join(".git/refs/remotes/origin")).unwrap();
+ std::fs::write(root.join(".git/HEAD"), "ref: refs/heads/main\n").unwrap();
+ std::fs::write(root.join(".git/refs/heads/main"), format!("{head_oid}\n")).unwrap();
+ std::fs::write(
+ root.join(".git/refs/remotes/origin/main"),
+ format!("{upstream_oid}\n"),
+ )
+ .unwrap();
+ std::fs::write(
+ root.join(".git/config"),
+ "[branch \"main\"]\n\tremote = origin\n\tmerge = refs/heads/main\n",
+ )
+ .unwrap();
+}
+
+pub(super) fn run_git(cwd: &Path, args: &[&str]) {
+ let output = std::process::Command::new("git")
+ .arg("-C")
+ .arg(cwd)
+ .args(args)
+ .output()
+ .unwrap();
+ assert!(
+ output.status.success(),
+ "git {:?} failed: {}",
+ args,
+ String::from_utf8_lossy(&output.stderr)
+ );
+}
diff --git a/tests/api_ping.rs b/tests/api_ping.rs
index 22834615..b3fc4632 100644
--- a/tests/api_ping.rs
+++ b/tests/api_ping.rs
@@ -273,7 +273,7 @@ fn ping_over_socket_returns_version() {
assert_eq!(value["result"]["version"], env!("CARGO_PKG_VERSION"));
// Intentionally hardcoded so wire protocol bumps require updating this test.
// Changing this value means old clients/servers are no longer compatible.
- assert_eq!(value["result"]["protocol"], 11);
+ assert_eq!(value["result"]["protocol"], 12);
cleanup_spawned_herdr(child, base);
}
diff --git a/tests/cli_wrapper.rs b/tests/cli_wrapper.rs
index a2bbfdcc..0c81e7e4 100644
--- a/tests/cli_wrapper.rs
+++ b/tests/cli_wrapper.rs
@@ -1238,7 +1238,7 @@ fn status_commands_report_client_and_server_versions() {
"stdout: {full_stdout}"
);
assert!(
- full_stdout.contains(" protocol: 11"),
+ full_stdout.contains(" protocol: 12"),
"stdout: {full_stdout}"
);
assert!(full_stdout.contains("server:\n"), "stdout: {full_stdout}");
@@ -1271,7 +1271,7 @@ fn status_commands_report_client_and_server_versions() {
"stdout: {server_stdout}"
);
assert!(
- server_stdout.contains("protocol: 11"),
+ server_stdout.contains("protocol: 12"),
"stdout: {server_stdout}"
);
@@ -1283,7 +1283,7 @@ fn status_commands_report_client_and_server_versions() {
"stdout: {client_stdout}"
);
assert!(
- client_stdout.contains("protocol: 11"),
+ client_stdout.contains("protocol: 12"),
"stdout: {client_stdout}"
);
assert!(
@@ -1293,7 +1293,7 @@ fn status_commands_report_client_and_server_versions() {
let full_json = run_cli_json(&socket_path, &["status", "--json"]);
assert_eq!(full_json["client"]["version"], env!("CARGO_PKG_VERSION"));
- assert_eq!(full_json["client"]["protocol"], 11);
+ assert_eq!(full_json["client"]["protocol"], 12);
assert_eq!(full_json["server"]["status"], "running");
assert_eq!(full_json["server"]["running"], true);
assert_eq!(full_json["server"]["compatible"], true);
@@ -1307,12 +1307,12 @@ fn status_commands_report_client_and_server_versions() {
let server_json = run_cli_json(&socket_path, &["status", "server", "--json"]);
assert_eq!(server_json["status"], "running");
assert_eq!(server_json["version"], env!("CARGO_PKG_VERSION"));
- assert_eq!(server_json["protocol"], 11);
+ assert_eq!(server_json["protocol"], 12);
assert_eq!(server_json["compatible"], true);
let client_json = run_cli_json(&socket_path, &["status", "client", "--json"]);
assert_eq!(client_json["version"], env!("CARGO_PKG_VERSION"));
- assert_eq!(client_json["protocol"], 11);
+ assert_eq!(client_json["protocol"], 12);
assert!(client_json["binary"]
.as_str()
.is_some_and(|path| !path.is_empty()));
diff --git a/tests/client_mode.rs b/tests/client_mode.rs
index b7504aca..13dca5ef 100644
--- a/tests/client_mode.rs
+++ b/tests/client_mode.rs
@@ -274,8 +274,8 @@ fn client_connects_and_receives_frame() {
// Connect and handshake.
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
let (version, error) =
- client_handshake(&mut stream, 11, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 11, "server should report protocol version 11");
+ client_handshake(&mut stream, 12, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 12, "server should report protocol version 12");
assert!(
error.is_none(),
"handshake should not have error: {:?}",
@@ -342,8 +342,8 @@ fn client_sees_headless_startup_config_diagnostic() {
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
let (version, error) =
- client_handshake(&mut stream, 11, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 11);
+ client_handshake(&mut stream, 12, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 12);
assert!(error.is_none(), "{:?}", error);
stream
@@ -391,8 +391,8 @@ fn client_input_forwarded_to_pane() {
// Connect and handshake.
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
let (version, error) =
- client_handshake(&mut stream, 11, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 11);
+ client_handshake(&mut stream, 12, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 12);
assert!(error.is_none(), "{:?}", error);
// Send an Input message containing "echo hello\n".
@@ -445,8 +445,8 @@ fn client_resize_sends_message() {
// Connect and handshake.
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
let (version, error) =
- client_handshake(&mut stream, 11, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 11);
+ client_handshake(&mut stream, 12, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 12);
assert!(error.is_none(), "{:?}", error);
// Drain the initial frame(s).
@@ -504,8 +504,8 @@ fn server_shutdown_sends_message_to_client() {
// Connect and handshake.
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
let (version, error) =
- client_handshake(&mut stream, 11, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 11);
+ client_handshake(&mut stream, 12, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 12);
assert!(error.is_none(), "{:?}", error);
// Send SIGINT so the server takes the graceful shutdown path and
@@ -736,8 +736,8 @@ fn client_receives_frame_after_pane_output() {
// Connect and handshake.
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
let (version, error) =
- client_handshake(&mut stream, 11, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 11);
+ client_handshake(&mut stream, 12, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 12);
assert!(error.is_none(), "{:?}", error);
read_next_frame_payload(&mut stream, Duration::from_secs(10))
@@ -783,8 +783,8 @@ fn navigate_mode_keybind_dispatch_in_server() {
// Connect and handshake.
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
let (version, error) =
- client_handshake(&mut stream, 11, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 11);
+ client_handshake(&mut stream, 12, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 12);
assert!(error.is_none(), "{:?}", error);
// Drain initial frames.
@@ -901,8 +901,8 @@ fn graceful_shutdown_sends_server_shutdown_to_client() {
// Connect and handshake.
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
let (version, error) =
- client_handshake(&mut stream, 11, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 11);
+ client_handshake(&mut stream, 12, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 12);
assert!(error.is_none(), "{:?}", error);
// Drain initial frame(s).
@@ -1000,8 +1000,8 @@ fn client_receives_notify_on_agent_state_change() {
// Connect as a client and perform handshake.
let mut stream = UnixStream::connect(&client_socket).expect("should connect");
let (version, error) =
- client_handshake(&mut stream, 11, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 11);
+ client_handshake(&mut stream, 12, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 12);
assert!(error.is_none(), "{:?}", error);
// Drain initial frame(s).
diff --git a/tests/cross_area.rs b/tests/cross_area.rs
index 008358d0..38c22192 100644
--- a/tests/cross_area.rs
+++ b/tests/cross_area.rs
@@ -428,6 +428,7 @@ fn client_handshake(stream: &mut UnixStream, version: u32, cols: u16, rows: u16)
payload.extend_from_slice(&encode_varint_u32(16)); // cell_height_px
payload.extend_from_slice(&encode_varint_u32(0)); // RenderEncoding::SemanticFrame
payload.extend_from_slice(&encode_varint_u32(0)); // ClientKeybindings::Server
+ payload.extend_from_slice(&encode_varint_u32(0)); // ClientLaunchMode::App
stream
.write_all(&frame_message(&payload))
@@ -686,7 +687,7 @@ fn cross_area_detach_and_reattach_preserves_state() {
// Local attach (client A).
let mut client_a = UnixStream::connect(&client_socket).expect("client A should connect");
- client_handshake(&mut client_a, 11, 100, 30);
+ client_handshake(&mut client_a, 12, 100, 30);
assert!(wait_for_frame(&mut client_a, Duration::from_secs(2)));
// Use herdr: create a workspace and write output into its pane.
@@ -723,7 +724,7 @@ fn cross_area_detach_and_reattach_preserves_state() {
// Reattach from another terminal/session (client B).
let mut client_b = UnixStream::connect(&client_socket).expect("client B should connect");
- client_handshake(&mut client_b, 11, 80, 24);
+ client_handshake(&mut client_b, 12, 80, 24);
assert!(
wait_for_frame(&mut client_b, Duration::from_secs(5)),
"reattached client should receive frame"
@@ -779,7 +780,7 @@ fn cross_area_agent_process_survives_detach_and_reattach() {
wait_for_socket(&client_socket, Duration::from_secs(10));
let mut client_a = UnixStream::connect(&client_socket).expect("client A should connect");
- client_handshake(&mut client_a, 11, 100, 30);
+ client_handshake(&mut client_a, 12, 100, 30);
assert!(wait_for_frame(&mut client_a, Duration::from_secs(2)));
let created = workspace_create(&api_socket, "agent-persist");
@@ -832,7 +833,7 @@ fn cross_area_agent_process_survives_detach_and_reattach() {
// Reattach and ensure client-side state reflects the persisted working status.
let mut client_b = UnixStream::connect(&client_socket).expect("client B should connect");
- client_handshake(&mut client_b, 11, 80, 24);
+ client_handshake(&mut client_b, 12, 80, 24);
let saw_working_on_client =
wait_for_frame_matching(&mut client_b, Duration::from_secs(5), |frame| {
frame_contains_text(frame, "working")
@@ -877,7 +878,7 @@ fn cross_area_client_and_api_workspace_views_are_consistent() {
wait_for_socket(&client_socket, Duration::from_secs(10));
let mut client = UnixStream::connect(&client_socket).expect("client should connect");
- client_handshake(&mut client, 11, 100, 30);
+ client_handshake(&mut client, 12, 100, 30);
assert!(wait_for_frame(&mut client, Duration::from_secs(2)));
drain_server_messages(&mut client, Duration::from_millis(300));
@@ -940,9 +941,9 @@ fn cross_area_two_clients_shared_view_and_single_detach_stability() {
wait_for_socket(&client_socket, Duration::from_secs(10));
let mut client_a = UnixStream::connect(&client_socket).expect("client A should connect");
- client_handshake(&mut client_a, 11, 110, 30);
+ client_handshake(&mut client_a, 12, 110, 30);
let mut client_b = UnixStream::connect(&client_socket).expect("client B should connect");
- client_handshake(&mut client_b, 11, 100, 30);
+ client_handshake(&mut client_b, 12, 100, 30);
assert!(wait_for_frame(&mut client_a, Duration::from_secs(2)));
assert!(wait_for_frame(&mut client_b, Duration::from_secs(2)));
@@ -1111,7 +1112,7 @@ fn cross_area_server_kill_then_restart_and_reconnect() {
let mut reconnect_client =
UnixStream::connect(&client_socket).expect("new client should connect after restart");
- client_handshake(&mut reconnect_client, 11, 80, 24);
+ client_handshake(&mut reconnect_client, 12, 80, 24);
assert!(
wait_for_frame(&mut reconnect_client, Duration::from_secs(5)),
"new client should receive frame after restart"
diff --git a/tests/detach_reattach.rs b/tests/detach_reattach.rs
index 1e90d94b..18b0e6fc 100644
--- a/tests/detach_reattach.rs
+++ b/tests/detach_reattach.rs
@@ -276,8 +276,8 @@ fn navigate_q_detaches_client_and_server_persists() {
// Connect and handshake.
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
let (version, error) =
- client_handshake(&mut stream, 11, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 11);
+ client_handshake(&mut stream, 12, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 12);
assert!(error.is_none(), "{:?}", error);
// Drain initial frames.
@@ -338,8 +338,8 @@ fn explicit_detach_message_causes_clean_disconnect() {
// Connect and handshake.
let mut stream = UnixStream::connect(&client_socket).expect("should connect");
let (version, error) =
- client_handshake(&mut stream, 11, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 11);
+ client_handshake(&mut stream, 12, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 12);
assert!(error.is_none(), "{:?}", error);
// Drain initial frames.
@@ -397,8 +397,8 @@ fn reattach_after_detach_shows_current_state() {
// --- Client A ---
let mut stream_a = UnixStream::connect(&client_socket).expect("client A should connect");
let (version, error) =
- client_handshake(&mut stream_a, 11, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 11);
+ client_handshake(&mut stream_a, 12, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 12);
assert!(error.is_none(), "{:?}", error);
// Drain initial frames.
@@ -436,8 +436,8 @@ fn reattach_after_detach_shows_current_state() {
// --- Client B (reattach) ---
let mut stream_b = UnixStream::connect(&client_socket).expect("client B should connect");
let (version, error) =
- client_handshake(&mut stream_b, 11, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 11);
+ client_handshake(&mut stream_b, 12, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 12);
assert!(
error.is_none(),
"reattach handshake should succeed: {:?}",
@@ -516,8 +516,8 @@ fn processes_survive_during_and_after_detach() {
// Connect and handshake.
let mut stream = UnixStream::connect(&client_socket).expect("should connect");
let (version, error) =
- client_handshake(&mut stream, 11, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 11);
+ client_handshake(&mut stream, 12, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 12);
assert!(error.is_none(), "{:?}", error);
// Drain initial frames.
@@ -555,8 +555,8 @@ fn processes_survive_during_and_after_detach() {
// Reattach — verify we can connect and receive a frame.
let mut stream_b = UnixStream::connect(&client_socket).expect("should reattach");
let (version, error) =
- client_handshake(&mut stream_b, 11, 80, 24).expect("reattach handshake should succeed");
- assert_eq!(version, 11);
+ client_handshake(&mut stream_b, 12, 80, 24).expect("reattach handshake should succeed");
+ assert_eq!(version, 12);
assert!(error.is_none(), "{:?}", error);
// Verify the reattached client receives a frame.
@@ -604,8 +604,8 @@ fn server_persists_after_client_connection_drop() {
// Connect and handshake.
let mut stream = UnixStream::connect(&client_socket).expect("should connect");
let (version, error) =
- client_handshake(&mut stream, 11, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 11);
+ client_handshake(&mut stream, 12, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 12);
assert!(error.is_none(), "{:?}", error);
// Drain initial frames.
@@ -631,8 +631,8 @@ fn server_persists_after_client_connection_drop() {
// Reattach — verify we can connect and handshake again.
let mut stream_b = UnixStream::connect(&client_socket).expect("should reattach");
let (version, error) =
- client_handshake(&mut stream_b, 11, 80, 24).expect("reattach handshake should succeed");
- assert_eq!(version, 11);
+ client_handshake(&mut stream_b, 12, 80, 24).expect("reattach handshake should succeed");
+ assert_eq!(version, 12);
assert!(error.is_none(), "reattach should succeed: {:?}", error);
cleanup_spawned_herdr(spawned, base);
@@ -653,8 +653,8 @@ fn detached_output_preserves_last_attached_pty_size() {
let mut stream = UnixStream::connect(&client_socket).expect("client should connect");
let (version, error) =
- client_handshake(&mut stream, 11, 120, 40).expect("handshake should succeed");
- assert_eq!(version, 11);
+ client_handshake(&mut stream, 12, 120, 40).expect("handshake should succeed");
+ assert_eq!(version, 12);
assert!(error.is_none(), "{:?}", error);
drain_messages(&mut stream);
@@ -722,8 +722,8 @@ fn output_accumulated_while_detached_visible_on_reattach() {
// Connect and handshake client A.
let mut stream_a = UnixStream::connect(&client_socket).expect("client A should connect");
let (version, error) =
- client_handshake(&mut stream_a, 11, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 11);
+ client_handshake(&mut stream_a, 12, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 12);
assert!(error.is_none(), "{:?}", error);
// Detach client A immediately.
@@ -780,8 +780,8 @@ fn output_accumulated_while_detached_visible_on_reattach() {
// --- Client B (reattach) ---
let mut stream_b = UnixStream::connect(&client_socket).expect("client B should connect");
let (version, error) =
- client_handshake(&mut stream_b, 11, 80, 24).expect("reattach handshake should succeed");
- assert_eq!(version, 11);
+ client_handshake(&mut stream_b, 12, 80, 24).expect("reattach handshake should succeed");
+ assert_eq!(version, 12);
assert!(error.is_none(), "{:?}", error);
// Client B should receive a frame with the current state.
diff --git a/tests/multi_client.rs b/tests/multi_client.rs
index ca525e69..3080b677 100644
--- a/tests/multi_client.rs
+++ b/tests/multi_client.rs
@@ -500,6 +500,7 @@ fn client_handshake(
&encode_varint_u32(16), // cell_height_px
&encode_varint_u32(0), // RenderEncoding::SemanticFrame
&encode_varint_u32(0), // ClientKeybindings::Server
+ &encode_varint_u32(0), // ClientLaunchMode::App
],
);
stream
@@ -550,7 +551,7 @@ fn client_handshake(
fn connect_raw_client(client_socket: &Path, cols: u16, rows: u16) -> UnixStream {
let mut stream = UnixStream::connect(client_socket).expect("should connect to client socket");
- client_handshake(&mut stream, 11, cols, rows).expect("handshake should succeed");
+ client_handshake(&mut stream, 12, cols, rows).expect("handshake should succeed");
stream
}
diff --git a/tests/server_headless.rs b/tests/server_headless.rs
index 332398e0..08d4f5a5 100644
--- a/tests/server_headless.rs
+++ b/tests/server_headless.rs
@@ -180,6 +180,7 @@ fn client_handshake(
&encode_varint_u32(16), // cell_height_px
&encode_varint_u32(0), // RenderEncoding::SemanticFrame
&encode_varint_u32(0), // ClientKeybindings::Server
+ &encode_varint_u32(0), // ClientLaunchMode::App
],
);
let framed = frame_message(&hello_payload);
@@ -594,9 +595,9 @@ fn client_handshake_succeeds() {
// Send Hello with the current protocol version, 80 cols, 24 rows.
let (version, error) =
- client_handshake(&mut stream, 11, 80, 24).expect("handshake should succeed");
+ client_handshake(&mut stream, 12, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 11, "server should report protocol version 11");
+ assert_eq!(version, 12, "server should report protocol version 12");
assert!(
error.is_none(),
"handshake should not have an error: {:?}",
@@ -625,7 +626,7 @@ fn client_handshake_rejects_incompatible_version() {
let (version, error) = client_handshake(&mut stream, 0, 80, 24)
.expect("should read Welcome response even on rejection");
- assert_eq!(version, 11, "server should report its version 11");
+ assert_eq!(version, 12, "server should report its version 12");
assert!(
error.is_some(),
"version 0 should be rejected with an error"
@@ -650,10 +651,10 @@ fn client_handshake_clamps_small_terminal_size() {
// Send Hello with 0x0 terminal size — should be clamped.
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
- let (version, error) = client_handshake(&mut stream, 11, 0, 0)
+ let (version, error) = client_handshake(&mut stream, 12, 0, 0)
.expect("handshake with 0x0 should succeed (server clamps)");
- assert_eq!(version, 11);
+ assert_eq!(version, 12);
assert!(
error.is_none(),
"0x0 size should be accepted (clamped): {:?}",
@@ -713,9 +714,9 @@ fn no_hello_client_closed_within_five_seconds() {
// Verify the server is still healthy — a proper client can still connect.
let mut good_stream =
UnixStream::connect(&client_socket).expect("should connect after no-hello client");
- let (version, error) = client_handshake(&mut good_stream, 11, 80, 24)
+ let (version, error) = client_handshake(&mut good_stream, 12, 80, 24)
.expect("proper handshake should still work after no-hello client");
- assert_eq!(version, 11);
+ assert_eq!(version, 12);
assert!(error.is_none());
// API should still work.
diff --git a/tests/support/mod.rs b/tests/support/mod.rs
index cae140c9..0e357ff7 100644
--- a/tests/support/mod.rs
+++ b/tests/support/mod.rs
@@ -224,6 +224,7 @@ pub fn client_handshake(
&encode_varint_u32(16), // cell_height_px
&encode_varint_u32(0), // RenderEncoding::SemanticFrame
&encode_varint_u32(0), // ClientKeybindings::Server
+ &encode_varint_u32(0), // ClientLaunchMode::App
],
);
let framed = frame_message(&hello_payload);