From 550e9f8cc085f428429f5cf2da0bb2dba897e8b8 Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Thu, 16 Jul 2026 23:13:06 +0300 Subject: [PATCH] fix: follow foreground process group leader cwd refs #1472 --- docs/next/CHANGELOG.md | 1 + src/pane.rs | 26 ++++++++ src/terminal/runtime.rs | 4 ++ src/workspace/tab.rs | 11 +++- tests/api_ping.rs | 138 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 178 insertions(+), 2 deletions(-) diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md index bee2cf63..7a2f484a 100644 --- a/docs/next/CHANGELOG.md +++ b/docs/next/CHANGELOG.md @@ -7,6 +7,7 @@ - Added macOS support for the `HERDR_AGENT=` foreground-process hint, allowing agents hidden behind host-visible wrappers such as `nono` to use the named agent's screen manifest. (#679) ### Fixed +- New panes, tabs, layouts, and workspaces using `new_cwd = "follow"` now inherit the foreground process-group leader's working directory instead of an unrelated helper process directory. (#1472) - Background update checks, captured plugin commands, and workspace Git probes no longer flash console windows on Windows. (#1468) - Live handoff now preserves installed plugins and no longer lets the next plugin installation overwrite the existing registry. (#893) - `herdr wait agent-status` now returns `pane_not_found` promptly when its target pane closes instead of waiting for the full timeout. (#1439) diff --git a/src/pane.rs b/src/pane.rs index 5bb6fe4e..3c817876 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -2713,6 +2713,22 @@ impl PaneRuntime { (pid > 0).then_some(pid) } + pub fn follow_cwd(&self) -> Option { + #[cfg(unix)] + { + let leader_cwd = self + .io + .foreground_process_group_id() + .and_then(usable_process_cwd); + leader_cwd.or_else(|| self.cwd()) + } + + #[cfg(not(unix))] + { + self.cwd() + } + } + /// Get the current working directory of the process group controlling the pane PTY. pub fn foreground_cwd(&self) -> Option { #[cfg(unix)] @@ -2816,6 +2832,16 @@ impl PaneRuntime { mod tests { use super::*; + #[cfg(unix)] + #[tokio::test] + async fn follow_cwd_falls_back_to_reported_pane_cwd_without_foreground_group() { + let (runtime, _rx) = PaneRuntime::test_with_channel(80, 24); + let cwd = std::env::temp_dir(); + *runtime.reported_cwd.lock().unwrap() = Some(cwd.clone()); + + assert_eq!(runtime.follow_cwd(), Some(cwd)); + } + #[test] fn shutdown_liveness_treats_reaped_direct_child_as_gone() { assert!(!process_alive_for_shutdown(42, 42, true, |_| true)); diff --git a/src/terminal/runtime.rs b/src/terminal/runtime.rs index 1d19ad4a..0241f984 100644 --- a/src/terminal/runtime.rs +++ b/src/terminal/runtime.rs @@ -453,6 +453,10 @@ impl TerminalRuntime { self.0.cwd() } + pub fn follow_cwd(&self) -> Option { + self.0.follow_cwd() + } + pub fn foreground_cwd(&self) -> Option { self.0.foreground_cwd() } diff --git a/src/workspace/tab.rs b/src/workspace/tab.rs index 15ca7e45..4c86a6e8 100644 --- a/src/workspace/tab.rs +++ b/src/workspace/tab.rs @@ -578,7 +578,14 @@ impl Tab { terminals: &HashMap, terminal_runtimes: &TerminalRuntimeRegistry, ) -> Option { - self.foreground_cwd_for_pane(pane_id, terminal_runtimes) - .or_else(|| self.cwd_for_pane(pane_id, terminals, terminal_runtimes)) + let terminal_id = self.terminal_id(pane_id)?; + terminal_runtimes + .get(terminal_id) + .and_then(|runtime| runtime.follow_cwd()) + .or_else(|| { + terminals + .get(terminal_id) + .map(|terminal| terminal.cwd.clone()) + }) } } diff --git a/tests/api_ping.rs b/tests/api_ping.rs index d2033814..a52e71c3 100644 --- a/tests/api_ping.rs +++ b/tests/api_ping.rs @@ -895,6 +895,144 @@ fn pane_info_reports_foreground_cwd_without_changing_pane_cwd() { cleanup_spawned_herdr(child, base); } +#[cfg(target_os = "linux")] +#[test] +fn new_terminal_cwd_follow_ignores_nonleader_group_member_cwd() { + let _lock = test_lock(); + let base = unique_test_dir(); + let helper_cwd = base.join("plugin-cache"); + let helper_marker = base.join("helper-cwd-ready"); + let marker = base.join("helper-ready"); + let leader_pid_file = base.join("leader.pid"); + let helper_pid_file = base.join("helper.pid"); + fs::create_dir_all(&helper_cwd).unwrap(); + let config_home = base.join("config"); + let runtime_dir = base.join("runtime"); + let socket_path = runtime_dir.join("herdr.sock"); + + let child = spawn_herdr_with_shell(&config_home, &runtime_dir, &socket_path, "/bin/bash"); + wait_for_socket(&socket_path, Duration::from_secs(5)); + + let created = send_request( + &socket_path, + &format!( + r#"{{"id":"member_ws","method":"workspace.create","params":{{"cwd":"{}","focus":true}}}}"#, + base.display() + ), + ); + let pane_id = created["result"]["root_pane"]["pane_id"] + .as_str() + .unwrap() + .to_string(); + let workspace_id = created["result"]["workspace"]["workspace_id"] + .as_str() + .unwrap() + .to_string(); + let command = format!( + "/bin/sh -c 'printf %s $$ > {}; (cd {} && touch {} && sleep 30) & printf %s $! > {}; while [ ! -e {} ]; do sleep 0.01; done; touch {}; wait'", + leader_pid_file.display(), + helper_cwd.display(), + helper_marker.display(), + helper_pid_file.display(), + helper_marker.display(), + marker.display() + ); + let send_text = send_request( + &socket_path, + &serde_json::json!({ + "id": "member_send", + "method": "pane.send_text", + "params": { + "pane_id": pane_id, + "text": command, + }, + }) + .to_string(), + ); + assert_eq!(send_text["result"]["type"], "ok"); + let send_enter = send_request( + &socket_path, + &format!( + r#"{{"id":"member_enter","method":"pane.send_keys","params":{{"pane_id":"{}","keys":["Enter"]}}}}"#, + pane_id + ), + ); + assert_eq!(send_enter["result"]["type"], "ok"); + wait_for_path(&marker, Duration::from_secs(5)); + + let leader_pid: u32 = fs::read_to_string(&leader_pid_file) + .unwrap() + .parse() + .unwrap(); + let helper_pid: u32 = fs::read_to_string(&helper_pid_file) + .unwrap() + .parse() + .unwrap(); + assert_eq!( + unsafe { libc::getpgid(leader_pid as libc::pid_t) }, + leader_pid as libc::pid_t + ); + assert_eq!( + unsafe { libc::getpgid(helper_pid as libc::pid_t) }, + leader_pid as libc::pid_t + ); + assert_eq!( + fs::read_link(format!("/proc/{leader_pid}/cwd")).unwrap(), + base + ); + assert_eq!( + fs::read_link(format!("/proc/{helper_pid}/cwd")).unwrap(), + helper_cwd + ); + + let pane = send_request( + &socket_path, + &format!( + r#"{{"id":"member_pane","method":"pane.get","params":{{"pane_id":"{}"}}}}"#, + pane_id + ), + ); + assert_eq!(pane["result"]["pane"]["cwd"], base.display().to_string()); + assert_eq!( + pane["result"]["pane"]["foreground_cwd"], + helper_cwd.display().to_string() + ); + + let split = send_request( + &socket_path, + &serde_json::json!({ + "id": "member_split", + "method": "pane.split", + "params": { + "target_pane_id": pane_id, + "direction": "right", + "focus": false, + }, + }) + .to_string(), + ); + assert_eq!(split["result"]["pane"]["cwd"], base.display().to_string()); + + let tab = send_request( + &socket_path, + &serde_json::json!({ + "id": "member_tab", + "method": "tab.create", + "params": { + "workspace_id": workspace_id, + "focus": false, + }, + }) + .to_string(), + ); + assert_eq!( + tab["result"]["root_pane"]["cwd"], + base.display().to_string() + ); + + cleanup_spawned_herdr(child, base); +} + #[cfg(not(target_os = "macos"))] #[test] fn agent_start_creates_named_terminal_over_socket() {