mirror of
https://github.com/herdrdev/herdr.git
synced 2026-09-22 00:01:06 +00:00
+5
-1
@@ -2044,8 +2044,12 @@ impl AppState {
|
||||
// Intercepted in App::handle_internal_event before reaching this
|
||||
// dispatch; never touches AppState.
|
||||
AppEvent::ClipboardWrite { .. } => Vec::new(),
|
||||
AppEvent::GitStatusRefreshed { results } => {
|
||||
AppEvent::GitStatusRefreshed {
|
||||
results,
|
||||
cache_updates,
|
||||
} => {
|
||||
let _ = results;
|
||||
let _ = cache_updates;
|
||||
Vec::new()
|
||||
}
|
||||
AppEvent::WorktreeAddFinished(_) => Vec::new(),
|
||||
|
||||
+17
-2
@@ -21,14 +21,29 @@ enum RuntimeExitAction {
|
||||
impl App {
|
||||
pub(crate) fn handle_internal_event(&mut self, ev: AppEvent) {
|
||||
if let AppEvent::ClipboardWrite { content } = ev {
|
||||
#[cfg(not(test))]
|
||||
crate::selection::write_osc52_bytes(&content);
|
||||
#[cfg(test)]
|
||||
let _ = content;
|
||||
self.show_clipboard_feedback();
|
||||
return;
|
||||
}
|
||||
|
||||
if let AppEvent::GitStatusRefreshed { results } = ev {
|
||||
if let AppEvent::GitStatusRefreshed {
|
||||
results,
|
||||
cache_updates,
|
||||
} = ev
|
||||
{
|
||||
self.git_refresh_in_flight = false;
|
||||
self.last_git_remote_status_refresh = Instant::now();
|
||||
for (key, entry) in cache_updates {
|
||||
self.git_status_cache.insert(key, entry);
|
||||
}
|
||||
if self.git_refresh_due_after_in_flight {
|
||||
self.mark_git_status_refresh_due(Instant::now());
|
||||
self.git_refresh_due_after_in_flight = false;
|
||||
} else {
|
||||
self.last_git_remote_status_refresh = Instant::now();
|
||||
}
|
||||
if self
|
||||
.state
|
||||
.apply_workspace_git_statuses(&self.terminal_runtimes, results)
|
||||
|
||||
+11
-2
@@ -97,6 +97,8 @@ pub struct App {
|
||||
pub(crate) copy_feedback_deadline: Option<Instant>,
|
||||
pub(crate) last_git_remote_status_refresh: Instant,
|
||||
pub(crate) git_refresh_in_flight: bool,
|
||||
pub(crate) git_refresh_due_after_in_flight: bool,
|
||||
pub(crate) git_status_cache: HashMap<std::path::PathBuf, crate::workspace::GitStatusCacheEntry>,
|
||||
pub(crate) last_sidebar_divider_click: Option<Instant>,
|
||||
pub(crate) last_pane_click: Option<PaneClickState>,
|
||||
pub(crate) next_resize_poll: Instant,
|
||||
@@ -563,6 +565,8 @@ impl App {
|
||||
event_rx,
|
||||
last_git_remote_status_refresh: Instant::now() - GIT_REMOTE_STATUS_REFRESH_INTERVAL,
|
||||
git_refresh_in_flight: false,
|
||||
git_refresh_due_after_in_flight: false,
|
||||
git_status_cache: HashMap::new(),
|
||||
last_sidebar_divider_click: None,
|
||||
last_pane_click: None,
|
||||
next_resize_poll: Instant::now() + RESIZE_POLL_INTERVAL,
|
||||
@@ -1459,6 +1463,7 @@ mod tests {
|
||||
|
||||
app.handle_internal_event(AppEvent::GitStatusRefreshed {
|
||||
results: Vec::new(),
|
||||
cache_updates: Vec::new(),
|
||||
});
|
||||
|
||||
assert!(!app.git_refresh_in_flight);
|
||||
@@ -1481,6 +1486,7 @@ mod tests {
|
||||
ahead_behind: Some((1, 0)),
|
||||
space: None,
|
||||
}],
|
||||
cache_updates: Vec::new(),
|
||||
});
|
||||
|
||||
assert!(app.render_dirty.load(Ordering::Acquire));
|
||||
@@ -2915,7 +2921,7 @@ mod tests {
|
||||
app.next_auto_update_check = Some(now + Duration::from_secs(6));
|
||||
|
||||
assert_eq!(
|
||||
app.next_headless_loop_deadline(now, false),
|
||||
app.next_headless_loop_deadline_with_git_refresh(now, false, true),
|
||||
app.session_save_deadline
|
||||
);
|
||||
}
|
||||
@@ -2932,7 +2938,10 @@ mod tests {
|
||||
app.session_save_deadline = None;
|
||||
app.state.workspaces.clear();
|
||||
|
||||
assert_eq!(app.next_headless_loop_deadline(now, false), None);
|
||||
assert_eq!(
|
||||
app.next_headless_loop_deadline_with_git_refresh(now, false, true),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+256
-21
@@ -8,7 +8,35 @@ use super::{
|
||||
RESIZE_POLL_INTERVAL, SELECTION_AUTOSCROLL_INTERVAL,
|
||||
};
|
||||
use crate::events::AppEvent;
|
||||
use crate::workspace::{Workspace, WorkspaceGitStatus};
|
||||
use crate::workspace::{GitStatusCacheEntry, Workspace, WorkspaceGitStatus};
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct WorkspaceGitRefreshItem {
|
||||
pub(crate) workspace_id: String,
|
||||
pub(crate) resolved_identity_cwd: std::path::PathBuf,
|
||||
pub(crate) cache_key: std::path::PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct WorkspaceGitRefreshTarget {
|
||||
pub(crate) workspace_id: String,
|
||||
pub(crate) resolved_identity_cwd: std::path::PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct WorkspaceGitRefreshJob {
|
||||
pub(crate) cache_key: std::path::PathBuf,
|
||||
pub(crate) status_cwd: std::path::PathBuf,
|
||||
pub(crate) cached: Option<GitStatusCacheEntry>,
|
||||
pub(crate) targets: Vec<WorkspaceGitRefreshTarget>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct WorkspaceGitRefreshOutput {
|
||||
pub(crate) results: Vec<WorkspaceGitStatus>,
|
||||
pub(crate) cache_updates: Vec<(std::path::PathBuf, GitStatusCacheEntry)>,
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub(crate) fn shutdown_detached_terminal_runtimes(&mut self) {
|
||||
@@ -394,15 +422,7 @@ impl App {
|
||||
return;
|
||||
}
|
||||
|
||||
let workspaces: Vec<_> = self
|
||||
.state
|
||||
.workspaces
|
||||
.iter()
|
||||
.filter_map(|ws| {
|
||||
ws.resolved_identity_cwd_from(&self.state.terminals, &self.terminal_runtimes)
|
||||
.map(|cwd| (ws.id.clone(), cwd))
|
||||
})
|
||||
.collect();
|
||||
let workspaces = self.workspace_git_refresh_items();
|
||||
|
||||
if workspaces.is_empty() {
|
||||
self.last_git_remote_status_refresh = now;
|
||||
@@ -411,32 +431,43 @@ impl App {
|
||||
|
||||
self.git_refresh_in_flight = true;
|
||||
let event_tx = self.event_tx.clone();
|
||||
let cache = self.git_status_cache.clone();
|
||||
std::thread::spawn(move || {
|
||||
let results = workspaces
|
||||
.into_iter()
|
||||
.map(|(workspace_id, resolved_identity_cwd)| {
|
||||
Workspace::git_status_for_cwd(workspace_id, resolved_identity_cwd)
|
||||
})
|
||||
.collect::<Vec<WorkspaceGitStatus>>();
|
||||
let _ = event_tx.blocking_send(AppEvent::GitStatusRefreshed { results });
|
||||
let output = refresh_workspace_git_statuses_with_cache(workspaces, &cache);
|
||||
let _ = event_tx.blocking_send(AppEvent::GitStatusRefreshed {
|
||||
results: output.results,
|
||||
cache_updates: output.cache_updates,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn mark_git_status_refresh_due(&mut self, now: Instant) {
|
||||
if self.git_refresh_in_flight {
|
||||
self.git_refresh_due_after_in_flight = true;
|
||||
return;
|
||||
}
|
||||
self.last_git_remote_status_refresh = now
|
||||
.checked_sub(GIT_REMOTE_STATUS_REFRESH_INTERVAL)
|
||||
.unwrap_or(now);
|
||||
self.git_refresh_due_after_in_flight = false;
|
||||
}
|
||||
|
||||
pub(crate) fn git_refresh_deadline(&self) -> Option<Instant> {
|
||||
(!self.git_refresh_in_flight && !self.state.workspaces.is_empty())
|
||||
.then_some(self.last_git_remote_status_refresh + GIT_REMOTE_STATUS_REFRESH_INTERVAL)
|
||||
}
|
||||
|
||||
pub(crate) fn next_loop_deadline(&self, now: Instant, needs_render: bool) -> Option<Instant> {
|
||||
self.next_loop_deadline_with_resize_poll(now, needs_render, true)
|
||||
self.next_loop_deadline_with_resize_poll(now, needs_render, true, true)
|
||||
}
|
||||
|
||||
pub(crate) fn next_headless_loop_deadline(
|
||||
pub(crate) fn next_headless_loop_deadline_with_git_refresh(
|
||||
&self,
|
||||
now: Instant,
|
||||
needs_render: bool,
|
||||
include_git_refresh: bool,
|
||||
) -> Option<Instant> {
|
||||
self.next_loop_deadline_with_resize_poll(now, needs_render, false)
|
||||
self.next_loop_deadline_with_resize_poll(now, needs_render, false, include_git_refresh)
|
||||
}
|
||||
|
||||
fn next_loop_deadline_with_resize_poll(
|
||||
@@ -444,6 +475,7 @@ impl App {
|
||||
now: Instant,
|
||||
needs_render: bool,
|
||||
include_resize_poll: bool,
|
||||
include_git_refresh: bool,
|
||||
) -> Option<Instant> {
|
||||
let render_deadline = if needs_render {
|
||||
self.last_render_at
|
||||
@@ -459,7 +491,9 @@ impl App {
|
||||
self.toast_deadline,
|
||||
self.copy_feedback_deadline,
|
||||
self.next_animation_tick,
|
||||
self.git_refresh_deadline(),
|
||||
include_git_refresh
|
||||
.then(|| self.git_refresh_deadline())
|
||||
.flatten(),
|
||||
self.next_auto_update_check,
|
||||
self.agent_metadata_deadline,
|
||||
self.session_save_deadline,
|
||||
@@ -472,6 +506,24 @@ impl App {
|
||||
.min()
|
||||
}
|
||||
|
||||
fn workspace_git_refresh_items(&self) -> Vec<WorkspaceGitRefreshItem> {
|
||||
self.state
|
||||
.workspaces
|
||||
.iter()
|
||||
.filter_map(|ws| {
|
||||
let cwd =
|
||||
ws.resolved_identity_cwd_from(&self.state.terminals, &self.terminal_runtimes)?;
|
||||
let git_key = crate::workspace::git_status_cache_key(&cwd);
|
||||
let cache_key = git_key.unwrap_or_else(|| cwd.clone());
|
||||
Some(WorkspaceGitRefreshItem {
|
||||
workspace_id: ws.id.clone(),
|
||||
resolved_identity_cwd: cwd,
|
||||
cache_key,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn drain_internal_events(&mut self) -> bool {
|
||||
self.drain_internal_events_up_to(super::APP_EVENT_DRAIN_LIMIT)
|
||||
}
|
||||
@@ -497,11 +549,69 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn deduplicate_git_refresh_items(
|
||||
items: Vec<WorkspaceGitRefreshItem>,
|
||||
cache: &HashMap<std::path::PathBuf, GitStatusCacheEntry>,
|
||||
) -> Vec<WorkspaceGitRefreshJob> {
|
||||
let mut indexes = HashMap::<std::path::PathBuf, usize>::new();
|
||||
let mut jobs = Vec::<WorkspaceGitRefreshJob>::new();
|
||||
|
||||
for item in items {
|
||||
let target = WorkspaceGitRefreshTarget {
|
||||
workspace_id: item.workspace_id,
|
||||
resolved_identity_cwd: item.resolved_identity_cwd.clone(),
|
||||
};
|
||||
if let Some(&index) = indexes.get(&item.cache_key) {
|
||||
jobs[index].targets.push(target);
|
||||
continue;
|
||||
}
|
||||
|
||||
let status_cwd = item.cache_key.clone();
|
||||
let cached = cache.get(&item.cache_key).cloned();
|
||||
indexes.insert(item.cache_key, jobs.len());
|
||||
jobs.push(WorkspaceGitRefreshJob {
|
||||
cache_key: status_cwd.clone(),
|
||||
status_cwd,
|
||||
cached,
|
||||
targets: vec![target],
|
||||
});
|
||||
}
|
||||
|
||||
jobs
|
||||
}
|
||||
|
||||
pub(crate) fn refresh_workspace_git_statuses_with_cache(
|
||||
items: Vec<WorkspaceGitRefreshItem>,
|
||||
cache: &HashMap<std::path::PathBuf, GitStatusCacheEntry>,
|
||||
) -> WorkspaceGitRefreshOutput {
|
||||
let mut results = Vec::new();
|
||||
let mut cache_updates = Vec::new();
|
||||
|
||||
for job in deduplicate_git_refresh_items(items, cache) {
|
||||
let (snapshot, cache_entry) =
|
||||
Workspace::git_status_snapshot_for_cwd_with_cache(&job.status_cwd, job.cached.as_ref());
|
||||
if let Some(cache_entry) = cache_entry {
|
||||
cache_updates.push((job.cache_key.clone(), cache_entry));
|
||||
}
|
||||
results.extend(job.targets.into_iter().map(move |target| {
|
||||
snapshot
|
||||
.clone()
|
||||
.into_workspace_status(target.workspace_id, target.resolved_identity_cwd)
|
||||
}));
|
||||
}
|
||||
|
||||
WorkspaceGitRefreshOutput {
|
||||
results,
|
||||
cache_updates,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::app::state;
|
||||
use crate::workspace::Workspace;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn test_app_with_pane() -> (super::super::App, crate::layout::PaneId) {
|
||||
let mut app = super::super::App::new(
|
||||
@@ -525,6 +635,131 @@ mod tests {
|
||||
(app, pane_id)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_refresh_deduplicates_workspaces_with_same_cache_key() {
|
||||
let repo =
|
||||
std::env::temp_dir().join(format!("herdr-git-refresh-dedupe-{}", std::process::id()));
|
||||
let nested = repo.join("nested");
|
||||
let other = repo.join("other");
|
||||
std::fs::create_dir_all(&nested).expect("create nested dir");
|
||||
std::fs::create_dir_all(&other).expect("create other dir");
|
||||
std::process::Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(&repo)
|
||||
.arg("init")
|
||||
.output()
|
||||
.expect("run git init");
|
||||
|
||||
let output = refresh_workspace_git_statuses_with_cache(
|
||||
vec![
|
||||
WorkspaceGitRefreshItem {
|
||||
workspace_id: "one".into(),
|
||||
resolved_identity_cwd: nested.clone(),
|
||||
cache_key: repo.clone(),
|
||||
},
|
||||
WorkspaceGitRefreshItem {
|
||||
workspace_id: "two".into(),
|
||||
resolved_identity_cwd: other.clone(),
|
||||
cache_key: repo.clone(),
|
||||
},
|
||||
],
|
||||
&HashMap::new(),
|
||||
);
|
||||
|
||||
assert_eq!(output.cache_updates.len(), 1);
|
||||
assert_eq!(output.cache_updates[0].0, repo);
|
||||
assert_eq!(output.results.len(), 2);
|
||||
assert_eq!(output.results[0].workspace_id, "one");
|
||||
assert_eq!(
|
||||
output.results[0].resolved_identity_cwd,
|
||||
PathBuf::from(&nested)
|
||||
);
|
||||
assert_eq!(output.results[1].workspace_id, "two");
|
||||
assert_eq!(
|
||||
output.results[1].resolved_identity_cwd,
|
||||
PathBuf::from(&other)
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(repo);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_refresh_items_use_cwd_cache_key_for_non_git_cwd() {
|
||||
let mut app = super::super::App::new(
|
||||
&crate::config::Config::default(),
|
||||
true,
|
||||
None,
|
||||
tokio::sync::mpsc::unbounded_channel().1,
|
||||
crate::api::EventHub::default(),
|
||||
);
|
||||
let cwd = std::env::temp_dir().join(format!("herdr-non-git-cwd-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&cwd).expect("create temp cwd");
|
||||
let mut ws = Workspace::test_new("test");
|
||||
ws.identity_cwd = cwd.clone();
|
||||
ws.tabs.clear();
|
||||
app.state.workspaces.push(ws);
|
||||
|
||||
let items = app.workspace_git_refresh_items();
|
||||
|
||||
assert_eq!(items.len(), 1);
|
||||
assert_eq!(items[0].cache_key, cwd);
|
||||
let _ = std::fs::remove_dir_all(&cwd);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn headless_deadline_can_suppress_git_refresh_timer() {
|
||||
let mut app = super::super::App::new(
|
||||
&crate::config::Config::default(),
|
||||
true,
|
||||
None,
|
||||
tokio::sync::mpsc::unbounded_channel().1,
|
||||
crate::api::EventHub::default(),
|
||||
);
|
||||
app.state.workspaces.push(Workspace::test_new("test"));
|
||||
let now = Instant::now();
|
||||
app.last_git_remote_status_refresh = now - super::super::GIT_REMOTE_STATUS_REFRESH_INTERVAL;
|
||||
|
||||
assert_eq!(
|
||||
app.next_headless_loop_deadline_with_git_refresh(now, false, false),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
app.next_headless_loop_deadline_with_git_refresh(now, false, true),
|
||||
Some(now)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_refresh_due_request_survives_in_flight_refresh() {
|
||||
let mut app = super::super::App::new(
|
||||
&crate::config::Config::default(),
|
||||
true,
|
||||
None,
|
||||
tokio::sync::mpsc::unbounded_channel().1,
|
||||
crate::api::EventHub::default(),
|
||||
);
|
||||
let now = Instant::now();
|
||||
app.git_refresh_in_flight = true;
|
||||
|
||||
app.mark_git_status_refresh_due(now);
|
||||
assert!(app.git_refresh_due_after_in_flight);
|
||||
|
||||
app.handle_internal_event(crate::events::AppEvent::GitStatusRefreshed {
|
||||
results: Vec::new(),
|
||||
cache_updates: Vec::new(),
|
||||
});
|
||||
|
||||
assert!(!app.git_refresh_in_flight);
|
||||
assert!(!app.git_refresh_due_after_in_flight);
|
||||
assert_eq!(app.git_refresh_deadline(), None);
|
||||
|
||||
app.state.workspaces.push(Workspace::test_new("test"));
|
||||
let deadline = app
|
||||
.git_refresh_deadline()
|
||||
.expect("refresh should be due once a workspace exists");
|
||||
assert!(deadline <= Instant::now());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tick_selection_autoscroll_stops_when_metrics_unavailable() {
|
||||
// Without a runtime, pane_scroll_metrics returns None.
|
||||
|
||||
+10
-3
@@ -31,9 +31,9 @@ use tracing::{debug, info, warn};
|
||||
|
||||
use crate::protocol::render_ansi;
|
||||
use crate::protocol::{
|
||||
self, AttachScrollDirection, AttachScrollSource, ClientKeybindings, ClientMessage, NotifyKind,
|
||||
RenderEncoding, ServerMessage, MAX_CLIPBOARD_IMAGE_PAYLOAD, MAX_FRAME_SIZE,
|
||||
MAX_GRAPHICS_FRAME_SIZE, PROTOCOL_VERSION,
|
||||
self, AttachScrollDirection, AttachScrollSource, ClientKeybindings, ClientLaunchMode,
|
||||
ClientMessage, NotifyKind, RenderEncoding, ServerMessage, MAX_CLIPBOARD_IMAGE_PAYLOAD,
|
||||
MAX_FRAME_SIZE, MAX_GRAPHICS_FRAME_SIZE, PROTOCOL_VERSION,
|
||||
};
|
||||
use crate::server::socket_paths::client_socket_path;
|
||||
|
||||
@@ -420,6 +420,7 @@ fn do_handshake(
|
||||
cell_width_px: u32,
|
||||
cell_height_px: u32,
|
||||
requested_encoding: RenderEncoding,
|
||||
direct_attach_requested: bool,
|
||||
) -> Result<RenderEncoding, ClientError> {
|
||||
stream
|
||||
.set_nonblocking(false)
|
||||
@@ -434,6 +435,11 @@ fn do_handshake(
|
||||
cell_height_px,
|
||||
requested_encoding,
|
||||
keybindings: requested_keybindings(),
|
||||
launch_mode: if direct_attach_requested {
|
||||
ClientLaunchMode::TerminalAttach
|
||||
} else {
|
||||
ClientLaunchMode::App
|
||||
},
|
||||
};
|
||||
protocol::write_message(stream, &hello)
|
||||
.map_err(|e| ClientError::ConnectionFailed(io::Error::other(e.to_string())))?;
|
||||
@@ -549,6 +555,7 @@ fn run_client_with_mode(
|
||||
cell_width_px,
|
||||
cell_height_px,
|
||||
requested_encoding,
|
||||
direct_attach_requested,
|
||||
) {
|
||||
Ok(encoding) => encoding,
|
||||
Err(err) => {
|
||||
|
||||
+5
-2
@@ -7,7 +7,7 @@ use std::time::Instant;
|
||||
|
||||
use crate::detect::{Agent, AgentState};
|
||||
use crate::layout::PaneId;
|
||||
use crate::workspace::WorkspaceGitStatus;
|
||||
use crate::workspace::{GitStatusCacheEntry, WorkspaceGitStatus};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct WorktreeAddResult {
|
||||
@@ -89,7 +89,10 @@ pub enum AppEvent {
|
||||
/// re-emits it through herdr's own clipboard writer.
|
||||
ClipboardWrite { content: Vec<u8> },
|
||||
/// Background git status refresh completed for workspaces.
|
||||
GitStatusRefreshed { results: Vec<WorkspaceGitStatus> },
|
||||
GitStatusRefreshed {
|
||||
results: Vec<WorkspaceGitStatus>,
|
||||
cache_updates: Vec<(std::path::PathBuf, GitStatusCacheEntry)>,
|
||||
},
|
||||
/// Background `git worktree add` completed.
|
||||
WorktreeAddFinished(WorktreeAddResult),
|
||||
/// Background `git worktree remove` completed.
|
||||
|
||||
+17
-1
@@ -13,7 +13,7 @@ use serde::{Deserialize, Serialize};
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Current protocol version. Bumped when wire format changes incompatibly.
|
||||
pub const PROTOCOL_VERSION: u32 = 11;
|
||||
pub const PROTOCOL_VERSION: u32 = 12;
|
||||
|
||||
/// Maximum allowed frame payload size (2 MB). Frames larger than this are
|
||||
/// rejected to prevent denial-of-service via oversized length prefixes.
|
||||
@@ -52,6 +52,15 @@ pub enum ClientKeybindings {
|
||||
Local { keys_toml: String },
|
||||
}
|
||||
|
||||
/// Client behavior requested at connection time.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ClientLaunchMode {
|
||||
/// Full app client.
|
||||
App,
|
||||
/// Direct terminal attach client.
|
||||
TerminalAttach,
|
||||
}
|
||||
|
||||
/// Messages sent from the client to the server over the client protocol socket.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ClientMessage {
|
||||
@@ -71,6 +80,8 @@ pub enum ClientMessage {
|
||||
requested_encoding: RenderEncoding,
|
||||
/// Keybinding profile requested by the client.
|
||||
keybindings: ClientKeybindings,
|
||||
/// Whether this connection will render the full app or attach directly to a pane terminal.
|
||||
launch_mode: ClientLaunchMode,
|
||||
},
|
||||
|
||||
/// Raw input bytes read from the client's stdin.
|
||||
@@ -651,6 +662,7 @@ mod tests {
|
||||
cell_height_px: 16,
|
||||
requested_encoding: RenderEncoding::SemanticFrame,
|
||||
keybindings: ClientKeybindings::Server,
|
||||
launch_mode: ClientLaunchMode::App,
|
||||
};
|
||||
let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap();
|
||||
let (decoded, _): (ClientMessage, _) =
|
||||
@@ -952,6 +964,7 @@ mod tests {
|
||||
cell_height_px: 16,
|
||||
requested_encoding: RenderEncoding::SemanticFrame,
|
||||
keybindings: ClientKeybindings::Server,
|
||||
launch_mode: ClientLaunchMode::App,
|
||||
};
|
||||
let mut buf = Vec::new();
|
||||
write_message(&mut buf, &msg).unwrap();
|
||||
@@ -1025,6 +1038,7 @@ mod tests {
|
||||
cell_height_px: 16,
|
||||
requested_encoding: RenderEncoding::SemanticFrame,
|
||||
keybindings: ClientKeybindings::Server,
|
||||
launch_mode: ClientLaunchMode::App,
|
||||
},
|
||||
1 => ClientMessage::Input {
|
||||
data: vec![(i % 256) as u8; (i as usize % 50) + 1],
|
||||
@@ -1460,6 +1474,7 @@ mod tests {
|
||||
cell_height_px: 16,
|
||||
requested_encoding: RenderEncoding::SemanticFrame,
|
||||
keybindings: ClientKeybindings::Server,
|
||||
launch_mode: ClientLaunchMode::App,
|
||||
};
|
||||
let mut buf = Vec::new();
|
||||
write_message(&mut buf, &msg).unwrap();
|
||||
@@ -1494,6 +1509,7 @@ mod tests {
|
||||
cell_height_px: 16,
|
||||
requested_encoding: RenderEncoding::SemanticFrame,
|
||||
keybindings: ClientKeybindings::Server,
|
||||
launch_mode: ClientLaunchMode::App,
|
||||
},
|
||||
ClientMessage::Input {
|
||||
data: b"hello world".to_vec(),
|
||||
|
||||
+137
-60
@@ -14,8 +14,8 @@ use tokio::sync::mpsc;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use crate::protocol::{
|
||||
self, AttachScrollDirection, AttachScrollSource, ClientKeybindings, ClientMessage,
|
||||
RenderEncoding, ServerMessage, MAX_CLIPBOARD_IMAGE_PAYLOAD, MAX_FRAME_SIZE,
|
||||
self, AttachScrollDirection, AttachScrollSource, ClientKeybindings, ClientLaunchMode,
|
||||
ClientMessage, RenderEncoding, ServerMessage, MAX_CLIPBOARD_IMAGE_PAYLOAD, MAX_FRAME_SIZE,
|
||||
MAX_GRAPHICS_FRAME_SIZE, PROTOCOL_VERSION,
|
||||
};
|
||||
|
||||
@@ -57,6 +57,7 @@ pub(crate) enum ServerEvent {
|
||||
cell_height_px: u32,
|
||||
render_encoding: RenderEncoding,
|
||||
keybindings: Option<Box<crate::config::LiveKeybindConfig>>,
|
||||
direct_attach_requested: bool,
|
||||
writer: ClientWriter,
|
||||
},
|
||||
/// A client sent an input message.
|
||||
@@ -159,68 +160,77 @@ pub(crate) fn handle_client_handshake(
|
||||
}
|
||||
};
|
||||
|
||||
let (client_cols, client_rows, cell_width_px, cell_height_px, render_encoding, keybindings) =
|
||||
match hello {
|
||||
ClientMessage::Hello {
|
||||
version,
|
||||
cols,
|
||||
rows,
|
||||
let (
|
||||
client_cols,
|
||||
client_rows,
|
||||
cell_width_px,
|
||||
cell_height_px,
|
||||
render_encoding,
|
||||
keybindings,
|
||||
direct_attach_requested,
|
||||
) = match hello {
|
||||
ClientMessage::Hello {
|
||||
version,
|
||||
cols,
|
||||
rows,
|
||||
cell_width_px,
|
||||
cell_height_px,
|
||||
requested_encoding,
|
||||
keybindings,
|
||||
launch_mode,
|
||||
} => {
|
||||
// Version check.
|
||||
match protocol::check_client_version(version) {
|
||||
protocol::VersionCheck::Compatible => {}
|
||||
protocol::VersionCheck::Incompatible(reason) => {
|
||||
// Send rejection Welcome.
|
||||
let welcome = ServerMessage::Welcome {
|
||||
version: PROTOCOL_VERSION,
|
||||
encoding: RenderEncoding::SemanticFrame,
|
||||
error: Some(reason),
|
||||
};
|
||||
let _ = protocol::write_message(&mut stream, &welcome);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
let keybindings = match parse_client_keybindings(keybindings) {
|
||||
Ok(keybindings) => keybindings,
|
||||
Err(error) => {
|
||||
let welcome = ServerMessage::Welcome {
|
||||
version: PROTOCOL_VERSION,
|
||||
encoding: RenderEncoding::SemanticFrame,
|
||||
error: Some(error),
|
||||
};
|
||||
let _ = protocol::write_message(&mut stream, &welcome);
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
// Clamp size.
|
||||
let (clamped_cols, clamped_rows) = clamp_terminal_size(cols, rows);
|
||||
(
|
||||
clamped_cols,
|
||||
clamped_rows,
|
||||
cell_width_px,
|
||||
cell_height_px,
|
||||
requested_encoding,
|
||||
keybindings,
|
||||
} => {
|
||||
// Version check.
|
||||
match protocol::check_client_version(version) {
|
||||
protocol::VersionCheck::Compatible => {}
|
||||
protocol::VersionCheck::Incompatible(reason) => {
|
||||
// Send rejection Welcome.
|
||||
let welcome = ServerMessage::Welcome {
|
||||
version: PROTOCOL_VERSION,
|
||||
encoding: RenderEncoding::SemanticFrame,
|
||||
error: Some(reason),
|
||||
};
|
||||
let _ = protocol::write_message(&mut stream, &welcome);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
let keybindings = match parse_client_keybindings(keybindings) {
|
||||
Ok(keybindings) => keybindings,
|
||||
Err(error) => {
|
||||
let welcome = ServerMessage::Welcome {
|
||||
version: PROTOCOL_VERSION,
|
||||
encoding: RenderEncoding::SemanticFrame,
|
||||
error: Some(error),
|
||||
};
|
||||
let _ = protocol::write_message(&mut stream, &welcome);
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
// Clamp size.
|
||||
let (clamped_cols, clamped_rows) = clamp_terminal_size(cols, rows);
|
||||
(
|
||||
clamped_cols,
|
||||
clamped_rows,
|
||||
cell_width_px,
|
||||
cell_height_px,
|
||||
requested_encoding,
|
||||
keybindings,
|
||||
)
|
||||
}
|
||||
_ => {
|
||||
// First message must be Hello.
|
||||
debug!(client_id, "first message was not Hello, closing");
|
||||
let welcome = ServerMessage::Welcome {
|
||||
version: PROTOCOL_VERSION,
|
||||
encoding: RenderEncoding::SemanticFrame,
|
||||
error: Some("expected Hello as first message".to_owned()),
|
||||
};
|
||||
let _ = protocol::write_message(&mut stream, &welcome);
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
launch_mode == ClientLaunchMode::TerminalAttach,
|
||||
)
|
||||
}
|
||||
_ => {
|
||||
// First message must be Hello.
|
||||
debug!(client_id, "first message was not Hello, closing");
|
||||
let welcome = ServerMessage::Welcome {
|
||||
version: PROTOCOL_VERSION,
|
||||
encoding: RenderEncoding::SemanticFrame,
|
||||
error: Some("expected Hello as first message".to_owned()),
|
||||
};
|
||||
let _ = protocol::write_message(&mut stream, &welcome);
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
// Send Welcome.
|
||||
let welcome = ServerMessage::Welcome {
|
||||
@@ -250,6 +260,7 @@ pub(crate) fn handle_client_handshake(
|
||||
cell_height_px,
|
||||
render_encoding,
|
||||
keybindings,
|
||||
direct_attach_requested,
|
||||
writer,
|
||||
});
|
||||
|
||||
@@ -576,6 +587,7 @@ new_tab = "ctrl+notakey"
|
||||
cell_height_px: 16,
|
||||
requested_encoding: RenderEncoding::TerminalAnsi,
|
||||
keybindings: ClientKeybindings::Server,
|
||||
launch_mode: ClientLaunchMode::App,
|
||||
},
|
||||
)
|
||||
.expect("write hello");
|
||||
@@ -607,6 +619,7 @@ new_tab = "ctrl+notakey"
|
||||
cell_height_px,
|
||||
render_encoding,
|
||||
keybindings,
|
||||
direct_attach_requested,
|
||||
writer,
|
||||
} => {
|
||||
assert_eq!(client_id, 42);
|
||||
@@ -614,6 +627,70 @@ new_tab = "ctrl+notakey"
|
||||
assert_eq!((cell_width_px, cell_height_px), (8, 16));
|
||||
assert_eq!(render_encoding, RenderEncoding::TerminalAnsi);
|
||||
assert!(keybindings.is_none());
|
||||
assert!(!direct_attach_requested);
|
||||
drop(writer);
|
||||
}
|
||||
other => panic!("expected ClientConnected, got {other:?}"),
|
||||
}
|
||||
|
||||
drop(client_stream);
|
||||
should_quit.store(true, Ordering::Release);
|
||||
handle
|
||||
.join()
|
||||
.expect("handshake thread join")
|
||||
.expect("handshake thread result");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handshake_marks_terminal_attach_launch_mode() {
|
||||
let (mut client_stream, server_stream) = UnixStream::pair().expect("socket pair");
|
||||
let (server_event_tx, mut server_event_rx) = mpsc::channel(4);
|
||||
let should_quit = Arc::new(AtomicBool::new(false));
|
||||
let handshake_quit = should_quit.clone();
|
||||
let handle = std::thread::spawn(move || {
|
||||
handle_client_handshake(server_stream, 42, &server_event_tx, &handshake_quit)
|
||||
});
|
||||
|
||||
protocol::write_message(
|
||||
&mut client_stream,
|
||||
&ClientMessage::Hello {
|
||||
version: PROTOCOL_VERSION,
|
||||
cols: 100,
|
||||
rows: 30,
|
||||
cell_width_px: 8,
|
||||
cell_height_px: 16,
|
||||
requested_encoding: RenderEncoding::TerminalAnsi,
|
||||
keybindings: ClientKeybindings::Server,
|
||||
launch_mode: ClientLaunchMode::TerminalAttach,
|
||||
},
|
||||
)
|
||||
.expect("write hello");
|
||||
|
||||
let welcome: ServerMessage =
|
||||
protocol::read_message(&mut client_stream, MAX_FRAME_SIZE).expect("read welcome");
|
||||
match welcome {
|
||||
ServerMessage::Welcome {
|
||||
version,
|
||||
encoding,
|
||||
error,
|
||||
} => {
|
||||
assert_eq!(version, PROTOCOL_VERSION);
|
||||
assert_eq!(encoding, RenderEncoding::TerminalAnsi);
|
||||
assert_eq!(error, None);
|
||||
}
|
||||
other => panic!("expected Welcome, got {other:?}"),
|
||||
}
|
||||
|
||||
match server_event_rx
|
||||
.blocking_recv()
|
||||
.expect("client connected event")
|
||||
{
|
||||
ServerEvent::ClientConnected {
|
||||
direct_attach_requested,
|
||||
writer,
|
||||
..
|
||||
} => {
|
||||
assert!(direct_attach_requested);
|
||||
drop(writer);
|
||||
}
|
||||
other => panic!("expected ClientConnected, got {other:?}"),
|
||||
|
||||
+15
-2
@@ -23,6 +23,8 @@ pub(crate) type RenderTarget = (
|
||||
pub(crate) struct ClientConnection {
|
||||
/// Whether this connection is the full app client or a direct terminal attach.
|
||||
pub(crate) mode: ClientConnectionMode,
|
||||
/// True after the handshake for clients that will switch into direct terminal attach mode.
|
||||
pub(crate) pending_terminal_attach: bool,
|
||||
/// Client-local app keybindings. None means use the server's keybindings.
|
||||
pub(crate) keybindings: Option<Box<crate::config::LiveKeybindConfig>>,
|
||||
/// The client's terminal size after clamping.
|
||||
@@ -73,6 +75,7 @@ impl ClientConnection {
|
||||
outer_terminal_focus,
|
||||
last_activity,
|
||||
render_encoding,
|
||||
false,
|
||||
writer,
|
||||
)
|
||||
}
|
||||
@@ -86,10 +89,12 @@ impl ClientConnection {
|
||||
outer_terminal_focus: Option<bool>,
|
||||
last_activity: u64,
|
||||
render_encoding: RenderEncoding,
|
||||
pending_terminal_attach: bool,
|
||||
writer: Option<ClientWriter>,
|
||||
) -> Self {
|
||||
Self {
|
||||
mode,
|
||||
pending_terminal_attach,
|
||||
keybindings,
|
||||
terminal_size,
|
||||
cell_size,
|
||||
@@ -112,6 +117,10 @@ impl ClientConnection {
|
||||
self.graphics_surface_reset_pending = true;
|
||||
}
|
||||
|
||||
pub(crate) fn is_full_app_client(&self) -> bool {
|
||||
matches!(self.mode, ClientConnectionMode::App) && !self.pending_terminal_attach
|
||||
}
|
||||
|
||||
pub(crate) fn request_semantic_redraw_after_input(&mut self) {
|
||||
self.render_state.reset_semantic_input_baseline();
|
||||
}
|
||||
@@ -168,7 +177,7 @@ pub(crate) fn events_include_interaction(events: &[crate::raw_input::RawInputEve
|
||||
pub(crate) fn latest_app_client(clients: &HashMap<u64, ClientConnection>) -> Option<u64> {
|
||||
clients
|
||||
.iter()
|
||||
.filter(|(_, client)| matches!(client.mode, ClientConnectionMode::App))
|
||||
.filter(|(_, client)| client.is_full_app_client())
|
||||
.max_by_key(|(_, client)| client.last_activity)
|
||||
.map(|(&client_id, _)| client_id)
|
||||
}
|
||||
@@ -194,7 +203,11 @@ pub(crate) fn render_targets(
|
||||
) -> Vec<RenderTarget> {
|
||||
let mut targets: Vec<RenderTarget> = clients
|
||||
.iter()
|
||||
.filter(|(_, client)| client.writer.is_some())
|
||||
.filter(|(_, client)| {
|
||||
client.writer.is_some()
|
||||
&& (client.is_full_app_client()
|
||||
|| matches!(client.mode, ClientConnectionMode::TerminalAttach { .. }))
|
||||
})
|
||||
.map(|(&client_id, client)| {
|
||||
(
|
||||
client_id,
|
||||
|
||||
+149
-4
@@ -423,7 +423,11 @@ impl HeadlessServer {
|
||||
// 8. Wait for next event.
|
||||
let next_deadline = self
|
||||
.app
|
||||
.next_headless_loop_deadline(now, needs_render)
|
||||
.next_headless_loop_deadline_with_git_refresh(
|
||||
now,
|
||||
needs_render,
|
||||
self.has_app_client(),
|
||||
)
|
||||
.map(|deadline| deadline.min(now + CLIENT_ACCEPT_POLL_INTERVAL))
|
||||
.or(Some(now + CLIENT_ACCEPT_POLL_INTERVAL));
|
||||
let event = {
|
||||
@@ -901,6 +905,17 @@ impl HeadlessServer {
|
||||
changed
|
||||
}
|
||||
|
||||
fn app_client_count(&self) -> usize {
|
||||
self.clients
|
||||
.values()
|
||||
.filter(|client| client.is_full_app_client() && client.writer.is_some())
|
||||
.count()
|
||||
}
|
||||
|
||||
fn has_app_client(&self) -> bool {
|
||||
self.app_client_count() > 0
|
||||
}
|
||||
|
||||
fn remove_client(&mut self, client_id: u64) -> bool {
|
||||
let was_foreground = self.foreground_client_id == Some(client_id);
|
||||
self.send_client_graphics_cleanup(client_id);
|
||||
@@ -1619,6 +1634,7 @@ impl HeadlessServer {
|
||||
client.mode = ClientConnectionMode::TerminalAttach {
|
||||
terminal_id: terminal_id.clone(),
|
||||
};
|
||||
client.pending_terminal_attach = false;
|
||||
client.render_state.reset_baseline();
|
||||
client.last_activity = stamp;
|
||||
let was_foreground = self.foreground_client_id == Some(client_id);
|
||||
@@ -1655,6 +1671,7 @@ impl HeadlessServer {
|
||||
keybindings,
|
||||
writer,
|
||||
render_encoding,
|
||||
direct_attach_requested,
|
||||
} => {
|
||||
if self.handoff_in_progress {
|
||||
if let Ok(message) =
|
||||
@@ -1669,6 +1686,7 @@ impl HeadlessServer {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
let first_app_client = !direct_attach_requested && self.app_client_count() == 0;
|
||||
info!(
|
||||
client_id,
|
||||
cols,
|
||||
@@ -1693,10 +1711,16 @@ impl HeadlessServer {
|
||||
None,
|
||||
last_activity,
|
||||
render_encoding,
|
||||
direct_attach_requested,
|
||||
Some(writer),
|
||||
),
|
||||
);
|
||||
self.foreground_client_id = Some(client_id);
|
||||
if !direct_attach_requested {
|
||||
self.foreground_client_id = Some(client_id);
|
||||
}
|
||||
if first_app_client {
|
||||
self.app.mark_git_status_refresh_due(Instant::now());
|
||||
}
|
||||
self.sync_foreground_client_state();
|
||||
self.resize_shared_runtime_to_effective_size();
|
||||
self.nudge_handoff_panes_on_first_client_attach();
|
||||
@@ -2190,7 +2214,7 @@ impl HeadlessServer {
|
||||
|
||||
let mut broken_clients: Vec<u64> = Vec::new();
|
||||
for (&client_id, client) in &mut self.clients {
|
||||
if !matches!(client.mode, ClientConnectionMode::App) {
|
||||
if !client.is_full_app_client() {
|
||||
continue;
|
||||
}
|
||||
if client.host_mouse_capture_active == Some(enabled) {
|
||||
@@ -2484,7 +2508,9 @@ impl HeadlessServer {
|
||||
|
||||
changed |= self.app.clear_due_selection_highlight(now);
|
||||
|
||||
self.app.start_git_status_refresh_if_due(now);
|
||||
if self.has_app_client() {
|
||||
self.app.start_git_status_refresh_if_due(now);
|
||||
}
|
||||
|
||||
if self
|
||||
.app
|
||||
@@ -3011,6 +3037,7 @@ new_tab = "prefix+t"
|
||||
cell_height_px: 0,
|
||||
render_encoding: RenderEncoding::SemanticFrame,
|
||||
keybindings: Some(Box::new(local_keybindings)),
|
||||
direct_attach_requested: false,
|
||||
writer: writer_a,
|
||||
}));
|
||||
assert_eq!(
|
||||
@@ -3034,6 +3061,7 @@ new_tab = "prefix+t"
|
||||
cell_height_px: 0,
|
||||
render_encoding: RenderEncoding::SemanticFrame,
|
||||
keybindings: None,
|
||||
direct_attach_requested: false,
|
||||
writer: writer_b,
|
||||
}));
|
||||
assert_eq!(
|
||||
@@ -3073,6 +3101,7 @@ new_tab = "prefix+t"
|
||||
cell_height_px: 0,
|
||||
render_encoding: RenderEncoding::SemanticFrame,
|
||||
keybindings: Some(Box::new(local_keybindings)),
|
||||
direct_attach_requested: false,
|
||||
writer: writer_a,
|
||||
}));
|
||||
assert_eq!(server.app.state.config_diagnostic, without_keybindings);
|
||||
@@ -3085,6 +3114,7 @@ new_tab = "prefix+t"
|
||||
cell_height_px: 0,
|
||||
render_encoding: RenderEncoding::SemanticFrame,
|
||||
keybindings: None,
|
||||
direct_attach_requested: false,
|
||||
writer: writer_b,
|
||||
}));
|
||||
assert_eq!(
|
||||
@@ -3127,6 +3157,7 @@ next_tab = ""
|
||||
cell_height_px: 0,
|
||||
render_encoding: RenderEncoding::SemanticFrame,
|
||||
keybindings: Some(Box::new(local_keybindings)),
|
||||
direct_attach_requested: false,
|
||||
writer,
|
||||
}));
|
||||
server.app.state.mode = crate::app::Mode::Settings;
|
||||
@@ -3200,6 +3231,7 @@ next_tab = ""
|
||||
cell_height_px: 0,
|
||||
render_encoding: RenderEncoding::SemanticFrame,
|
||||
keybindings: Some(Box::new(local_config.live_keybinds().unwrap())),
|
||||
direct_attach_requested: false,
|
||||
writer: writer_a,
|
||||
}));
|
||||
server.app.state.mode = crate::app::Mode::Settings;
|
||||
@@ -3219,6 +3251,7 @@ next_tab = ""
|
||||
cell_height_px: 0,
|
||||
render_encoding: RenderEncoding::SemanticFrame,
|
||||
keybindings: None,
|
||||
direct_attach_requested: false,
|
||||
writer: writer_b,
|
||||
}));
|
||||
assert_eq!(
|
||||
@@ -3251,6 +3284,7 @@ next_tab = ""
|
||||
cell_height_px: 0,
|
||||
render_encoding: RenderEncoding::TerminalAnsi,
|
||||
keybindings: None,
|
||||
direct_attach_requested: true,
|
||||
writer,
|
||||
}));
|
||||
assert!(server.clients.contains_key(&7));
|
||||
@@ -3270,6 +3304,115 @@ next_tab = ""
|
||||
);
|
||||
}
|
||||
|
||||
fn app_client_marks_git_refresh_due_on_first_attach(render_encoding: RenderEncoding) {
|
||||
let mut server = test_headless_server();
|
||||
server
|
||||
.app
|
||||
.state
|
||||
.workspaces
|
||||
.push(crate::workspace::Workspace::test_new("test"));
|
||||
let future = Instant::now() + Duration::from_secs(60);
|
||||
server.app.last_git_remote_status_refresh = future;
|
||||
let (writer, _control_rx, _render_rx) = test_client_writer();
|
||||
|
||||
assert!(server.handle_server_event(ServerEvent::ClientConnected {
|
||||
client_id: 7,
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cell_width_px: 0,
|
||||
cell_height_px: 0,
|
||||
render_encoding,
|
||||
keybindings: None,
|
||||
direct_attach_requested: false,
|
||||
writer,
|
||||
}));
|
||||
|
||||
assert!(server.has_app_client());
|
||||
assert!(server
|
||||
.app
|
||||
.git_refresh_deadline()
|
||||
.is_some_and(|deadline| deadline <= Instant::now()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_ansi_app_client_enables_headless_git_refresh() {
|
||||
app_client_marks_git_refresh_due_on_first_attach(RenderEncoding::TerminalAnsi);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_terminal_attach_client_does_not_enable_headless_git_refresh() {
|
||||
let mut server = test_headless_server();
|
||||
server
|
||||
.app
|
||||
.state
|
||||
.workspaces
|
||||
.push(crate::workspace::Workspace::test_new("test"));
|
||||
let (writer, _control_rx, _render_rx) = test_client_writer();
|
||||
|
||||
assert!(server.handle_server_event(ServerEvent::ClientConnected {
|
||||
client_id: 7,
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cell_width_px: 0,
|
||||
cell_height_px: 0,
|
||||
render_encoding: RenderEncoding::TerminalAnsi,
|
||||
keybindings: None,
|
||||
direct_attach_requested: true,
|
||||
writer,
|
||||
}));
|
||||
|
||||
assert!(!server.has_app_client());
|
||||
assert_eq!(
|
||||
server.app.next_headless_loop_deadline_with_git_refresh(
|
||||
Instant::now(),
|
||||
false,
|
||||
server.has_app_client()
|
||||
),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writerless_app_client_does_not_enable_headless_git_refresh() {
|
||||
let mut server = test_headless_server();
|
||||
server
|
||||
.app
|
||||
.state
|
||||
.workspaces
|
||||
.push(crate::workspace::Workspace::test_new("test"));
|
||||
let (writer, _control_rx, _render_rx) = test_client_writer();
|
||||
|
||||
assert!(server.handle_server_event(ServerEvent::ClientConnected {
|
||||
client_id: 7,
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cell_width_px: 0,
|
||||
cell_height_px: 0,
|
||||
render_encoding: RenderEncoding::SemanticFrame,
|
||||
keybindings: None,
|
||||
direct_attach_requested: false,
|
||||
writer,
|
||||
}));
|
||||
assert!(server.has_app_client());
|
||||
|
||||
server.clients.get_mut(&7).expect("client").writer = None;
|
||||
|
||||
assert!(!server.has_app_client());
|
||||
assert_eq!(
|
||||
server.app.next_headless_loop_deadline_with_git_refresh(
|
||||
Instant::now(),
|
||||
false,
|
||||
server.has_app_client()
|
||||
),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn semantic_app_client_marks_git_refresh_due_on_first_attach() {
|
||||
app_client_marks_git_refresh_due_on_first_attach(RenderEncoding::SemanticFrame);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_attach_client_exits_when_attached_pane_dies() {
|
||||
let mut server = test_headless_server();
|
||||
@@ -3292,6 +3435,7 @@ next_tab = ""
|
||||
cell_height_px: 0,
|
||||
render_encoding: RenderEncoding::TerminalAnsi,
|
||||
keybindings: None,
|
||||
direct_attach_requested: true,
|
||||
writer,
|
||||
}));
|
||||
assert!(
|
||||
@@ -4334,6 +4478,7 @@ next_tab = ""
|
||||
cell_height_px: 0,
|
||||
render_encoding: RenderEncoding::TerminalAnsi,
|
||||
keybindings: None,
|
||||
direct_attach_requested: true,
|
||||
writer,
|
||||
}));
|
||||
assert!(
|
||||
|
||||
+33
-12
@@ -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> {
|
||||
|
||||
@@ -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 §ion {
|
||||
ConfigSection::Extensions if key.eq_ignore_ascii_case("worktreeConfig") => {
|
||||
enabled = matches!(
|
||||
value.to_ascii_lowercase().as_str(),
|
||||
"true" | "1" | "yes" | "on"
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if matches!(section, ConfigSection::Extensions)
|
||||
&& line.eq_ignore_ascii_case("worktreeConfig")
|
||||
{
|
||||
enabled = true;
|
||||
}
|
||||
}
|
||||
enabled
|
||||
}
|
||||
|
||||
fn collect_remote_urls(
|
||||
path: &Path,
|
||||
info: &GitWorktreeInfo,
|
||||
branch: &str,
|
||||
remote_urls: &mut Vec<(String, String)>,
|
||||
include_stack: &mut Vec<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 §ion {
|
||||
ConfigSection::Remote(remote) if key.eq_ignore_ascii_case("url") => {
|
||||
remote_urls.push((remote.clone(), value));
|
||||
}
|
||||
ConfigSection::Include if key.eq_ignore_ascii_case("path") => {
|
||||
collect_remote_urls(
|
||||
&resolve_include_path(&path, &value),
|
||||
info,
|
||||
branch,
|
||||
remote_urls,
|
||||
include_stack,
|
||||
);
|
||||
}
|
||||
ConfigSection::IncludeIf(IncludeIfMode::Enabled)
|
||||
if key.eq_ignore_ascii_case("path") =>
|
||||
{
|
||||
collect_remote_urls(
|
||||
&resolve_include_path(&path, &value),
|
||||
info,
|
||||
branch,
|
||||
remote_urls,
|
||||
include_stack,
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
include_stack.pop();
|
||||
}
|
||||
|
||||
fn merge_git_config(
|
||||
config: &mut BranchConfig,
|
||||
path: &Path,
|
||||
branch: &str,
|
||||
info: &GitWorktreeInfo,
|
||||
collect_hasconfig_urls: bool,
|
||||
include_stack: &mut Vec<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 §ion {
|
||||
ConfigSection::Branch if key.eq_ignore_ascii_case("remote") => config.remote = value,
|
||||
ConfigSection::Branch if key.eq_ignore_ascii_case("merge") => config.merge_ref = value,
|
||||
ConfigSection::Remote(remote) if key.eq_ignore_ascii_case("fetch") => {
|
||||
config.fetch_refspecs.push((remote.clone(), value));
|
||||
}
|
||||
ConfigSection::Remote(remote)
|
||||
if collect_hasconfig_urls && key.eq_ignore_ascii_case("url") =>
|
||||
{
|
||||
config.remote_urls.push((remote.clone(), value));
|
||||
}
|
||||
ConfigSection::Include if key.eq_ignore_ascii_case("path") => {
|
||||
let include_path = resolve_include_path(&path, &value);
|
||||
merge_git_config(
|
||||
config,
|
||||
&include_path,
|
||||
branch,
|
||||
info,
|
||||
collect_hasconfig_urls,
|
||||
include_stack,
|
||||
);
|
||||
}
|
||||
ConfigSection::IncludeIf(IncludeIfMode::Enabled)
|
||||
if key.eq_ignore_ascii_case("path") =>
|
||||
{
|
||||
let include_path = resolve_include_path(&path, &value);
|
||||
merge_git_config(
|
||||
config,
|
||||
&include_path,
|
||||
branch,
|
||||
info,
|
||||
collect_hasconfig_urls,
|
||||
include_stack,
|
||||
);
|
||||
}
|
||||
ConfigSection::IncludeIf(IncludeIfMode::HasConfig)
|
||||
if key.eq_ignore_ascii_case("path") =>
|
||||
{
|
||||
let include_path = resolve_include_path(&path, &value);
|
||||
if !included_config_defines_remote_url(
|
||||
&include_path,
|
||||
branch,
|
||||
info,
|
||||
config,
|
||||
include_stack,
|
||||
) {
|
||||
merge_git_config(
|
||||
config,
|
||||
&include_path,
|
||||
branch,
|
||||
info,
|
||||
collect_hasconfig_urls,
|
||||
include_stack,
|
||||
);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
include_stack.pop();
|
||||
}
|
||||
|
||||
enum ConfigSection {
|
||||
Branch,
|
||||
Extensions,
|
||||
Include,
|
||||
IncludeIf(IncludeIfMode),
|
||||
Remote(String),
|
||||
Other,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum IncludeIfMode {
|
||||
Disabled,
|
||||
Enabled,
|
||||
HasConfig,
|
||||
}
|
||||
|
||||
fn extract_config_section(line: &str) -> Option<&str> {
|
||||
if !line.starts_with('[') {
|
||||
return None;
|
||||
}
|
||||
let mut in_quotes = false;
|
||||
let mut escaped = false;
|
||||
for (index, ch) in line.char_indices().skip(1) {
|
||||
if escaped {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
match ch {
|
||||
'\\' if in_quotes => escaped = true,
|
||||
'"' => in_quotes = !in_quotes,
|
||||
']' if !in_quotes => {
|
||||
let rest = line[index + 1..].trim();
|
||||
if rest.is_empty() || rest.starts_with('#') || rest.starts_with(';') {
|
||||
return Some(&line[1..index]);
|
||||
}
|
||||
return None;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn parse_config_section(
|
||||
section: &str,
|
||||
branch: &str,
|
||||
info: &GitWorktreeInfo,
|
||||
config_path: &Path,
|
||||
config: &BranchConfig,
|
||||
) -> ConfigSection {
|
||||
if let Some(name) = quoted_config_subsection(section, "branch") {
|
||||
return if name == branch {
|
||||
ConfigSection::Branch
|
||||
} else {
|
||||
ConfigSection::Other
|
||||
};
|
||||
}
|
||||
if let Some(name) = quoted_config_subsection(section, "remote") {
|
||||
return ConfigSection::Remote(name.to_string());
|
||||
}
|
||||
if section.eq_ignore_ascii_case("include") {
|
||||
return ConfigSection::Include;
|
||||
}
|
||||
if let Some(condition) = quoted_config_subsection(section, "includeIf") {
|
||||
return ConfigSection::IncludeIf(include_if_mode(
|
||||
condition,
|
||||
info,
|
||||
config_path,
|
||||
branch,
|
||||
config,
|
||||
));
|
||||
}
|
||||
ConfigSection::Other
|
||||
}
|
||||
|
||||
fn include_if_mode(
|
||||
condition: &str,
|
||||
info: &GitWorktreeInfo,
|
||||
config_path: &Path,
|
||||
branch: &str,
|
||||
config: &BranchConfig,
|
||||
) -> IncludeIfMode {
|
||||
let (case_insensitive, pattern) = if let Some(pattern) = condition.strip_prefix("gitdir/i:") {
|
||||
(true, pattern)
|
||||
} else if let Some(pattern) = condition.strip_prefix("gitdir:") {
|
||||
(false, pattern)
|
||||
} else if let Some(pattern) = condition.strip_prefix("onbranch:") {
|
||||
let pattern = normalize_branch_include_pattern(pattern);
|
||||
return if wildcard_match(&pattern, branch, false) {
|
||||
IncludeIfMode::Enabled
|
||||
} else {
|
||||
IncludeIfMode::Disabled
|
||||
};
|
||||
} else if let Some(pattern) = condition.strip_prefix("hasconfig:remote.*.url:") {
|
||||
return if config
|
||||
.remote_urls
|
||||
.iter()
|
||||
.any(|(_, url)| wildcard_match(pattern, url, false))
|
||||
{
|
||||
IncludeIfMode::HasConfig
|
||||
} else {
|
||||
IncludeIfMode::Disabled
|
||||
};
|
||||
} else {
|
||||
return IncludeIfMode::Disabled;
|
||||
};
|
||||
let pattern = normalize_gitdir_include_pattern(pattern, config_path);
|
||||
let candidates = [
|
||||
info.git_dir.display().to_string(),
|
||||
info.git_common_dir.display().to_string(),
|
||||
info.repo_root.join(".git").display().to_string(),
|
||||
];
|
||||
if candidates
|
||||
.iter()
|
||||
.any(|candidate| wildcard_match(&pattern, candidate, case_insensitive))
|
||||
{
|
||||
IncludeIfMode::Enabled
|
||||
} else {
|
||||
IncludeIfMode::Disabled
|
||||
}
|
||||
}
|
||||
|
||||
fn included_config_defines_remote_url(
|
||||
path: &Path,
|
||||
branch: &str,
|
||||
info: &GitWorktreeInfo,
|
||||
config: &BranchConfig,
|
||||
include_stack: &mut Vec<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 §ion {
|
||||
ConfigSection::Include if key.eq_ignore_ascii_case("path") => {
|
||||
let include_path = resolve_include_path(&path, &value);
|
||||
if !included_config_defines_remote_url(
|
||||
&include_path,
|
||||
branch,
|
||||
info,
|
||||
config,
|
||||
include_stack,
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
defines_remote_url = true;
|
||||
break;
|
||||
}
|
||||
ConfigSection::IncludeIf(IncludeIfMode::Enabled | IncludeIfMode::HasConfig)
|
||||
if key.eq_ignore_ascii_case("path") =>
|
||||
{
|
||||
let include_path = resolve_include_path(&path, &value);
|
||||
if !included_config_defines_remote_url(
|
||||
&include_path,
|
||||
branch,
|
||||
info,
|
||||
config,
|
||||
include_stack,
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
defines_remote_url = true;
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
include_stack.pop();
|
||||
defines_remote_url
|
||||
}
|
||||
|
||||
fn normalize_branch_include_pattern(pattern: &str) -> String {
|
||||
if pattern.ends_with('/') {
|
||||
format!("{pattern}**")
|
||||
} else {
|
||||
pattern.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_gitdir_include_pattern(pattern: &str, config_path: &Path) -> String {
|
||||
let mut pattern = if let Some(rest) = pattern.strip_prefix("~/") {
|
||||
std::env::var_os("HOME")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_default()
|
||||
.join(rest)
|
||||
.display()
|
||||
.to_string()
|
||||
} else if let Some(rest) = pattern.strip_prefix("./") {
|
||||
config_path
|
||||
.parent()
|
||||
.unwrap_or_else(|| Path::new("."))
|
||||
.join(rest)
|
||||
.display()
|
||||
.to_string()
|
||||
} else if Path::new(pattern).is_absolute() {
|
||||
pattern.to_string()
|
||||
} else {
|
||||
format!("**/{pattern}")
|
||||
};
|
||||
if pattern.ends_with('/') {
|
||||
pattern.push_str("**");
|
||||
}
|
||||
pattern
|
||||
}
|
||||
|
||||
fn wildcard_match(pattern: &str, value: &str, case_insensitive: bool) -> bool {
|
||||
let pattern = if case_insensitive {
|
||||
pattern.to_ascii_lowercase()
|
||||
} else {
|
||||
pattern.to_string()
|
||||
};
|
||||
let value = if case_insensitive {
|
||||
value.to_ascii_lowercase()
|
||||
} else {
|
||||
value.to_string()
|
||||
};
|
||||
wildcard_match_bytes(pattern.as_bytes(), value.as_bytes())
|
||||
}
|
||||
|
||||
fn wildcard_match_bytes(pattern: &[u8], value: &[u8]) -> bool {
|
||||
match pattern.split_first() {
|
||||
None => value.is_empty(),
|
||||
Some((&b'*', rest)) => {
|
||||
wildcard_match_bytes(rest, value)
|
||||
|| (!value.is_empty() && wildcard_match_bytes(pattern, &value[1..]))
|
||||
}
|
||||
Some((&expected, rest)) => value.split_first().is_some_and(|(&actual, value_rest)| {
|
||||
actual == expected && wildcard_match_bytes(rest, value_rest)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn quoted_config_subsection<'a>(section: &'a str, name: &str) -> Option<&'a str> {
|
||||
let prefix_len = name.len() + 2;
|
||||
if section.len() <= prefix_len {
|
||||
return None;
|
||||
}
|
||||
let prefix = §ion[..prefix_len];
|
||||
if !prefix.eq_ignore_ascii_case(&format!("{name} \"")) {
|
||||
return None;
|
||||
}
|
||||
section[prefix_len..].strip_suffix('"')
|
||||
}
|
||||
|
||||
fn resolve_include_path(config_path: &Path, include_path: &str) -> PathBuf {
|
||||
let include_path = include_path.strip_prefix("~/").map_or_else(
|
||||
|| PathBuf::from(include_path),
|
||||
|rest| {
|
||||
std::env::var_os("HOME")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_default()
|
||||
.join(rest)
|
||||
},
|
||||
);
|
||||
if include_path.is_absolute() {
|
||||
include_path
|
||||
} else {
|
||||
config_path
|
||||
.parent()
|
||||
.unwrap_or_else(|| Path::new("."))
|
||||
.join(include_path)
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_config_value(value: &str) -> String {
|
||||
let value = value.trim();
|
||||
let mut in_quotes = false;
|
||||
let mut escaped = false;
|
||||
for (index, ch) in value.char_indices() {
|
||||
if escaped {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
match ch {
|
||||
'\\' if in_quotes => escaped = true,
|
||||
'"' => in_quotes = !in_quotes,
|
||||
'#' | ';'
|
||||
if !in_quotes
|
||||
&& value[..index]
|
||||
.chars()
|
||||
.next_back()
|
||||
.is_some_and(char::is_whitespace) =>
|
||||
{
|
||||
return unquote_config_value(value[..index].trim());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
unquote_config_value(value)
|
||||
}
|
||||
|
||||
fn unquote_config_value(value: &str) -> String {
|
||||
value
|
||||
.strip_prefix('"')
|
||||
.and_then(|value| value.strip_suffix('"'))
|
||||
.unwrap_or(value)
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub(super) fn upstream_full_ref(config: &BranchConfig) -> Option<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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,733 @@
|
||||
use super::config::*;
|
||||
use crate::workspace::git::{
|
||||
discovery::git_worktree_info,
|
||||
status::git_status_fingerprint,
|
||||
test_support::{temp_test_dir, write_fake_tracked_repo},
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn git_status_fingerprint_honors_remote_fetch_refspec() {
|
||||
let root = temp_test_dir("custom-fetch-refspec");
|
||||
write_fake_tracked_repo(&root);
|
||||
std::fs::remove_dir_all(root.join(".git/refs/remotes/origin")).unwrap();
|
||||
std::fs::create_dir_all(root.join(".git/refs/remotes/upstream")).unwrap();
|
||||
std::fs::write(
|
||||
root.join(".git/refs/remotes/upstream/main"),
|
||||
"2222222222222222222222222222222222222222\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
root.join(".git/config"),
|
||||
"[remote \"origin\"]\n\tfetch = +refs/heads/*:refs/remotes/upstream/*\n[branch \"main\"]\n\tremote = origin\n\tmerge = refs/heads/main\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let fingerprint = git_status_fingerprint(&root).unwrap();
|
||||
|
||||
let upstream = fingerprint.upstream.unwrap();
|
||||
assert_eq!(upstream.full_ref, "refs/remotes/upstream/main");
|
||||
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_status_fingerprint_reads_included_config() {
|
||||
let root = temp_test_dir("included-config");
|
||||
write_fake_tracked_repo(&root);
|
||||
std::fs::remove_dir_all(root.join(".git/refs/remotes/origin")).unwrap();
|
||||
std::fs::create_dir_all(root.join(".git/refs/remotes/included")).unwrap();
|
||||
std::fs::write(
|
||||
root.join(".git/refs/remotes/included/main"),
|
||||
"2222222222222222222222222222222222222222\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
root.join(".git/config"),
|
||||
"[include]\n\tpath = included.cfg\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
root.join(".git/included.cfg"),
|
||||
"[remote \"included\"]\n\tfetch = +refs/heads/*:refs/remotes/included/*\n[branch \"main\"]\n\tremote = included\n\tmerge = refs/heads/main\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let fingerprint = git_status_fingerprint(&root).unwrap();
|
||||
|
||||
let upstream = fingerprint.upstream.unwrap();
|
||||
assert_eq!(upstream.remote, "included");
|
||||
assert_eq!(upstream.full_ref, "refs/remotes/included/main");
|
||||
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_status_branch_config_reads_user_config_before_repo_config() {
|
||||
let root = temp_test_dir("user-config");
|
||||
write_fake_tracked_repo(&root);
|
||||
let user_config = root.join("user.gitconfig");
|
||||
std::fs::write(root.join(".git/config"), "").unwrap();
|
||||
std::fs::write(
|
||||
&user_config,
|
||||
"[remote \"global\"]\n\tfetch = +refs/heads/*:refs/remotes/global/*\n[branch \"main\"]\n\tremote = global\n\tmerge = refs/heads/main\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let info = git_worktree_info(&root).unwrap();
|
||||
let config = read_branch_config_with_user_paths(&info, "main", vec![user_config]).unwrap();
|
||||
|
||||
assert_eq!(config.remote, "global");
|
||||
assert_eq!(
|
||||
upstream_full_ref(&config).as_deref(),
|
||||
Some("refs/remotes/global/main")
|
||||
);
|
||||
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_status_branch_config_repo_config_overrides_user_config() {
|
||||
let root = temp_test_dir("repo-overrides-user-config");
|
||||
write_fake_tracked_repo(&root);
|
||||
let user_config = root.join("user.gitconfig");
|
||||
std::fs::write(
|
||||
&user_config,
|
||||
"[remote \"global\"]\n\tfetch = +refs/heads/*:refs/remotes/global/*\n[branch \"main\"]\n\tremote = global\n\tmerge = refs/heads/main\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let info = git_worktree_info(&root).unwrap();
|
||||
let config = read_branch_config_with_user_paths(&info, "main", vec![user_config]).unwrap();
|
||||
|
||||
assert_eq!(config.remote, "origin");
|
||||
assert_eq!(
|
||||
upstream_full_ref(&config).as_deref(),
|
||||
Some("refs/remotes/origin/main")
|
||||
);
|
||||
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_status_fingerprint_applies_repeated_includes_in_order() {
|
||||
let root = temp_test_dir("repeated-include");
|
||||
write_fake_tracked_repo(&root);
|
||||
std::fs::remove_dir_all(root.join(".git/refs/remotes/origin")).unwrap();
|
||||
std::fs::create_dir_all(root.join(".git/refs/remotes/included")).unwrap();
|
||||
std::fs::write(
|
||||
root.join(".git/refs/remotes/included/main"),
|
||||
"2222222222222222222222222222222222222222\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
root.join(".git/config"),
|
||||
"[include]\n\tpath = included.cfg\n[branch \"main\"]\n\tremote = middle\n[include]\n\tpath = included.cfg\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
root.join(".git/included.cfg"),
|
||||
"[remote \"included\"]\n\tfetch = +refs/heads/*:refs/remotes/included/*\n[branch \"main\"]\n\tremote = included\n\tmerge = refs/heads/main\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let fingerprint = git_status_fingerprint(&root).unwrap();
|
||||
|
||||
let upstream = fingerprint.upstream.unwrap();
|
||||
assert_eq!(upstream.remote, "included");
|
||||
assert_eq!(upstream.full_ref, "refs/remotes/included/main");
|
||||
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_status_fingerprint_reads_matching_include_if_config() {
|
||||
let root = temp_test_dir("include-if-config");
|
||||
write_fake_tracked_repo(&root);
|
||||
std::fs::remove_dir_all(root.join(".git/refs/remotes/origin")).unwrap();
|
||||
std::fs::create_dir_all(root.join(".git/refs/remotes/included")).unwrap();
|
||||
std::fs::write(
|
||||
root.join(".git/refs/remotes/included/main"),
|
||||
"2222222222222222222222222222222222222222\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
root.join(".git/config"),
|
||||
format!(
|
||||
"[includeIf \"gitdir:{}\"]\n\tpath = included.cfg\n",
|
||||
root.join(".git").display()
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
root.join(".git/included.cfg"),
|
||||
"[remote \"included\"]\n\tfetch = +refs/heads/*:refs/remotes/included/*\n[branch \"main\"]\n\tremote = included\n\tmerge = refs/heads/main\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let fingerprint = git_status_fingerprint(&root).unwrap();
|
||||
|
||||
let upstream = fingerprint.upstream.unwrap();
|
||||
assert_eq!(upstream.remote, "included");
|
||||
assert_eq!(upstream.full_ref, "refs/remotes/included/main");
|
||||
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_status_fingerprint_matches_gitdir_include_if_directory_pattern() {
|
||||
let base = temp_test_dir("include-if-dir");
|
||||
let root = base.join("work/repo");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
write_fake_tracked_repo(&root);
|
||||
std::fs::remove_dir_all(root.join(".git/refs/remotes/origin")).unwrap();
|
||||
std::fs::create_dir_all(root.join(".git/refs/remotes/included")).unwrap();
|
||||
std::fs::write(
|
||||
root.join(".git/refs/remotes/included/main"),
|
||||
"2222222222222222222222222222222222222222\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
root.join(".git/config"),
|
||||
format!(
|
||||
"[includeIf \"gitdir:{}/\"]\n\tpath = included.cfg\n",
|
||||
base.join("work").display()
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
root.join(".git/included.cfg"),
|
||||
"[remote \"included\"]\n\tfetch = +refs/heads/*:refs/remotes/included/*\n[branch \"main\"]\n\tremote = included\n\tmerge = refs/heads/main\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let fingerprint = git_status_fingerprint(&root).unwrap();
|
||||
|
||||
let upstream = fingerprint.upstream.unwrap();
|
||||
assert_eq!(upstream.remote, "included");
|
||||
assert_eq!(upstream.full_ref, "refs/remotes/included/main");
|
||||
|
||||
std::fs::remove_dir_all(base).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_status_fingerprint_reads_case_insensitive_config_keys() {
|
||||
let root = temp_test_dir("case-insensitive-config");
|
||||
write_fake_tracked_repo(&root);
|
||||
std::fs::write(
|
||||
root.join(".git/config"),
|
||||
"[Remote \"origin\"] # remote section\n\tFetch = +refs/heads/*:refs/remotes/origin/*\n[Branch \"main\"] ; branch section\n\tRemote = origin\n\tMerge = refs/heads/main\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let fingerprint = git_status_fingerprint(&root).unwrap();
|
||||
|
||||
let upstream = fingerprint.upstream.unwrap();
|
||||
assert_eq!(upstream.remote, "origin");
|
||||
assert_eq!(upstream.full_ref, "refs/remotes/origin/main");
|
||||
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_status_fingerprint_keeps_refspecs_for_later_remote_override() {
|
||||
let root = temp_test_dir("worktree-remote-override");
|
||||
write_fake_tracked_repo(&root);
|
||||
std::fs::remove_dir_all(root.join(".git/refs/remotes/origin")).unwrap();
|
||||
std::fs::create_dir_all(root.join(".git/refs/remotes/fork")).unwrap();
|
||||
std::fs::write(
|
||||
root.join(".git/refs/remotes/fork/main"),
|
||||
"2222222222222222222222222222222222222222\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
root.join(".git/config"),
|
||||
"[extensions]\n\tworktreeConfig = true\n[remote \"fork\"]\n\tfetch = +refs/heads/*:refs/remotes/fork/*\n[branch \"main\"]\n\tremote = origin\n\tmerge = refs/heads/main\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
root.join(".git/config.worktree"),
|
||||
"[branch \"main\"]\n\tremote = fork\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let fingerprint = git_status_fingerprint(&root).unwrap();
|
||||
|
||||
let upstream = fingerprint.upstream.unwrap();
|
||||
assert_eq!(upstream.remote, "fork");
|
||||
assert_eq!(upstream.full_ref, "refs/remotes/fork/main");
|
||||
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_status_fingerprint_ignores_worktree_config_when_extension_disabled() {
|
||||
let root = temp_test_dir("worktree-config-disabled");
|
||||
write_fake_tracked_repo(&root);
|
||||
std::fs::create_dir_all(root.join(".git/refs/remotes/fork")).unwrap();
|
||||
std::fs::write(
|
||||
root.join(".git/refs/remotes/fork/main"),
|
||||
"3333333333333333333333333333333333333333\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
root.join(".git/config"),
|
||||
"[remote \"fork\"]\n\tfetch = +refs/heads/*:refs/remotes/fork/*\n[branch \"main\"]\n\tremote = origin\n\tmerge = refs/heads/main\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
root.join(".git/config.worktree"),
|
||||
"[branch \"main\"]\n\tremote = fork\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let fingerprint = git_status_fingerprint(&root).unwrap();
|
||||
|
||||
let upstream = fingerprint.upstream.unwrap();
|
||||
assert_eq!(upstream.remote, "origin");
|
||||
assert_eq!(upstream.full_ref, "refs/remotes/origin/main");
|
||||
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_status_fingerprint_accepts_git_boolean_worktree_config() {
|
||||
let root = temp_test_dir("worktree-config-boolean");
|
||||
write_fake_tracked_repo(&root);
|
||||
std::fs::create_dir_all(root.join(".git/refs/remotes/fork")).unwrap();
|
||||
std::fs::write(
|
||||
root.join(".git/refs/remotes/fork/main"),
|
||||
"3333333333333333333333333333333333333333\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
root.join(".git/config"),
|
||||
"[extensions]\n\tworktreeConfig\n[remote \"fork\"]\n\tfetch = +refs/heads/*:refs/remotes/fork/*\n[branch \"main\"]\n\tremote = origin\n\tmerge = refs/heads/main\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
root.join(".git/config.worktree"),
|
||||
"[branch \"main\"]\n\tremote = fork\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let fingerprint = git_status_fingerprint(&root).unwrap();
|
||||
|
||||
let upstream = fingerprint.upstream.unwrap();
|
||||
assert_eq!(upstream.remote, "fork");
|
||||
assert_eq!(upstream.full_ref, "refs/remotes/fork/main");
|
||||
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_status_fingerprint_uses_last_worktree_config_boolean() {
|
||||
let root = temp_test_dir("worktree-config-duplicate-boolean");
|
||||
write_fake_tracked_repo(&root);
|
||||
std::fs::create_dir_all(root.join(".git/refs/remotes/fork")).unwrap();
|
||||
std::fs::write(
|
||||
root.join(".git/refs/remotes/fork/main"),
|
||||
"3333333333333333333333333333333333333333\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
root.join(".git/config"),
|
||||
"[extensions]\n\tworktreeConfig = false\n\tworktreeConfig = true\n[remote \"fork\"]\n\tfetch = +refs/heads/*:refs/remotes/fork/*\n[branch \"main\"]\n\tremote = origin\n\tmerge = refs/heads/main\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
root.join(".git/config.worktree"),
|
||||
"[branch \"main\"]\n\tremote = fork\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let fingerprint = git_status_fingerprint(&root).unwrap();
|
||||
|
||||
let upstream = fingerprint.upstream.unwrap();
|
||||
assert_eq!(upstream.remote, "fork");
|
||||
assert_eq!(upstream.full_ref, "refs/remotes/fork/main");
|
||||
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_status_fingerprint_ignores_included_worktree_config_extension() {
|
||||
let root = temp_test_dir("worktree-config-included-extension");
|
||||
write_fake_tracked_repo(&root);
|
||||
std::fs::create_dir_all(root.join(".git/refs/remotes/fork")).unwrap();
|
||||
std::fs::write(
|
||||
root.join(".git/refs/remotes/fork/main"),
|
||||
"3333333333333333333333333333333333333333\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
root.join(".git/config"),
|
||||
"[include]\n\tpath = extension.cfg\n[remote \"fork\"]\n\tfetch = +refs/heads/*:refs/remotes/fork/*\n[branch \"main\"]\n\tremote = origin\n\tmerge = refs/heads/main\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
root.join(".git/extension.cfg"),
|
||||
"[extensions]\n\tworktreeConfig = true\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
root.join(".git/config.worktree"),
|
||||
"[branch \"main\"]\n\tremote = fork\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let fingerprint = git_status_fingerprint(&root).unwrap();
|
||||
|
||||
let upstream = fingerprint.upstream.unwrap();
|
||||
assert_eq!(upstream.remote, "origin");
|
||||
assert_eq!(upstream.full_ref, "refs/remotes/origin/main");
|
||||
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_status_fingerprint_reads_onbranch_include_if_config() {
|
||||
let root = temp_test_dir("include-if-onbranch");
|
||||
write_fake_tracked_repo(&root);
|
||||
std::fs::remove_dir_all(root.join(".git/refs/remotes/origin")).unwrap();
|
||||
std::fs::create_dir_all(root.join(".git/refs/remotes/included")).unwrap();
|
||||
std::fs::write(
|
||||
root.join(".git/refs/remotes/included/main"),
|
||||
"2222222222222222222222222222222222222222\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
root.join(".git/config"),
|
||||
"[includeIf \"onbranch:main\"]\n\tpath = included.cfg\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
root.join(".git/included.cfg"),
|
||||
"[remote \"included\"]\n\tfetch = +refs/heads/*:refs/remotes/included/*\n[branch \"main\"]\n\tremote = included\n\tmerge = refs/heads/main\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let fingerprint = git_status_fingerprint(&root).unwrap();
|
||||
|
||||
let upstream = fingerprint.upstream.unwrap();
|
||||
assert_eq!(upstream.remote, "included");
|
||||
assert_eq!(upstream.full_ref, "refs/remotes/included/main");
|
||||
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_status_fingerprint_reads_hasconfig_include_if_config() {
|
||||
let root = temp_test_dir("include-if-hasconfig");
|
||||
write_fake_tracked_repo(&root);
|
||||
std::fs::remove_dir_all(root.join(".git/refs/remotes/origin")).unwrap();
|
||||
std::fs::create_dir_all(root.join(".git/refs/remotes/included")).unwrap();
|
||||
std::fs::write(
|
||||
root.join(".git/refs/remotes/included/main"),
|
||||
"2222222222222222222222222222222222222222\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
root.join(".git/config"),
|
||||
"[remote \"fork\"]\n\turl = https://example.test/fork.git\n[includeIf \"hasconfig:remote.*.url:*fork.git\"]\n\tpath = included.cfg\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
root.join(".git/included.cfg"),
|
||||
"[remote \"included\"]\n\tfetch = +refs/heads/*:refs/remotes/included/*\n[branch \"main\"]\n\tremote = included\n\tmerge = refs/heads/main\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let fingerprint = git_status_fingerprint(&root).unwrap();
|
||||
|
||||
let upstream = fingerprint.upstream.unwrap();
|
||||
assert_eq!(upstream.remote, "included");
|
||||
assert_eq!(upstream.full_ref, "refs/remotes/included/main");
|
||||
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_status_fingerprint_matches_user_hasconfig_against_repo_remote_url() {
|
||||
let root = temp_test_dir("include-if-hasconfig-user-repo-url");
|
||||
let user_config = root.join("user.gitconfig");
|
||||
write_fake_tracked_repo(&root);
|
||||
std::fs::remove_dir_all(root.join(".git/refs/remotes/origin")).unwrap();
|
||||
std::fs::create_dir_all(root.join(".git/refs/remotes/included")).unwrap();
|
||||
std::fs::write(
|
||||
root.join(".git/refs/remotes/included/main"),
|
||||
"2222222222222222222222222222222222222222\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
root.join(".git/config"),
|
||||
"[remote \"fork\"]\n\turl = https://example.test/fork.git\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
&user_config,
|
||||
"[includeIf \"hasconfig:remote.*.url:*fork.git\"]\n\tpath = user-included.cfg\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
root.join("user-included.cfg"),
|
||||
"[remote \"included\"]\n\tfetch = +refs/heads/*:refs/remotes/included/*\n[branch \"main\"]\n\tremote = included\n\tmerge = refs/heads/main\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let info = git_worktree_info(&root).unwrap();
|
||||
let config = read_branch_config_with_user_paths(&info, "main", vec![user_config]).unwrap();
|
||||
|
||||
assert_eq!(config.remote, "included");
|
||||
assert_eq!(config.merge_ref, "refs/heads/main");
|
||||
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_status_fingerprint_skips_hasconfig_include_that_defines_remote_url() {
|
||||
let root = temp_test_dir("include-if-hasconfig-rejects-remote-url");
|
||||
let user_config = root.join("user.gitconfig");
|
||||
write_fake_tracked_repo(&root);
|
||||
std::fs::create_dir_all(root.join(".git/refs/remotes/included")).unwrap();
|
||||
std::fs::write(
|
||||
root.join(".git/refs/remotes/included/main"),
|
||||
"3333333333333333333333333333333333333333\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
root.join(".git/config"),
|
||||
"[remote \"fork\"]\n\turl = https://example.test/fork.git\n[branch \"main\"]\n\tremote = origin\n\tmerge = refs/heads/main\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
&user_config,
|
||||
"[includeIf \"hasconfig:remote.*.url:*fork.git\"]\n\tpath = user-included.cfg\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
root.join("user-included.cfg"),
|
||||
"[remote \"included\"]\n\turl = https://example.test/included.git\n\tfetch = +refs/heads/*:refs/remotes/included/*\n[branch \"main\"]\n\tremote = included\n\tmerge = refs/heads/main\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let info = git_worktree_info(&root).unwrap();
|
||||
let config = read_branch_config_with_user_paths(&info, "main", vec![user_config]).unwrap();
|
||||
|
||||
assert_eq!(config.remote, "origin");
|
||||
assert_eq!(config.merge_ref, "refs/heads/main");
|
||||
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_status_fingerprint_skips_hasconfig_include_chain_that_defines_remote_url() {
|
||||
let root = temp_test_dir("include-if-hasconfig-rejects-nested-remote-url");
|
||||
let user_config = root.join("user.gitconfig");
|
||||
write_fake_tracked_repo(&root);
|
||||
std::fs::create_dir_all(root.join(".git/refs/remotes/included")).unwrap();
|
||||
std::fs::write(
|
||||
root.join(".git/refs/remotes/included/main"),
|
||||
"3333333333333333333333333333333333333333\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
root.join(".git/config"),
|
||||
"[remote \"fork\"]\n\turl = https://example.test/fork.git\n[branch \"main\"]\n\tremote = origin\n\tmerge = refs/heads/main\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
&user_config,
|
||||
"[includeIf \"hasconfig:remote.*.url:*fork.git\"]\n\tpath = user-included.cfg\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
root.join("user-included.cfg"),
|
||||
"[include]\n\tpath = nested-remote.cfg\n[branch \"main\"]\n\tremote = included\n\tmerge = refs/heads/main\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
root.join("nested-remote.cfg"),
|
||||
"[remote \"included\"]\n\turl = https://example.test/included.git\n\tfetch = +refs/heads/*:refs/remotes/included/*\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let info = git_worktree_info(&root).unwrap();
|
||||
let config = read_branch_config_with_user_paths(&info, "main", vec![user_config]).unwrap();
|
||||
|
||||
assert_eq!(config.remote, "origin");
|
||||
assert_eq!(config.merge_ref, "refs/heads/main");
|
||||
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_status_fingerprint_ignores_worktree_urls_for_hasconfig() {
|
||||
let root = temp_test_dir("include-if-hasconfig-worktree-url");
|
||||
write_fake_tracked_repo(&root);
|
||||
std::fs::write(
|
||||
root.join(".git/config"),
|
||||
"[extensions]\n\tworktreeConfig = true\n[branch \"main\"]\n\tremote = origin\n\tmerge = refs/heads/main\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
root.join(".git/config.worktree"),
|
||||
"[remote \"fork\"]\n\turl = https://example.test/fork.git\n[includeIf \"hasconfig:remote.*.url:*fork.git\"]\n\tpath = included.cfg\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
root.join(".git/included.cfg"),
|
||||
"[branch \"main\"]\n\tremote = included\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let fingerprint = git_status_fingerprint(&root).unwrap();
|
||||
|
||||
let upstream = fingerprint.upstream.unwrap();
|
||||
assert_eq!(upstream.remote, "origin");
|
||||
assert_eq!(upstream.full_ref, "refs/remotes/origin/main");
|
||||
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_status_fingerprint_stops_recursive_include_cycles() {
|
||||
let root = temp_test_dir("include-cycle");
|
||||
write_fake_tracked_repo(&root);
|
||||
std::fs::remove_dir_all(root.join(".git/refs/remotes/origin")).unwrap();
|
||||
std::fs::create_dir_all(root.join(".git/refs/remotes/included")).unwrap();
|
||||
std::fs::write(
|
||||
root.join(".git/refs/remotes/included/main"),
|
||||
"2222222222222222222222222222222222222222\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(root.join(".git/config"), "[include]\n\tpath = a.cfg\n").unwrap();
|
||||
std::fs::write(root.join(".git/a.cfg"), "[include]\n\tpath = b.cfg\n").unwrap();
|
||||
std::fs::write(
|
||||
root.join(".git/b.cfg"),
|
||||
"[include]\n\tpath = a.cfg\n[remote \"included\"]\n\tfetch = +refs/heads/*:refs/remotes/included/*\n[branch \"main\"]\n\tremote = included\n\tmerge = refs/heads/main\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let fingerprint = git_status_fingerprint(&root).unwrap();
|
||||
|
||||
let upstream = fingerprint.upstream.unwrap();
|
||||
assert_eq!(upstream.remote, "included");
|
||||
assert_eq!(upstream.full_ref, "refs/remotes/included/main");
|
||||
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_status_fingerprint_reads_linked_worktree_config() {
|
||||
let base = temp_test_dir("linked-worktree-config");
|
||||
let common_dir = base.join("repo/.git");
|
||||
let worktree = base.join("linked");
|
||||
let git_dir = common_dir.join("worktrees/linked");
|
||||
std::fs::create_dir_all(common_dir.join("refs/heads")).unwrap();
|
||||
std::fs::create_dir_all(common_dir.join("refs/remotes/fork")).unwrap();
|
||||
std::fs::create_dir_all(&git_dir).unwrap();
|
||||
std::fs::create_dir_all(&worktree).unwrap();
|
||||
std::fs::write(
|
||||
worktree.join(".git"),
|
||||
format!("gitdir: {}\n", git_dir.display()),
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(git_dir.join("commondir"), "../..\n").unwrap();
|
||||
std::fs::write(git_dir.join("HEAD"), "ref: refs/heads/main\n").unwrap();
|
||||
std::fs::write(
|
||||
common_dir.join("refs/heads/main"),
|
||||
"1111111111111111111111111111111111111111\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
common_dir.join("refs/remotes/fork/main"),
|
||||
"2222222222222222222222222222222222222222\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
common_dir.join("config"),
|
||||
"[extensions]\n\tworktreeConfig = TRUE\n[remote \"fork\"]\n\tfetch = +refs/heads/*:refs/remotes/fork/*\n[branch \"main\"]\n\tremote = origin\n\tmerge = refs/heads/main\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
git_dir.join("config.worktree"),
|
||||
"[branch \"main\"]\n\tremote = fork\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let fingerprint = git_status_fingerprint(&worktree).unwrap();
|
||||
|
||||
let upstream = fingerprint.upstream.unwrap();
|
||||
assert_eq!(upstream.remote, "fork");
|
||||
assert_eq!(upstream.full_ref, "refs/remotes/fork/main");
|
||||
|
||||
std::fs::remove_dir_all(base).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_status_fingerprint_ignores_inline_fetch_refspec_comment() {
|
||||
let root = temp_test_dir("commented-fetch-refspec");
|
||||
write_fake_tracked_repo(&root);
|
||||
std::fs::remove_dir_all(root.join(".git/refs/remotes/origin")).unwrap();
|
||||
std::fs::create_dir_all(root.join(".git/refs/remotes/upstream")).unwrap();
|
||||
std::fs::write(
|
||||
root.join(".git/refs/remotes/upstream/main"),
|
||||
"2222222222222222222222222222222222222222\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
root.join(".git/config"),
|
||||
"[remote \"origin\"]\n\tfetch = +refs/heads/*:refs/remotes/upstream/* # custom map\n[branch \"main\"]\n\tremote = origin\n\tmerge = refs/heads/main\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let fingerprint = git_status_fingerprint(&root).unwrap();
|
||||
|
||||
let upstream = fingerprint.upstream.unwrap();
|
||||
assert_eq!(upstream.full_ref, "refs/remotes/upstream/main");
|
||||
assert_eq!(
|
||||
upstream.oid.as_deref(),
|
||||
Some("2222222222222222222222222222222222222222")
|
||||
);
|
||||
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_status_fingerprint_clears_upstream_for_unmapped_refspec() {
|
||||
let root = temp_test_dir("unmapped-fetch-refspec");
|
||||
write_fake_tracked_repo(&root);
|
||||
std::fs::write(
|
||||
root.join(".git/config"),
|
||||
"[remote \"origin\"]\n\tfetch = +refs/pull/*:refs/remotes/origin/pr/*\n[branch \"main\"]\n\tremote = origin\n\tmerge = refs/heads/main\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let fingerprint = git_status_fingerprint(&root).unwrap();
|
||||
|
||||
assert_eq!(fingerprint.upstream, None);
|
||||
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_status_fingerprint_honors_negative_fetch_refspec() {
|
||||
let root = temp_test_dir("negative-fetch-refspec");
|
||||
write_fake_tracked_repo(&root);
|
||||
std::fs::write(
|
||||
root.join(".git/config"),
|
||||
"[remote \"origin\"]\n\tfetch = +refs/heads/*:refs/remotes/origin/*\n\tfetch = ^refs/heads/main\n[branch \"main\"]\n\tremote = origin\n\tmerge = refs/heads/main\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let fingerprint = git_status_fingerprint(&root).unwrap();
|
||||
|
||||
let upstream = fingerprint.upstream.unwrap();
|
||||
assert_eq!(upstream.full_ref, "refs/remotes/origin/main");
|
||||
assert_eq!(
|
||||
upstream.oid.as_deref(),
|
||||
Some("2222222222222222222222222222222222222222")
|
||||
);
|
||||
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
@@ -40,44 +40,23 @@ pub fn derive_label_from_cwd(cwd: &Path) -> String {
|
||||
}
|
||||
|
||||
pub fn git_worktree_info(cwd: &Path) -> Option<GitWorktreeInfo> {
|
||||
let repo_root = git_rev_parse(cwd, &["--show-toplevel"])?;
|
||||
let git_dir = git_rev_parse(cwd, &["--path-format=absolute", "--git-dir"])?;
|
||||
let git_common_dir = git_rev_parse(cwd, &["--path-format=absolute", "--git-common-dir"])?;
|
||||
let is_bare = git_rev_parse(cwd, &["--is-bare-repository"])? == "true";
|
||||
let is_linked_worktree =
|
||||
canonicalize_best_effort(&git_dir) != canonicalize_best_effort(&git_common_dir);
|
||||
let repo_root = git_repo_root(cwd)?;
|
||||
let git_dir = canonicalize_best_effort_path(&git_dir_for_repo_root(&repo_root)?);
|
||||
let git_common_dir = canonicalize_best_effort_path(&git_common_dir_for_git_dir(&git_dir));
|
||||
let is_linked_worktree = git_dir != git_common_dir;
|
||||
|
||||
Some(GitWorktreeInfo {
|
||||
repo_root: PathBuf::from(repo_root),
|
||||
git_dir: PathBuf::from(git_dir),
|
||||
git_common_dir: PathBuf::from(git_common_dir),
|
||||
is_bare,
|
||||
repo_root,
|
||||
git_dir,
|
||||
git_common_dir,
|
||||
is_bare: false,
|
||||
is_linked_worktree,
|
||||
})
|
||||
}
|
||||
|
||||
fn git_rev_parse(cwd: &Path, args: &[&str]) -> Option<String> {
|
||||
let output = std::process::Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(cwd)
|
||||
.arg("rev-parse")
|
||||
.args(args)
|
||||
.output()
|
||||
.ok()?;
|
||||
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8(output.stdout).ok()?;
|
||||
Some(stdout.trim().to_string())
|
||||
}
|
||||
|
||||
fn canonicalize_best_effort(path: &str) -> PathBuf {
|
||||
std::fs::canonicalize(path).unwrap_or_else(|_| PathBuf::from(path))
|
||||
}
|
||||
|
||||
pub fn git_space_metadata(cwd: &Path) -> Option<GitSpaceMetadata> {
|
||||
git_repo_root(cwd)?;
|
||||
|
||||
let info = git_worktree_info(cwd)?;
|
||||
if info.is_bare {
|
||||
return None;
|
||||
@@ -112,10 +91,23 @@ pub fn git_space_metadata(cwd: &Path) -> Option<GitSpaceMetadata> {
|
||||
})
|
||||
}
|
||||
|
||||
fn canonicalize_best_effort_path(path: &Path) -> PathBuf {
|
||||
pub(super) fn canonicalize_best_effort_path(path: &Path) -> PathBuf {
|
||||
std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
|
||||
}
|
||||
|
||||
fn git_common_dir_for_git_dir(git_dir: &Path) -> PathBuf {
|
||||
let commondir = git_dir.join("commondir");
|
||||
let Ok(contents) = std::fs::read_to_string(commondir) else {
|
||||
return git_dir.to_path_buf();
|
||||
};
|
||||
let path = Path::new(contents.trim());
|
||||
if path.is_absolute() {
|
||||
path.to_path_buf()
|
||||
} else {
|
||||
git_dir.join(path)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn git_branch(cwd: &Path) -> Option<String> {
|
||||
let repo_root = git_repo_root(cwd)?;
|
||||
let git_dir = git_dir_for_repo_root(&repo_root)?;
|
||||
@@ -123,7 +115,7 @@ pub fn git_branch(cwd: &Path) -> Option<String> {
|
||||
parse_git_head_branch(&head)
|
||||
}
|
||||
|
||||
fn git_dir_for_repo_root(repo_root: &Path) -> Option<PathBuf> {
|
||||
pub(super) fn git_dir_for_repo_root(repo_root: &Path) -> Option<PathBuf> {
|
||||
let git_path = repo_root.join(".git");
|
||||
if git_path.is_dir() {
|
||||
return Some(git_path);
|
||||
@@ -144,7 +136,7 @@ fn parse_git_head_branch(head: &str) -> Option<String> {
|
||||
(!branch.is_empty()).then(|| branch.to_string())
|
||||
}
|
||||
|
||||
fn git_repo_root(start: &Path) -> Option<PathBuf> {
|
||||
pub(super) fn git_repo_root(start: &Path) -> Option<PathBuf> {
|
||||
let mut current = if start.is_dir() {
|
||||
start.to_path_buf()
|
||||
} else {
|
||||
@@ -152,7 +144,10 @@ fn git_repo_root(start: &Path) -> Option<PathBuf> {
|
||||
};
|
||||
|
||||
loop {
|
||||
if current.join(".git").exists() {
|
||||
if git_dir_for_repo_root(¤t)
|
||||
.map(|git_dir| git_dir.join("HEAD").is_file())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Some(current);
|
||||
}
|
||||
if !current.pop() {
|
||||
@@ -161,33 +156,34 @@ fn git_repo_root(start: &Path) -> Option<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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
pub(super) fn temp_test_dir(name: &str) -> PathBuf {
|
||||
let unique = format!(
|
||||
"herdr-workspace-tests-{}-{}-{}",
|
||||
name,
|
||||
std::process::id(),
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
);
|
||||
let path = std::env::temp_dir().join(unique);
|
||||
std::fs::create_dir_all(&path).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
pub(super) fn write_fake_tracked_repo(root: &Path) {
|
||||
let head_oid = "1111111111111111111111111111111111111111";
|
||||
let upstream_oid = "2222222222222222222222222222222222222222";
|
||||
std::fs::create_dir_all(root.join(".git/refs/heads")).unwrap();
|
||||
std::fs::create_dir_all(root.join(".git/refs/remotes/origin")).unwrap();
|
||||
std::fs::write(root.join(".git/HEAD"), "ref: refs/heads/main\n").unwrap();
|
||||
std::fs::write(root.join(".git/refs/heads/main"), format!("{head_oid}\n")).unwrap();
|
||||
std::fs::write(
|
||||
root.join(".git/refs/remotes/origin/main"),
|
||||
format!("{upstream_oid}\n"),
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
root.join(".git/config"),
|
||||
"[branch \"main\"]\n\tremote = origin\n\tmerge = refs/heads/main\n",
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
pub(super) fn run_git(cwd: &Path, args: &[&str]) {
|
||||
let output = std::process::Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(cwd)
|
||||
.args(args)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"git {:?} failed: {}",
|
||||
args,
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
+1
-1
@@ -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);
|
||||
}
|
||||
|
||||
@@ -1238,7 +1238,7 @@ fn status_commands_report_client_and_server_versions() {
|
||||
"stdout: {full_stdout}"
|
||||
);
|
||||
assert!(
|
||||
full_stdout.contains(" protocol: 11"),
|
||||
full_stdout.contains(" protocol: 12"),
|
||||
"stdout: {full_stdout}"
|
||||
);
|
||||
assert!(full_stdout.contains("server:\n"), "stdout: {full_stdout}");
|
||||
@@ -1271,7 +1271,7 @@ fn status_commands_report_client_and_server_versions() {
|
||||
"stdout: {server_stdout}"
|
||||
);
|
||||
assert!(
|
||||
server_stdout.contains("protocol: 11"),
|
||||
server_stdout.contains("protocol: 12"),
|
||||
"stdout: {server_stdout}"
|
||||
);
|
||||
|
||||
@@ -1283,7 +1283,7 @@ fn status_commands_report_client_and_server_versions() {
|
||||
"stdout: {client_stdout}"
|
||||
);
|
||||
assert!(
|
||||
client_stdout.contains("protocol: 11"),
|
||||
client_stdout.contains("protocol: 12"),
|
||||
"stdout: {client_stdout}"
|
||||
);
|
||||
assert!(
|
||||
@@ -1293,7 +1293,7 @@ fn status_commands_report_client_and_server_versions() {
|
||||
|
||||
let full_json = run_cli_json(&socket_path, &["status", "--json"]);
|
||||
assert_eq!(full_json["client"]["version"], env!("CARGO_PKG_VERSION"));
|
||||
assert_eq!(full_json["client"]["protocol"], 11);
|
||||
assert_eq!(full_json["client"]["protocol"], 12);
|
||||
assert_eq!(full_json["server"]["status"], "running");
|
||||
assert_eq!(full_json["server"]["running"], true);
|
||||
assert_eq!(full_json["server"]["compatible"], true);
|
||||
@@ -1307,12 +1307,12 @@ fn status_commands_report_client_and_server_versions() {
|
||||
let server_json = run_cli_json(&socket_path, &["status", "server", "--json"]);
|
||||
assert_eq!(server_json["status"], "running");
|
||||
assert_eq!(server_json["version"], env!("CARGO_PKG_VERSION"));
|
||||
assert_eq!(server_json["protocol"], 11);
|
||||
assert_eq!(server_json["protocol"], 12);
|
||||
assert_eq!(server_json["compatible"], true);
|
||||
|
||||
let client_json = run_cli_json(&socket_path, &["status", "client", "--json"]);
|
||||
assert_eq!(client_json["version"], env!("CARGO_PKG_VERSION"));
|
||||
assert_eq!(client_json["protocol"], 11);
|
||||
assert_eq!(client_json["protocol"], 12);
|
||||
assert!(client_json["binary"]
|
||||
.as_str()
|
||||
.is_some_and(|path| !path.is_empty()));
|
||||
|
||||
+18
-18
@@ -274,8 +274,8 @@ fn client_connects_and_receives_frame() {
|
||||
// Connect and handshake.
|
||||
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
|
||||
let (version, error) =
|
||||
client_handshake(&mut stream, 11, 80, 24).expect("handshake should succeed");
|
||||
assert_eq!(version, 11, "server should report protocol version 11");
|
||||
client_handshake(&mut stream, 12, 80, 24).expect("handshake should succeed");
|
||||
assert_eq!(version, 12, "server should report protocol version 12");
|
||||
assert!(
|
||||
error.is_none(),
|
||||
"handshake should not have error: {:?}",
|
||||
@@ -342,8 +342,8 @@ fn client_sees_headless_startup_config_diagnostic() {
|
||||
|
||||
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
|
||||
let (version, error) =
|
||||
client_handshake(&mut stream, 11, 80, 24).expect("handshake should succeed");
|
||||
assert_eq!(version, 11);
|
||||
client_handshake(&mut stream, 12, 80, 24).expect("handshake should succeed");
|
||||
assert_eq!(version, 12);
|
||||
assert!(error.is_none(), "{:?}", error);
|
||||
|
||||
stream
|
||||
@@ -391,8 +391,8 @@ fn client_input_forwarded_to_pane() {
|
||||
// Connect and handshake.
|
||||
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
|
||||
let (version, error) =
|
||||
client_handshake(&mut stream, 11, 80, 24).expect("handshake should succeed");
|
||||
assert_eq!(version, 11);
|
||||
client_handshake(&mut stream, 12, 80, 24).expect("handshake should succeed");
|
||||
assert_eq!(version, 12);
|
||||
assert!(error.is_none(), "{:?}", error);
|
||||
|
||||
// Send an Input message containing "echo hello\n".
|
||||
@@ -445,8 +445,8 @@ fn client_resize_sends_message() {
|
||||
// Connect and handshake.
|
||||
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
|
||||
let (version, error) =
|
||||
client_handshake(&mut stream, 11, 80, 24).expect("handshake should succeed");
|
||||
assert_eq!(version, 11);
|
||||
client_handshake(&mut stream, 12, 80, 24).expect("handshake should succeed");
|
||||
assert_eq!(version, 12);
|
||||
assert!(error.is_none(), "{:?}", error);
|
||||
|
||||
// Drain the initial frame(s).
|
||||
@@ -504,8 +504,8 @@ fn server_shutdown_sends_message_to_client() {
|
||||
// Connect and handshake.
|
||||
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
|
||||
let (version, error) =
|
||||
client_handshake(&mut stream, 11, 80, 24).expect("handshake should succeed");
|
||||
assert_eq!(version, 11);
|
||||
client_handshake(&mut stream, 12, 80, 24).expect("handshake should succeed");
|
||||
assert_eq!(version, 12);
|
||||
assert!(error.is_none(), "{:?}", error);
|
||||
|
||||
// Send SIGINT so the server takes the graceful shutdown path and
|
||||
@@ -736,8 +736,8 @@ fn client_receives_frame_after_pane_output() {
|
||||
// Connect and handshake.
|
||||
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
|
||||
let (version, error) =
|
||||
client_handshake(&mut stream, 11, 80, 24).expect("handshake should succeed");
|
||||
assert_eq!(version, 11);
|
||||
client_handshake(&mut stream, 12, 80, 24).expect("handshake should succeed");
|
||||
assert_eq!(version, 12);
|
||||
assert!(error.is_none(), "{:?}", error);
|
||||
|
||||
read_next_frame_payload(&mut stream, Duration::from_secs(10))
|
||||
@@ -783,8 +783,8 @@ fn navigate_mode_keybind_dispatch_in_server() {
|
||||
// Connect and handshake.
|
||||
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
|
||||
let (version, error) =
|
||||
client_handshake(&mut stream, 11, 80, 24).expect("handshake should succeed");
|
||||
assert_eq!(version, 11);
|
||||
client_handshake(&mut stream, 12, 80, 24).expect("handshake should succeed");
|
||||
assert_eq!(version, 12);
|
||||
assert!(error.is_none(), "{:?}", error);
|
||||
|
||||
// Drain initial frames.
|
||||
@@ -901,8 +901,8 @@ fn graceful_shutdown_sends_server_shutdown_to_client() {
|
||||
// Connect and handshake.
|
||||
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
|
||||
let (version, error) =
|
||||
client_handshake(&mut stream, 11, 80, 24).expect("handshake should succeed");
|
||||
assert_eq!(version, 11);
|
||||
client_handshake(&mut stream, 12, 80, 24).expect("handshake should succeed");
|
||||
assert_eq!(version, 12);
|
||||
assert!(error.is_none(), "{:?}", error);
|
||||
|
||||
// Drain initial frame(s).
|
||||
@@ -1000,8 +1000,8 @@ fn client_receives_notify_on_agent_state_change() {
|
||||
// Connect as a client and perform handshake.
|
||||
let mut stream = UnixStream::connect(&client_socket).expect("should connect");
|
||||
let (version, error) =
|
||||
client_handshake(&mut stream, 11, 80, 24).expect("handshake should succeed");
|
||||
assert_eq!(version, 11);
|
||||
client_handshake(&mut stream, 12, 80, 24).expect("handshake should succeed");
|
||||
assert_eq!(version, 12);
|
||||
assert!(error.is_none(), "{:?}", error);
|
||||
|
||||
// Drain initial frame(s).
|
||||
|
||||
+9
-8
@@ -428,6 +428,7 @@ fn client_handshake(stream: &mut UnixStream, version: u32, cols: u16, rows: u16)
|
||||
payload.extend_from_slice(&encode_varint_u32(16)); // cell_height_px
|
||||
payload.extend_from_slice(&encode_varint_u32(0)); // RenderEncoding::SemanticFrame
|
||||
payload.extend_from_slice(&encode_varint_u32(0)); // ClientKeybindings::Server
|
||||
payload.extend_from_slice(&encode_varint_u32(0)); // ClientLaunchMode::App
|
||||
|
||||
stream
|
||||
.write_all(&frame_message(&payload))
|
||||
@@ -686,7 +687,7 @@ fn cross_area_detach_and_reattach_preserves_state() {
|
||||
|
||||
// Local attach (client A).
|
||||
let mut client_a = UnixStream::connect(&client_socket).expect("client A should connect");
|
||||
client_handshake(&mut client_a, 11, 100, 30);
|
||||
client_handshake(&mut client_a, 12, 100, 30);
|
||||
assert!(wait_for_frame(&mut client_a, Duration::from_secs(2)));
|
||||
|
||||
// Use herdr: create a workspace and write output into its pane.
|
||||
@@ -723,7 +724,7 @@ fn cross_area_detach_and_reattach_preserves_state() {
|
||||
|
||||
// Reattach from another terminal/session (client B).
|
||||
let mut client_b = UnixStream::connect(&client_socket).expect("client B should connect");
|
||||
client_handshake(&mut client_b, 11, 80, 24);
|
||||
client_handshake(&mut client_b, 12, 80, 24);
|
||||
assert!(
|
||||
wait_for_frame(&mut client_b, Duration::from_secs(5)),
|
||||
"reattached client should receive frame"
|
||||
@@ -779,7 +780,7 @@ fn cross_area_agent_process_survives_detach_and_reattach() {
|
||||
wait_for_socket(&client_socket, Duration::from_secs(10));
|
||||
|
||||
let mut client_a = UnixStream::connect(&client_socket).expect("client A should connect");
|
||||
client_handshake(&mut client_a, 11, 100, 30);
|
||||
client_handshake(&mut client_a, 12, 100, 30);
|
||||
assert!(wait_for_frame(&mut client_a, Duration::from_secs(2)));
|
||||
|
||||
let created = workspace_create(&api_socket, "agent-persist");
|
||||
@@ -832,7 +833,7 @@ fn cross_area_agent_process_survives_detach_and_reattach() {
|
||||
|
||||
// Reattach and ensure client-side state reflects the persisted working status.
|
||||
let mut client_b = UnixStream::connect(&client_socket).expect("client B should connect");
|
||||
client_handshake(&mut client_b, 11, 80, 24);
|
||||
client_handshake(&mut client_b, 12, 80, 24);
|
||||
let saw_working_on_client =
|
||||
wait_for_frame_matching(&mut client_b, Duration::from_secs(5), |frame| {
|
||||
frame_contains_text(frame, "working")
|
||||
@@ -877,7 +878,7 @@ fn cross_area_client_and_api_workspace_views_are_consistent() {
|
||||
wait_for_socket(&client_socket, Duration::from_secs(10));
|
||||
|
||||
let mut client = UnixStream::connect(&client_socket).expect("client should connect");
|
||||
client_handshake(&mut client, 11, 100, 30);
|
||||
client_handshake(&mut client, 12, 100, 30);
|
||||
assert!(wait_for_frame(&mut client, Duration::from_secs(2)));
|
||||
drain_server_messages(&mut client, Duration::from_millis(300));
|
||||
|
||||
@@ -940,9 +941,9 @@ fn cross_area_two_clients_shared_view_and_single_detach_stability() {
|
||||
wait_for_socket(&client_socket, Duration::from_secs(10));
|
||||
|
||||
let mut client_a = UnixStream::connect(&client_socket).expect("client A should connect");
|
||||
client_handshake(&mut client_a, 11, 110, 30);
|
||||
client_handshake(&mut client_a, 12, 110, 30);
|
||||
let mut client_b = UnixStream::connect(&client_socket).expect("client B should connect");
|
||||
client_handshake(&mut client_b, 11, 100, 30);
|
||||
client_handshake(&mut client_b, 12, 100, 30);
|
||||
|
||||
assert!(wait_for_frame(&mut client_a, Duration::from_secs(2)));
|
||||
assert!(wait_for_frame(&mut client_b, Duration::from_secs(2)));
|
||||
@@ -1111,7 +1112,7 @@ fn cross_area_server_kill_then_restart_and_reconnect() {
|
||||
|
||||
let mut reconnect_client =
|
||||
UnixStream::connect(&client_socket).expect("new client should connect after restart");
|
||||
client_handshake(&mut reconnect_client, 11, 80, 24);
|
||||
client_handshake(&mut reconnect_client, 12, 80, 24);
|
||||
assert!(
|
||||
wait_for_frame(&mut reconnect_client, Duration::from_secs(5)),
|
||||
"new client should receive frame after restart"
|
||||
|
||||
+22
-22
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user