diff --git a/.github/workflows/pr-gate.yml b/.github/workflows/pr-gate.yml index d6ef7435b4..46a6c9e769 100644 --- a/.github/workflows/pr-gate.yml +++ b/.github/workflows/pr-gate.yml @@ -6,17 +6,22 @@ on: jobs: check-contributor: - # pull_request_target loads this workflow from the PR base ref. Skip the - # entire job on forks before actions/github-script resolves the maintainer - # token, which intentionally exists only in the canonical repository. - if: ${{ github.repository == 'ogulcancelik/herdr' }} runs-on: ubuntu-latest permissions: contents: read issues: write pull-requests: write steps: + # pull_request_target resolves this workflow from the repository's + # default branch. Non-canonical repository copies do not have the + # canonical maintainer token, so complete their local check without + # resolving the token-bearing action. + - name: Confirm canonical intake policy is not applicable to this repository + if: ${{ github.repository != 'ogulcancelik/herdr' }} + run: echo "PR Gate is canonical-repository-only; fork check passed." + - name: Check pull request intake policy + if: ${{ github.repository == 'ogulcancelik/herdr' }} uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 with: github-token: ${{ secrets.KANGAL_GITHUB_TOKEN }} diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md index 041a5c2a56..3ea65e2a0f 100644 --- a/docs/next/CHANGELOG.md +++ b/docs/next/CHANGELOG.md @@ -4,7 +4,7 @@ ### Changed - Agent status indicators now use the same static workspace marks across the sidebar, navigator, and mobile views, eliminating continuous spinner rendering while agents work. -- Desktop sessions now show a theme-derived, right-aligned native status row containing the focused folder and Git branch, device name, CPU, and memory. The left side stays blank; the row does not collect or display network details or usernames, and it does not show the Herdr version, battery, date, or time. Set `ui.status_bar.enabled = false` to hide the row and stop its background sampling. +- Desktop sessions now show a theme-derived, right-aligned native status row containing the focused folder and Git branch, device name, Herdr build version, CPU, and memory. The left side stays blank; the row does not collect or display session, workspace, tab, or pane identity, network details, usernames, battery, date, or time. Set `ui.status_bar.enabled = false` to hide the row and stop its background sampling. - Relicensed Herdr from AGPL-3.0-or-later to Apache-2.0. ### Fixed diff --git a/docs/next/website/src/content/docs/configuration.mdx b/docs/next/website/src/content/docs/configuration.mdx index e99cf7e24f..8ac001558c 100644 --- a/docs/next/website/src/content/docs/configuration.mdx +++ b/docs/next/website/src/content/docs/configuration.mdx @@ -258,9 +258,9 @@ The sidebar is the main Herdr dashboard. Search `ui.` in the [Config reference]( ### Status row -Desktop layouts show a right-aligned, full-width status row above the tab bar. Its compact information order is focused folder, Git branch, device name, CPU, then memory. The left side stays blank. Literal `CPU` and `MEM` segments stay visible at desktop widths, use percent and GiB, and show `CPU --%` or `MEM --/-- GiB` when a sample is unavailable. The row does not display or sample network details or usernames, and it does not show the Herdr version, battery, date, or time. As the terminal narrows, optional segments disappear in this order: folder, branch, then device name. +Desktop layouts show a right-aligned, full-width status row above the tab bar. Its compact information order is focused folder, Git branch, device name, Herdr build version, CPU, then memory. The left side stays blank. The build version plus the literal `CPU` and `MEM` segments stay visible at desktop widths; metrics use percent and GiB and show `CPU --%` or `MEM --/-- GiB` when a sample is unavailable. The row does not display session, workspace, tab, or pane identity or sample network details or usernames, and it does not show battery, date, or time. As the terminal narrows, optional segments disappear in this order: folder, branch, then device name. -The sampler starts immediately, refreshes every two seconds with at most one sample in flight, and retries failures at the next interval. Samples that reach four seconds old are discarded so unavailable values render instead. CPU and memory metrics come from native APIs or local proc files. +The sampler starts when a renderable status row first exists, refreshes every two seconds with at most one sample in flight, and retries failures at the next interval. Samples that reach four seconds old are discarded so unavailable values render instead. CPU and memory metrics come from native APIs or local proc files. ```toml [ui.status_bar] diff --git a/docs/next/website/src/content/docs/ja/configuration.mdx b/docs/next/website/src/content/docs/ja/configuration.mdx index ff33ef0599..56a401492b 100644 --- a/docs/next/website/src/content/docs/ja/configuration.mdx +++ b/docs/next/website/src/content/docs/ja/configuration.mdx @@ -254,9 +254,9 @@ yellow = "#f9e2af" ### ステータス行 -デスクトップレイアウトでは、タブバーの上に右寄せの全幅ステータス行が表示されます。コンパクトな情報順は、フォーカス中のフォルダー、Git ブランチ、デバイス名、CPU、メモリです。左側は空白のままです。デスクトップ幅では、`CPU` と `MEM` のラベル付きセグメントが表示され、単位にはパーセントと GiB が使われます。サンプルを利用できない場合は `CPU --%` または `MEM --/-- GiB` と表示されます。この行はネットワーク情報やユーザー名を表示もサンプリングもせず、Herdr バージョン、バッテリー、日付、時刻も表示しません。ターミナルが狭くなると、フォルダー、Git ブランチ、デバイス名の順に省略されます。 +デスクトップレイアウトでは、タブバーの上に右寄せの全幅ステータス行が表示されます。情報の順序は、フォーカス中のフォルダー、Git ブランチ、デバイス名、Herdr ビルドバージョン、CPU、メモリです。左側は空白のままです。デスクトップ幅では、ビルドバージョンと `CPU`、`MEM` の各セグメントが常に表示されます。メトリクスはパーセントと GiB を使い、サンプルを利用できない場合は `CPU --%` または `MEM --/-- GiB` と表示されます。セッション、ワークスペース、タブ、ペインの識別情報、ネットワーク情報、ユーザー名、バッテリー、日付、時刻は表示されません。ターミナルが狭くなると、フォルダー、ブランチ、デバイス名の順に省略されます。 -サンプラーは直ちに開始し、同時に実行するサンプルを 1 つまでに制限しながら 2 秒ごとに更新します。失敗した場合は次の間隔で再試行します。4 秒に達した古いサンプルは破棄され、利用不可の値が表示されます。CPU とメモリのメトリクスには、ネイティブ API またはローカルの proc ファイルだけが使われます。 +サンプラーは表示可能なステータス行が初めて存在したときに開始し、同時に実行するサンプルを 1 つまでに制限しながら 2 秒ごとに更新します。失敗した場合は次の間隔で再試行します。4 秒に達した古いサンプルは破棄され、利用不可の値が表示されます。CPU とメモリのメトリクスには、ネイティブ API またはローカルの proc ファイルだけが使われます。 ```toml [ui.status_bar] diff --git a/docs/next/website/src/content/docs/zh-cn/configuration.mdx b/docs/next/website/src/content/docs/zh-cn/configuration.mdx index 0650ab9447..378c406c38 100644 --- a/docs/next/website/src/content/docs/zh-cn/configuration.mdx +++ b/docs/next/website/src/content/docs/zh-cn/configuration.mdx @@ -254,9 +254,9 @@ yellow = "#f9e2af" ### 状态行 -桌面布局会在标签栏上方显示一条右对齐的全宽状态行。紧凑信息顺序为:当前聚焦文件夹、Git 分支、设备名称、CPU、内存。左侧保持空白。在桌面宽度下,带有 `CPU` 和 `MEM` 字样的片段会显示,分别使用百分比和 GiB 作为单位;样本不可用时会显示 `CPU --%` 或 `MEM --/-- GiB`。该行不会显示或采样网络信息或用户名,也不会显示 Herdr 版本、电池、日期或时间。随着终端变窄,可选片段会按以下顺序消失:文件夹、Git 分支、设备名称。 +桌面布局会在标签栏上方显示一条右对齐的全宽状态行。信息顺序是聚焦文件夹、Git 分支、设备名、Herdr 构建版本、CPU、内存;左侧始终留空。桌面宽度下,构建版本以及 `CPU` 和 `MEM` 片段始终可见。指标分别使用百分比和 GiB;样本不可用时显示 `CPU --%` 或 `MEM --/-- GiB`。状态行不显示会话、工作区、标签页或窗格标识、网络信息、用户名、电池、日期或时间。终端变窄时,可选片段按文件夹、分支、设备名的顺序消失。 -采样器会立即启动,每两秒刷新一次,同时最多只允许一个样本正在采集;失败后会在下一个间隔重试。达到四秒的旧样本会被丢弃并显示不可用值。CPU 和内存指标只来自原生 API 或本地 proc 文件。 +采样器会在首次出现可渲染的状态行时启动,每两秒刷新一次,同时最多只允许一个样本正在采集;失败后会在下一个间隔重试。达到四秒的旧样本会被丢弃并显示不可用值。CPU 和内存指标只来自原生 API 或本地 proc 文件。 ```toml [ui.status_bar] diff --git a/scripts/test_pr_gate_workflow.py b/scripts/test_pr_gate_workflow.py index 7303ea8d02..eff23bd84e 100644 --- a/scripts/test_pr_gate_workflow.py +++ b/scripts/test_pr_gate_workflow.py @@ -7,23 +7,44 @@ class PullRequestGateWorkflowTests(unittest.TestCase): - def test_forks_skip_job_before_maintainer_token_resolution(self) -> None: + def test_forks_pass_job_without_maintainer_token_resolution(self) -> None: workflow = WORKFLOW_PATH.read_text(encoding="utf-8") job_start = workflow.index(" check-contributor:\n") - guard = workflow.index( + runs_on = workflow.index(" runs-on:", job_start) + self.assertNotIn( " if: ${{ github.repository == 'ogulcancelik/herdr' }}\n", - job_start, + workflow[job_start:runs_on], ) - runs_on = workflow.index(" runs-on:", job_start) - steps = workflow.index(" steps:", job_start) - self.assertLess(job_start, guard) - self.assertLess(guard, runs_on) - self.assertLess(runs_on, steps) - self.assertIn( + no_op_step = workflow.index( + " - name: Confirm canonical intake policy is not applicable", + runs_on, + ) + no_op_condition = workflow.index( + " if: ${{ github.repository != 'ogulcancelik/herdr' }}\n", + no_op_step, + ) + policy_step = workflow.index( + " - name: Check pull request intake policy", + no_op_condition, + ) + policy_condition = workflow.index( + " if: ${{ github.repository == 'ogulcancelik/herdr' }}\n", + policy_step, + ) + token = workflow.index( "github-token: ${{ secrets.KANGAL_GITHUB_TOKEN }}", - workflow, + policy_condition, + ) + + self.assertLess(no_op_step, no_op_condition) + self.assertLess(no_op_condition, policy_step) + self.assertLess(policy_step, policy_condition) + self.assertLess(policy_condition, token) + self.assertIn( + 'run: echo "PR Gate is canonical-repository-only; fork check passed."', + workflow[no_op_condition:policy_step], ) diff --git a/src/app/api.rs b/src/app/api.rs index 2406c85347..112def03af 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -1459,7 +1459,7 @@ mod tests { assert_eq!(app.state.status_git_branch, None); assert!(app.git_identity_refresh_requested); assert_eq!(crate::terminal::TerminalRuntime::test_cwd_query_count(), 0); - let (_, _, _, cwd, branch) = crate::ui::focused_status_identity_for_test(&app.state); + let (cwd, branch) = crate::ui::focused_status_context_for_test(&app.state); assert_eq!(cwd, Some(std::path::PathBuf::from("/repo/nested"))); assert_eq!(branch, None); @@ -1474,7 +1474,7 @@ mod tests { assert_eq!(app.state.status_git_cwd, None); assert_eq!(app.state.status_git_branch, None); assert_eq!(crate::terminal::TerminalRuntime::test_cwd_query_count(), 0); - let (_, _, _, cwd, branch) = crate::ui::focused_status_identity_for_test(&app.state); + let (cwd, branch) = crate::ui::focused_status_context_for_test(&app.state); assert_eq!(cwd, None); assert_eq!(branch, None); } @@ -1523,7 +1523,7 @@ mod tests { assert!(!app.git_identity_refresh_requested); assert_eq!(crate::terminal::TerminalRuntime::test_cwd_query_count(), 0); app.state.status_bar_enabled = true; - let (_, _, _, cwd, branch) = crate::ui::focused_status_identity_for_test(&app.state); + let (cwd, branch) = crate::ui::focused_status_context_for_test(&app.state); assert_eq!(cwd, Some(std::path::PathBuf::from("/repo/nested"))); assert_eq!(branch, None); } diff --git a/src/app/mod.rs b/src/app/mod.rs index 9d45eb864a..79c4da7bcb 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -98,6 +98,7 @@ pub struct App { pub(crate) status_metric_sampler: Arc>, pub(crate) status_metric_refresh_enabled: bool, + pub(crate) status_metrics_visible: bool, pub(crate) terminal_runtimes: crate::terminal::TerminalRuntimeRegistry, pub event_tx: mpsc::Sender, pub(crate) event_rx: mpsc::Receiver, @@ -764,6 +765,7 @@ impl App { crate::platform::status_metrics::StatusMetricSampler::new(), )), status_metric_refresh_enabled: !cfg!(test), + status_metrics_visible: false, terminal_runtimes: restored_terminal_runtimes, event_tx, event_rx, @@ -1158,6 +1160,8 @@ impl App { frame, ); })?; + self.status_metrics_visible = + self.state.view.status_bar_rect != ratatui::layout::Rect::default(); if kitty_graphics_enabled { crate::kitty_graphics::paint_local_pane_graphics( &self.state, @@ -2941,7 +2945,7 @@ mod tests { assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied); assert!(app.state.status_bar_enabled); assert!(app.state.status_focus_projection_initialized); - let (_, _, _, cwd, branch) = crate::ui::focused_status_identity_for_test(&app.state); + let (cwd, branch) = crate::ui::focused_status_context_for_test(&app.state); assert_eq!(cwd, Some(std::path::PathBuf::from("/repo/nested"))); assert_eq!(branch, None); assert_eq!(crate::terminal::TerminalRuntime::test_cwd_query_count(), 0); @@ -2990,7 +2994,7 @@ mod tests { let report = app.reload_config(); assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied); - let (_, _, _, cwd, branch) = crate::ui::focused_status_identity_for_test(&app.state); + let (cwd, branch) = crate::ui::focused_status_context_for_test(&app.state); assert_eq!(cwd, Some(std::path::PathBuf::from("/repo"))); assert_eq!(branch, None); assert_eq!(app.state.status_git_cwd, None); @@ -5246,14 +5250,13 @@ last_pane = "prefix+tab" .terminal_id(active_pane) .expect("active terminal"); let expected_cwd = app.state.terminals[active_terminal].cwd.clone(); - let (_, _, _, stale_cwd, stale_branch) = - crate::ui::focused_status_identity_for_test(&app.state); + let (stale_cwd, stale_branch) = crate::ui::focused_status_context_for_test(&app.state); assert_eq!(stale_cwd, Some(std::path::PathBuf::from("/old-focus"))); assert_eq!(stale_branch.as_deref(), Some("old-branch")); crate::terminal::TerminalRuntime::test_reset_cwd_query_count(); assert!(app.sync_status_context_before_render()); - let (_, _, _, cwd, branch) = crate::ui::focused_status_identity_for_test(&app.state); + let (cwd, branch) = crate::ui::focused_status_context_for_test(&app.state); assert_eq!(cwd, Some(expected_cwd)); assert_eq!(branch, None); assert_eq!(crate::terminal::TerminalRuntime::test_cwd_query_count(), 0); @@ -5283,7 +5286,7 @@ last_pane = "prefix+tab" crate::terminal::TerminalRuntime::test_reset_cwd_query_count(); assert!(!app.sync_status_context_before_render()); - let (_, _, _, cwd, branch) = crate::ui::focused_status_identity_for_test(&app.state); + let (cwd, branch) = crate::ui::focused_status_context_for_test(&app.state); assert_eq!(cwd, Some(std::path::PathBuf::from("/runtime"))); assert_eq!(branch.as_deref(), Some("runtime-branch")); assert_eq!(crate::terminal::TerminalRuntime::test_cwd_query_count(), 0); @@ -5329,7 +5332,7 @@ last_pane = "prefix+tab" assert_eq!(app.status_context_focus, first_focus); crate::terminal::TerminalRuntime::test_reset_cwd_query_count(); assert!(app.sync_status_context_before_render()); - let (_, _, _, cwd, branch) = crate::ui::focused_status_identity_for_test(&app.state); + let (cwd, branch) = crate::ui::focused_status_context_for_test(&app.state); assert_eq!(cwd, Some(std::path::PathBuf::from("/second"))); assert_eq!(branch, None); assert_eq!(crate::terminal::TerminalRuntime::test_cwd_query_count(), 0); @@ -5370,7 +5373,7 @@ last_pane = "prefix+tab" crate::terminal::TerminalRuntime::test_reset_cwd_query_count(); assert!(!app.sync_status_context_before_render()); - let (_, _, _, cwd, branch) = crate::ui::focused_status_identity_for_test(&app.state); + let (cwd, branch) = crate::ui::focused_status_context_for_test(&app.state); assert_eq!(cwd, Some(std::path::PathBuf::from("/runtime"))); assert_eq!(branch.as_deref(), Some("runtime-branch")); assert_eq!(crate::terminal::TerminalRuntime::test_cwd_query_count(), 0); diff --git a/src/app/runtime.rs b/src/app/runtime.rs index 989ed3b0e1..fec5b0c55f 100644 --- a/src/app/runtime.rs +++ b/src/app/runtime.rs @@ -226,13 +226,12 @@ impl App { let mut changed = false; let mut resized = false; - changed |= self.schedule_status_metrics(now); - if now >= self.next_resize_poll { resized = self.handle_resize_poll(); changed |= resized; self.next_resize_poll = now + RESIZE_POLL_INTERVAL; } + changed |= self.schedule_status_metrics_after_resize_poll(now, resized); if self .config_diagnostic_deadline @@ -332,6 +331,15 @@ impl App { changed } + fn schedule_status_metrics_after_resize_poll(&mut self, now: Instant, resized: bool) -> bool { + if resized { + // The previous frame no longer proves that the status row is + // renderable. The next render restores visibility when appropriate. + self.status_metrics_visible = false; + } + self.schedule_status_metrics(now) + } + /// Drop a snapshot that outlived its staleness window so the status row /// shows unavailable metrics instead of silently aging values. pub(crate) fn discard_stale_status_metrics(&mut self, now: Instant) -> bool { @@ -349,6 +357,7 @@ impl App { pub(crate) fn schedule_status_metrics(&mut self, now: Instant) -> bool { let stale = self.discard_stale_status_metrics(now); if !self.status_metric_refresh_enabled + || !self.status_metrics_visible || !self.state.status_bar_enabled || !self.status_metric_refresh.begin(now) { @@ -593,11 +602,17 @@ impl App { self.status_metric_refresh.deadline().filter(|_| { include_client_refresh && self.status_metric_refresh_enabled + && self.status_metrics_visible && self.state.status_bar_enabled }), - self.state.status_metrics.as_ref().map(|snapshot| { - snapshot.sampled_at + crate::platform::status_metrics::STATUS_METRIC_STALE_AFTER - }), + self.status_metrics_visible + .then(|| { + self.state.status_metrics.as_ref().map(|snapshot| { + snapshot.sampled_at + + crate::platform::status_metrics::STATUS_METRIC_STALE_AFTER + }) + }) + .flatten(), include_client_refresh .then(|| self.git_refresh_deadline()) .flatten(), @@ -705,6 +720,25 @@ mod tests { ); } + #[test] + fn resize_invalidates_renderability_before_metric_sampling() { + 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.status_metrics_visible = true; + app.status_metric_refresh = + crate::platform::status_metrics::StatusMetricRefresh::immediate(now); + + assert!(!app.schedule_status_metrics_after_resize_poll(now, true)); + assert!(!app.status_metrics_visible); + assert!(!app.status_metric_refresh.in_flight()); + } + #[test] fn stale_status_metrics_are_dropped_without_sampling() { let mut app = super::super::App::new( diff --git a/src/server/headless.rs b/src/server/headless.rs index a35e66d620..a121a0df5e 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -1460,6 +1460,17 @@ impl HeadlessServer { self.app_client_count() > 0 } + fn has_renderable_status_target(&self) -> bool { + self.clients.values().any(|client| { + client.writer.is_some() + && client.is_full_app_client() + && crate::ui::status_bar_is_renderable( + &self.app.state, + Rect::new(0, 0, client.terminal_size.0, client.terminal_size.1), + ) + }) + } + fn remove_client(&mut self, client_id: u64) -> bool { let was_foreground = self.foreground_client_id == Some(client_id); self.app.clear_input_source(client_id); @@ -4040,7 +4051,11 @@ impl HeadlessServer { fn handle_scheduled_tasks_headless(&mut self, now: Instant, geometry_dirty: bool) -> bool { // Nothing renders the status row without an attached app client, so a // detached server never samples native metrics. - let mut changed = if self.has_app_client() { + // A client resize updates its announced geometry before that geometry + // has produced a frame. Wait for the pending render to finish so a + // narrow-to-wide resize cannot start collection for an unseen row. + self.app.status_metrics_visible = !geometry_dirty && self.has_renderable_status_target(); + let mut changed = if self.app.status_metrics_visible { self.app.schedule_status_metrics(now) } else { self.app.discard_stale_status_metrics(now) @@ -4776,15 +4791,15 @@ mod tests { server.app.route_client_input(vec![0x02, b'n']); assert_eq!(server.app.state.workspaces[0].active_tab, second_tab); - let (_, _, _, stale_cwd, stale_branch) = - crate::ui::focused_status_identity_for_test(&server.app.state); + let (stale_cwd, stale_branch) = + crate::ui::focused_status_context_for_test(&server.app.state); assert_eq!(stale_cwd, Some(std::path::PathBuf::from("/first"))); assert_eq!(stale_branch.as_deref(), Some("first-branch")); crate::terminal::TerminalRuntime::test_reset_cwd_query_count(); assert!(server.app.sync_status_context_before_render()); server.render_and_stream(); - let (_, _, _, cwd, branch) = crate::ui::focused_status_identity_for_test(&server.app.state); + let (cwd, branch) = crate::ui::focused_status_context_for_test(&server.app.state); assert_eq!(cwd, Some(std::path::PathBuf::from("/second"))); assert_eq!(branch, None); assert_eq!(crate::terminal::TerminalRuntime::test_cwd_query_count(), 0); @@ -5869,7 +5884,7 @@ next_tab = "" } #[test] - fn attached_app_client_starts_status_metric_sampling_immediately() { + fn attached_app_client_starts_status_metric_sampling_after_first_render() { let mut server = test_headless_server(); server.app.status_metric_refresh_enabled = true; let (writer, _control_rx, _render_rx) = test_client_writer(); @@ -5885,11 +5900,87 @@ next_tab = "" writer, })); + server.handle_scheduled_tasks_headless(Instant::now(), true); + assert!(!server.app.status_metric_refresh.in_flight()); + + server.render_and_stream(); server.handle_scheduled_tasks_headless(Instant::now(), false); assert!(server.app.status_metric_refresh.in_flight()); } + #[test] + fn narrow_to_wide_resize_waits_for_new_geometry_frame_before_sampling() { + let mut server = test_headless_server(); + server.app.status_metric_refresh_enabled = true; + let (writer, _control_rx, _render_rx) = test_client_writer(); + assert!(server.handle_server_event(ServerEvent::ClientConnected { + client_id: 7, + cols: 40, + rows: 24, + cell_width_px: 0, + cell_height_px: 0, + render_encoding: RenderEncoding::SemanticFrame, + keybindings: None, + direct_attach_requested: false, + writer, + })); + server.render_and_stream(); + server.handle_scheduled_tasks_headless(Instant::now(), false); + assert!(!server.app.status_metric_refresh.in_flight()); + + assert!(server.handle_server_event(ServerEvent::ClientResize { + client_id: 7, + cols: 120, + rows: 24, + cell_width_px: 0, + cell_height_px: 0, + })); + server.handle_scheduled_tasks_headless(Instant::now(), true); + assert!(!server.app.status_metric_refresh.in_flight()); + + server.render_and_stream(); + server.handle_scheduled_tasks_headless(Instant::now(), false); + assert!(server.app.status_metric_refresh.in_flight()); + } + + #[test] + fn status_sampling_tracks_renderable_clients() { + let mut server = test_headless_server(); + server.app.status_metric_refresh_enabled = true; + let (writer, _control_rx, _render_rx) = test_client_writer(); + assert!(server.handle_server_event(ServerEvent::ClientConnected { + client_id: 7, + cols: 120, + rows: 24, + cell_width_px: 0, + cell_height_px: 0, + render_encoding: RenderEncoding::SemanticFrame, + keybindings: None, + direct_attach_requested: false, + writer, + })); + + server.handle_scheduled_tasks_headless(Instant::now(), false); + + assert!(server.app.status_metrics_visible); + assert!(server.app.status_metric_refresh.in_flight()); + + server.app.status_metric_refresh.finish(); + server + .clients + .get_mut(&7) + .expect("attached client") + .terminal_size = (40, 24); + server.handle_scheduled_tasks_headless( + Instant::now() + crate::platform::status_metrics::STATUS_METRIC_REFRESH_INTERVAL, + false, + ); + + assert!(!server.app.status_metrics_visible); + assert!(!server.app.status_metric_refresh.in_flight()); + } + #[test] fn unchanged_git_refresh_does_not_request_headless_render() { let mut server = test_headless_server(); diff --git a/src/ui.rs b/src/ui.rs index 6c746b5a4c..99df01b252 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -56,7 +56,7 @@ use self::settings::render_settings_overlay; pub(crate) use self::sidebar::workspace_drop_indicator_row; use self::sidebar::{render_sidebar, render_sidebar_collapsed}; #[cfg(test)] -pub(crate) use self::status::focused_identity as focused_status_identity_for_test; +pub(crate) use self::status::focused_context as focused_status_context_for_test; use self::status::{ copy_feedback_rect, render_config_diagnostic, render_copy_feedback, render_status_bar, render_toast_notification, toast_notification_rect, @@ -212,7 +212,7 @@ fn compute_view_internal( resize_panes: bool, cell_size: crate::kitty_graphics::HostCellSize, ) { - if is_mobile_width(area, app.mobile_width_threshold) { + if uses_mobile_layout(app, area) { compute_mobile_view(app, terminal_runtimes, area, resize_panes, cell_size); return; } @@ -328,6 +328,16 @@ fn compute_view_internal( app.sync_copy_mode_search_geometry(); } +fn uses_mobile_layout(app: &AppState, area: Rect) -> bool { + is_mobile_width(area, app.mobile_width_threshold) + || (app.status_bar_enabled + && usize::from(area.width) < status::minimum_required_status_width(app)) +} + +pub(crate) fn status_bar_is_renderable(app: &AppState, area: Rect) -> bool { + app.status_bar_enabled && area.height > 1 && !uses_mobile_layout(app, area) +} + fn compute_mobile_view( app: &mut AppState, terminal_runtimes: &TerminalRuntimeRegistry, @@ -1067,7 +1077,7 @@ mod tests { app.mode = Mode::Terminal; app.sidebar_width = 26; - // Wide enough that context, identity, and metrics all fit. + // Wide enough that context, build identity, and metrics all fit. let width = 220u16; compute_view(&mut app, Rect::new(0, 0, width, 24)); assert_eq!(app.view.status_bar_rect, Rect::new(0, 0, width, 1)); @@ -1089,9 +1099,14 @@ mod tests { row0.contains("8.0") && row0.contains("12"), "status bar missing resource metrics: {row0:?}" ); - assert!(!row0.contains("Herdr v"), "{row0:?}"); + assert!( + row0.contains(&format!("Herdr v{}", crate::build_info::version())), + "status bar missing build identity: {row0:?}" + ); assert!(!row0.contains("testuser"), "{row0:?}"); - assert!(!row0.contains("session:"), "{row0:?}"); + for hidden_identity in ["session:", "workspace:", "tab:", "pane:"] { + assert!(!row0.contains(hidden_identity), "{row0:?}"); + } assert!(!row0.contains("↓"), "{row0:?}"); assert!(!row0.contains("↑"), "{row0:?}"); let first_content = row0 @@ -1123,9 +1138,12 @@ mod tests { app.view.status_bar_rect, app.view.status_bar_rect.y, ); - assert!(row0.contains("MEM 8.0/16.0 GiB"), "{row0:?}"); - assert!(row0.contains("CPU 12%"), "{row0:?}"); - assert!(!row0.contains("Herdr v"), "{row0:?}"); + assert!(row0.contains("MEM 8.0/ 16.0 GiB"), "{row0:?}"); + assert!(row0.contains("CPU 12%"), "{row0:?}"); + assert!( + row0.contains(&format!("Herdr v{}", crate::build_info::version())), + "{row0:?}" + ); } #[test] @@ -1155,9 +1173,12 @@ mod tests { app.view.status_bar_rect, app.view.status_bar_rect.y, ); - assert!(row0.contains("MEM 8.0/16.0 GiB"), "{row0:?}"); - assert!(row0.contains("CPU 12%"), "{row0:?}"); - assert!(!row0.contains("Herdr v"), "{row0:?}"); + assert!(row0.contains("MEM 8.0/ 16.0 GiB"), "{row0:?}"); + assert!(row0.contains("CPU 12%"), "{row0:?}"); + assert!( + row0.contains(&format!("Herdr v{}", crate::build_info::version())), + "{row0:?}" + ); rendered.push(( "sampled metrics", width, @@ -1178,8 +1199,12 @@ mod tests { app.view.status_bar_rect, app.view.status_bar_rect.y, ); - assert!(row0.contains("MEM --/-- GiB"), "{row0:?}"); - assert!(row0.contains("CPU --%"), "{row0:?}"); + assert!(row0.contains("MEM --/ -- GiB"), "{row0:?}"); + assert!(row0.contains("CPU --%"), "{row0:?}"); + assert!( + row0.contains(&format!("Herdr v{}", crate::build_info::version())), + "{row0:?}" + ); rendered.push(( "unavailable fallback", width, diff --git a/src/ui/status.rs b/src/ui/status.rs index 0ebc034aa0..2e87c77925 100644 --- a/src/ui/status.rs +++ b/src/ui/status.rs @@ -20,12 +20,12 @@ use crate::{ /// Full-width, right-aligned top status row. /// -/// Contents, left to right: folder · branch · device · CPU · memory. The row -/// before the first surviving segment is intentionally blank. +/// Contents, left to right: folder · branch · device · Herdr build version · +/// CPU · memory. The row before the first surviving segment is intentionally blank. /// /// Layout: spans the full client width above the sidebar and pads before the /// first surviving segment. On narrow widths, folder, branch, then device elide -/// in that order; CPU and memory remain required. +/// in that order; build version, CPU, and memory remain required. pub(crate) fn render_status_bar(app: &AppState, frame: &mut Frame, area: Rect) { if area.width == 0 || area.height == 0 { return; @@ -44,6 +44,9 @@ pub(crate) fn render_status_bar(app: &AppState, frame: &mut Frame, area: Rect) { .as_ref() .map(|snapshot| &snapshot.metrics) .unwrap_or(&unavailable); + if usize::from(area.width) < minimum_required_status_width(app) { + return; + } let segments = fitted_segments(status_segments(app, metrics, p), area.width as usize); let used = segment_width(&segments); @@ -92,24 +95,14 @@ fn fitted_segments(mut segments: Vec, width: usize) -> Vec { segments.remove(index); } - // Only pathological widths narrower than the required CPU/MEM pair need - // truncation after all optional context has been removed. - for index in 0..segments.len() { - let total_width = segment_width(&segments); - shrink_segment_for_overflow(&mut segments[index], total_width, width); - } - debug_assert!(segment_width(&segments) <= width); segments } -fn shrink_segment_for_overflow(segment: &mut Segment, total_width: usize, width: usize) { - let overflow = total_width.saturating_sub(width); - if overflow == 0 { - return; - } - let current_width = display_width(&segment.text); - segment.text = truncate_end(&segment.text, current_width.saturating_sub(overflow)); +pub(crate) fn minimum_required_status_width(_app: &AppState) -> usize { + 1 + display_width(&format!(" Herdr v{} ", crate::build_info::version())) + + display_width(" CPU 100% ") + + display_width(" MEM 9999.9/9999.9 GiB ") } fn status_segments( @@ -119,7 +112,7 @@ fn status_segments( ) -> Vec { let mut out = Vec::new(); - let (_, _, _, cwd, branch) = focused_identity(app); + let (cwd, branch) = focused_context(app); if let Some(cwd) = cwd { let display = shorten_path(&cwd, app.status_home_dir.as_deref(), 32); @@ -148,19 +141,35 @@ fn status_segments( elide_rank: Some(3), }); + out.push(Segment { + text: format!(" Herdr v{} ", crate::build_info::version()), + style: Style::default().fg(p.blue), + preserve_bg: false, + elide_rank: None, + }); + out.push(Segment { text: metrics .cpu_percent - .map(|cpu| format!(" CPU {cpu}% ")) - .unwrap_or_else(|| " CPU --% ".into()), + .filter(|cpu| *cpu <= 100) + .map(|cpu| format!(" CPU {cpu:>3}% ")) + .unwrap_or_else(|| " CPU --% ".into()), style: Style::default().fg(p.red), preserve_bg: false, elide_rank: None, }); let memory = match (metrics.mem_used_gib, metrics.mem_total_gib) { - (Some(used), Some(total)) => format!(" MEM {used:.1}/{total:.1} GiB "), - _ => " MEM --/-- GiB ".into(), + (Some(used), Some(total)) + if used.is_finite() + && total.is_finite() + && used >= 0.0 + && total >= used + && total <= 9_999.9 => + { + format!(" MEM {used:>6.1}/{total:>6.1} GiB ") + } + _ => " MEM --/ -- GiB ".into(), }; out.push(Segment { text: memory, @@ -171,28 +180,13 @@ fn status_segments( out } -pub(crate) fn focused_identity( - app: &AppState, -) -> (String, String, String, Option, Option) { +pub(crate) fn focused_context(app: &AppState) -> (Option, Option) { let Some(ws_idx) = app.active else { - return ("1".into(), "1".into(), "1".into(), None, None); + return (None, None); }; let Some(ws) = app.workspaces.get(ws_idx) else { - return ("1".into(), "1".into(), "1".into(), None, None); + return (None, None); }; - let ws_label = crate::workspace::public_workspace_number(&ws.id) - .unwrap_or(ws_idx + 1) - .to_string(); - let tab_idx = ws.active_tab_index(); - let tab_label = ws - .public_tab_number(tab_idx) - .unwrap_or(tab_idx + 1) - .to_string(); - let pane_label = ws - .focused_pane_id() - .and_then(|id| ws.public_pane_number(id)) - .map(|number| number.to_string()) - .unwrap_or_else(|| "1".into()); // Runtime-resolved focused cwd, projected by the same code path that // resolves the Git refresh target, so the cwd and branch segments always // describe the same directory. Falls back to workspace identity until the @@ -210,7 +204,7 @@ pub(crate) fn focused_identity( } else { None }; - (ws_label, tab_label, pane_label, cwd, branch) + (cwd, branch) } /// Both separators are accepted everywhere: Windows paths use `\`, and a @@ -612,29 +606,80 @@ mod tests { #[test] fn required_metrics_survive_optional_segment_elision() { - // CPU/MEM are required while optional right-side context elides by rank. + // Build version and CPU/MEM are required while other context elides by rank. let mut app = AppState::test_new(); app.status_focused_cwd = Some(PathBuf::from("/very/long/focused/folder")); app.status_git_cwd = app.status_focused_cwd.clone(); app.status_git_branch = Some("feature/very-long-branch".into()); let metrics = crate::platform::status_metrics::status_metrics_fixture(); - let segments = status_segments(&app, &metrics, &app.palette); - let required_width = segments - .iter() - .filter(|segment| segment.elide_rank.is_none()) - .map(|segment| display_width(&segment.text)) - .sum(); - let segments = fitted_segments(segments, required_width); + let width = minimum_required_status_width(&app); + let segments = fitted_segments(status_segments(&app, &metrics, &app.palette), width); let rendered = segments .iter() .map(|segment| segment.text.as_str()) .collect::(); - assert!(rendered.contains("MEM 8.0/16.0 GiB")); - assert!(rendered.contains("CPU 12%")); + assert!(rendered.contains("MEM 8.0/ 16.0 GiB")); + assert!(rendered.contains("CPU 12%")); + assert!(rendered.contains(&format!("Herdr v{}", crate::build_info::version()))); assert!(!rendered.contains("feature/very-long")); assert!(!rendered.contains("testhost")); } + #[test] + fn narrow_desktop_never_truncates_build_version() { + use ratatui::{backend::TestBackend, Terminal}; + + let mut app = AppState::test_new(); + app.mobile_width_threshold = 0; + let required = minimum_required_status_width(&app) as u16; + app.status_metrics = Some(crate::platform::status_metrics::StatusMetricsSnapshot { + metrics: StatusMetrics { + cpu_percent: Some(100), + mem_used_gib: Some(9_999.9), + mem_total_gib: Some(9_999.9), + hostname: "wide-metrics".into(), + }, + sampled_at: std::time::Instant::now(), + }); + assert_eq!( + minimum_required_status_width(&app) as u16, + required, + "live samples must not move the desktop/mobile breakpoint" + ); + crate::ui::compute_view_with_runtime_registry( + &mut app, + &crate::terminal::TerminalRuntimeRegistry::new(), + Rect::new(0, 0, required - 1, 5), + ); + assert_eq!(app.view.layout, crate::app::state::ViewLayout::Mobile); + + crate::ui::compute_view_with_runtime_registry( + &mut app, + &crate::terminal::TerminalRuntimeRegistry::new(), + Rect::new(0, 0, required, 5), + ); + assert_eq!(app.view.layout, crate::app::state::ViewLayout::Desktop); + let mut terminal = Terminal::new(TestBackend::new(required, 1)).unwrap(); + terminal + .draw(|frame| render_status_bar(&app, frame, Rect::new(0, 0, required, 1))) + .unwrap(); + let rendered = terminal + .backend() + .buffer() + .content() + .iter() + .map(|cell| cell.symbol()) + .collect::(); + assert!( + rendered.contains(&format!("Herdr v{}", crate::build_info::version())), + "{rendered}" + ); + assert!( + rendered.starts_with(' '), + "left side must stay blank: {rendered:?}" + ); + } + fn assert_long_context_fits_status_row(cwd: &str, branch: &str) { use ratatui::{backend::TestBackend, Terminal}; @@ -659,8 +704,12 @@ mod tests { .iter() .map(|cell| cell.symbol()) .collect::(); - assert!(rendered.contains("MEM 8.0/16.0 GiB"), "{rendered}"); - assert!(rendered.contains("CPU 12%"), "{rendered}"); + assert!(rendered.contains("MEM 8.0/ 16.0 GiB"), "{rendered}"); + assert!(rendered.contains("CPU 12%"), "{rendered}"); + assert!( + rendered.contains(&format!("Herdr v{}", crate::build_info::version())), + "{rendered}" + ); } #[test] @@ -701,7 +750,7 @@ mod tests { app.status_git_cwd = Some(PathBuf::from("/repo/nested")); app.status_git_branch = Some("nested-branch".into()); - let (_, _, _, cwd, branch) = focused_identity(&app); + let (cwd, branch) = focused_context(&app); assert_eq!(cwd, Some(PathBuf::from("/repo/nested"))); assert_eq!(branch.as_deref(), Some("nested-branch")); @@ -744,7 +793,7 @@ mod tests { assert!(app.focus_pane_in_workspace(0, nested)); assert!(app.sync_status_focused_cwd(&runtimes)); - let (_, _, _, cwd, branch) = focused_identity(&app); + let (cwd, branch) = focused_context(&app); assert_eq!(app.status_focused_cwd, Some(nested_cwd.clone())); assert_eq!(cwd, Some(nested_cwd)); @@ -761,7 +810,7 @@ mod tests { app.workspaces = vec![workspace]; app.active = Some(0); - let (_, _, _, cwd, branch) = focused_identity(&app); + let (cwd, branch) = focused_context(&app); assert_eq!(cwd, Some(PathBuf::from("/repo"))); assert_eq!(branch.as_deref(), Some("workspace-root")); @@ -776,8 +825,41 @@ mod tests { .iter() .map(|segment| segment.text.as_str()) .collect::(); - assert!(rendered.contains("MEM --/-- GiB")); - assert!(rendered.contains("CPU --%")); + assert!(rendered.contains("MEM --/ -- GiB")); + assert!(rendered.contains("CPU --%")); + } + + #[test] + fn status_metrics_outside_bounded_display_contract_use_fallbacks() { + let app = AppState::test_new(); + let baseline = status_segments( + &app, + &StatusMetrics { + cpu_percent: Some(12), + mem_used_gib: Some(8.0), + mem_total_gib: Some(16.0), + hostname: "testhost".into(), + }, + &app.palette, + ); + let metrics = StatusMetrics { + cpu_percent: Some(101), + mem_used_gib: Some(10_000.0), + mem_total_gib: Some(10_000.0), + hostname: "testhost".into(), + }; + let rendered = status_segments(&app, &metrics, &app.palette) + .iter() + .map(|segment| segment.text.as_str()) + .collect::(); + assert!(rendered.contains("CPU --%"), "{rendered}"); + assert!(rendered.contains("MEM --/ -- GiB"), "{rendered}"); + let fallback = status_segments(&app, &metrics, &app.palette); + assert_eq!( + segment_width(&baseline), + segment_width(&fallback), + "metric values and fallbacks must not shift or re-elide the row" + ); } #[test] @@ -839,8 +921,9 @@ mod tests { assert!(!rendered.contains("~/work/status"), "{rendered}"); assert!(!rendered.contains("feat/status"), "{rendered}"); assert!(!rendered.contains("testhost"), "{rendered}"); - assert!(rendered.contains("CPU 12%"), "{rendered}"); - assert!(rendered.contains("MEM 8.0/16.0 GiB"), "{rendered}"); + assert!(rendered.contains("Herdr v"), "{rendered}"); + assert!(rendered.contains("CPU 12%"), "{rendered}"); + assert!(rendered.contains("MEM 8.0/ 16.0 GiB"), "{rendered}"); } #[test] @@ -860,16 +943,19 @@ mod tests { let metrics = crate::platform::status_metrics::status_metrics_fixture(); let segments = status_segments(&app, &metrics, &app.palette); + assert_eq!(segments.len(), 6, "only the six visible contract fields"); let rendered = segments .iter() .map(|segment| segment.text.as_str()) .collect::(); + let version = format!("Herdr v{}", crate::build_info::version()); let ordered = [ "~/work/status", "feat/status", "testhost", - "CPU 12%", - "MEM 8.0/16.0 GiB", + version.as_str(), + "CPU 12%", + "MEM 8.0/ 16.0 GiB", ]; let mut previous = 0; for value in ordered { @@ -887,7 +973,9 @@ mod tests { "↓", "↑", "session:", - "Herdr v", + "workspace:", + "tab:", + "pane:", "88%", "2026-01-02", "03:04", @@ -921,6 +1009,15 @@ mod tests { .fg, Some(app.palette.green) ); + assert_eq!( + segments + .iter() + .find(|segment| segment.text.contains("Herdr v")) + .unwrap() + .style + .fg, + Some(app.palette.blue) + ); assert_eq!( segments .iter() diff --git a/src/ui/tab_surface.rs b/src/ui/tab_surface.rs index 196ceab667..e3c76f0a1c 100644 --- a/src/ui/tab_surface.rs +++ b/src/ui/tab_surface.rs @@ -340,10 +340,10 @@ mod tests { assert_eq!(frame.hyperlinks, vec![uri.to_owned()]); let status = frame_rect_text(&frame, app.view.status_bar_rect); assert!(status.starts_with(' '), "{status:?}"); - assert!(status.contains("CPU 12%"), "{status:?}"); - assert!(status.contains("MEM 8.0/16.0 GiB"), "{status:?}"); + assert!(status.contains("CPU 12%"), "{status:?}"); + assert!(status.contains("MEM 8.0/ 16.0 GiB"), "{status:?}"); assert!( - status.trim_end().ends_with("MEM 8.0/16.0 GiB"), + status.trim_end().ends_with("MEM 8.0/ 16.0 GiB"), "{status:?}" ); let sidebar = frame_rect_text(&frame, app.view.sidebar_rect); diff --git a/src/workspace/git/config.rs b/src/workspace/git/config.rs index 9783387caf..e66a7397c9 100644 --- a/src/workspace/git/config.rs +++ b/src/workspace/git/config.rs @@ -19,8 +19,7 @@ pub(super) fn read_branch_config_with_user_paths( branch: &str, user_config_paths: Vec, ) -> Option { - let worktree_config_enabled = - worktree_config_enabled(&info.git_common_dir.join("config"), info); + let worktree_config_enabled = worktree_config_enabled(&info.git_common_dir.join("config")); let config_paths = user_config_paths .into_iter() .chain(std::iter::once(info.git_common_dir.join("config"))) @@ -67,55 +66,41 @@ fn git_user_config_paths() -> Vec { paths } -fn worktree_config_enabled(path: &Path, info: &GitWorktreeInfo) -> bool { +pub(super) fn worktree_config_enabled(path: &Path) -> bool { + read_config_value(path, "extensions", "worktreeConfig").is_some_and(|value| { + matches!( + value.to_ascii_lowercase().as_str(), + "true" | "1" | "yes" | "on" + ) + }) +} + +pub(super) fn read_config_value(path: &Path, section: &str, key: &str) -> Option { let Ok(contents) = std::fs::read_to_string(path) else { - return false; + return None; }; - let mut section = ConfigSection::Other; - let mut enabled = false; + let mut in_section = false; + let mut value = None; 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(), - }, - ) - }; + in_section = section_name.eq_ignore_ascii_case(section); 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" - ); - } - _ => {} + if !in_section || line.is_empty() || line.starts_with('#') || line.starts_with(';') { + continue; + } + if let Some((name, raw_value)) = line.split_once('=') { + if name.trim().eq_ignore_ascii_case(key) { + value = Some(normalize_config_value(raw_value)); } continue; } - if matches!(section, ConfigSection::Extensions) - && line.eq_ignore_ascii_case("worktreeConfig") - { - enabled = true; + if line.eq_ignore_ascii_case(key) { + value = Some("true".to_string()); } } - enabled + value } fn collect_remote_urls( @@ -276,7 +261,6 @@ fn merge_git_config( enum ConfigSection { Branch, - Extensions, Include, IncludeIf(IncludeIfMode), Remote(String), diff --git a/src/workspace/git/discovery.rs b/src/workspace/git/discovery.rs index 3e867443cd..a04116d19e 100644 --- a/src/workspace/git/discovery.rs +++ b/src/workspace/git/discovery.rs @@ -14,6 +14,7 @@ pub struct GitWorktreeInfo { pub repo_root: PathBuf, pub git_dir: PathBuf, pub git_common_dir: PathBuf, + pub main_worktree_root: PathBuf, pub is_bare: bool, pub is_linked_worktree: bool, } @@ -45,11 +46,14 @@ pub fn git_worktree_info(cwd: &Path) -> Option { 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; let is_bare = git_dir_is_bare(&git_dir); + let main_worktree_root = + resolve_main_worktree_root(&repo_root, &git_common_dir, is_bare, is_linked_worktree); Some(GitWorktreeInfo { repo_root, git_dir, git_common_dir, + main_worktree_root, is_bare, is_linked_worktree, }) @@ -75,17 +79,8 @@ pub(super) fn git_space_metadata_from_info(info: &GitWorktreeInfo) -> GitSpaceMe let checkout_key = canonicalize_best_effort_path(&info.repo_root) .display() .to_string(); - let label_path = if info - .git_common_dir - .file_name() - .and_then(|name| name.to_str()) - == Some(".git") - { - info.git_common_dir.parent().unwrap_or(&info.repo_root) - } else { - &info.git_common_dir - }; - let repo_name = label_path + let repo_name = info + .main_worktree_root .file_name() .and_then(|name| name.to_str()) .unwrap_or("repo") @@ -99,6 +94,73 @@ pub(super) fn git_space_metadata_from_info(info: &GitWorktreeInfo) -> GitSpaceMe } } +fn resolve_main_worktree_root( + repo_root: &Path, + git_common_dir: &Path, + is_bare: bool, + is_linked_worktree: bool, +) -> PathBuf { + if !is_bare && !is_linked_worktree { + if git_common_dir.file_name().and_then(|name| name.to_str()) != Some(".git") { + return canonicalize_best_effort_path(repo_root); + } + return repo_root.to_path_buf(); + } + + if git_common_dir.file_name().and_then(|name| name.to_str()) == Some(".git") { + return git_common_dir.parent().unwrap_or(repo_root).to_path_buf(); + } + + if is_linked_worktree { + if let Some(main_worktree_root) = configured_main_worktree_root(git_common_dir) { + return main_worktree_root; + } + if let Some(main_worktree_root) = gitdir_main_worktree_root(git_common_dir) { + return main_worktree_root; + } + } + + git_common_dir.to_path_buf() +} + +fn configured_main_worktree_root(git_common_dir: &Path) -> Option { + let config = git_common_dir.join("config"); + let value = super::config::worktree_config_enabled(&config) + .then(|| { + super::config::read_config_value( + &git_common_dir.join("config.worktree"), + "core", + "worktree", + ) + }) + .flatten() + .or_else(|| super::config::read_config_value(&config, "core", "worktree"))?; + let path = Path::new(&value); + let resolved = if path.is_absolute() { + path.to_path_buf() + } else { + git_common_dir.join(path) + }; + Some(canonicalize_best_effort_path(&resolved)) +} + +fn gitdir_main_worktree_root(git_common_dir: &Path) -> Option { + let value = std::fs::read_to_string(git_common_dir.join("gitdir")).ok()?; + let path = Path::new( + value + .trim() + .strip_prefix("gitdir:") + .unwrap_or(value.trim()) + .trim(), + ); + let resolved = if path.is_absolute() { + path.to_path_buf() + } else { + git_common_dir.join(path) + }; + Some(canonicalize_best_effort_path(resolved.parent()?)) +} + pub(super) fn canonicalize_best_effort_path(path: &Path) -> PathBuf { std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()) } @@ -169,12 +231,12 @@ pub(super) fn git_rev_parse_verify(repo_root: &Path, revision: &str) -> Option bool { - read_git_config_value(&git_common_dir.join("config"), "extensions", "refstorage") + super::config::read_config_value(&git_common_dir.join("config"), "extensions", "refstorage") .is_some_and(|value| value.eq_ignore_ascii_case("reftable")) } fn git_dir_is_bare(git_dir: &Path) -> bool { - read_git_config_value(&git_dir.join("config"), "core", "bare") + super::config::read_config_value(&git_dir.join("config"), "core", "bare") .is_some_and(|value| value.eq_ignore_ascii_case("true")) } @@ -183,48 +245,6 @@ fn parse_git_head_branch(head: &str) -> Option { (!branch.is_empty()).then(|| branch.to_string()) } -fn read_git_config_value(path: &Path, section: &str, key: &str) -> Option { - let contents = std::fs::read_to_string(path).ok()?; - let mut in_section = false; - for raw_line in contents.lines() { - let line = raw_line.trim(); - if line.is_empty() || line.starts_with('#') || line.starts_with(';') { - continue; - } - if let Some(section_name) = simple_git_config_section(line) { - in_section = section_name.eq_ignore_ascii_case(section); - continue; - } - if !in_section { - continue; - } - let Some((name, value)) = line.split_once('=') else { - continue; - }; - if name.trim().eq_ignore_ascii_case(key) { - return Some(strip_git_config_comment(value).trim().to_string()); - } - } - None -} - -fn simple_git_config_section(line: &str) -> Option<&str> { - let section = line.strip_prefix('[')?.split_once(']')?.0.trim(); - (!section.contains('"')).then_some(section) -} - -fn strip_git_config_comment(value: &str) -> &str { - let value = value.trim(); - for marker in ['#', ';'] { - if let Some((prefix, _)) = value.split_once(marker) { - if prefix.chars().next_back().is_some_and(char::is_whitespace) { - return prefix; - } - } - } - value -} - fn git_trimmed_stdout(repo_root: &Path, args: &[&str]) -> Option { let output = crate::noninteractive_process::command("git") .arg("-C") @@ -431,6 +451,96 @@ mod tests { std::fs::remove_dir_all(base).unwrap(); } + #[test] + fn separate_git_dir_checkouts_share_main_checkout_repo_name() { + let base = temp_test_dir("separate-git-dir"); + let checkout = base.join("My #repo"); + let linked_checkout = base.join("feature"); + let metadata = base.join("meta"); + let separate_git_dir = format!("--separate-git-dir={}", metadata.display()); + run_git( + &base, + &[ + "init", + "--quiet", + separate_git_dir.as_str(), + checkout.to_str().unwrap(), + ], + ); + run_git( + &checkout, + &["config", "user.email", "herdr@example.invalid"], + ); + run_git(&checkout, &["config", "user.name", "Herdr Test"]); + run_git( + &checkout, + &["config", "--add", "extensions.worktreeConfig", "false"], + ); + run_git( + &checkout, + &["config", "--add", "extensions.worktreeConfig", "yes"], + ); + run_git( + &checkout, + &[ + "config", + "--worktree", + "core.worktree", + checkout.to_str().unwrap(), + ], + ); + run_git( + &checkout, + &["commit", "--quiet", "--allow-empty", "-m", "initial"], + ); + run_git( + &checkout, + &[ + "worktree", + "add", + "--quiet", + "-b", + "feature", + linked_checkout.to_str().unwrap(), + "HEAD", + ], + ); + std::fs::write( + metadata.join("config.worktree"), + format!( + "[core]\n\tworktree = \"../wrong\"\n\tworktree = \"../{}\"\n", + checkout.file_name().unwrap().to_string_lossy() + ), + ) + .unwrap(); + + let linked_info = git_worktree_info(&linked_checkout).unwrap(); + let linked_space = git_space_metadata_from_info(&linked_info); + let primary_info = git_worktree_info(&checkout).unwrap(); + let primary_space = git_space_metadata_from_info(&primary_info); + + assert!(!primary_info.is_bare); + assert!(!primary_info.is_linked_worktree); + assert!(linked_info.is_linked_worktree); + assert_eq!( + primary_info.git_common_dir, + canonicalize_best_effort_path(&metadata) + ); + assert_eq!( + primary_info.main_worktree_root, + canonicalize_best_effort_path(&checkout) + ); + assert_eq!( + linked_info.main_worktree_root, + primary_info.main_worktree_root + ); + assert_eq!(primary_space.key, linked_space.key); + assert_eq!(primary_space.repo_name, "My #repo"); + assert_eq!(linked_space.repo_name, primary_space.repo_name); + + std::fs::remove_dir_all(base).unwrap(); + } + #[test] fn git_space_metadata_marks_bare_dot_git_repo() { let root = temp_test_dir("bare-dot-git"); diff --git a/tests/live_handoff.rs b/tests/live_handoff.rs index 832079e8db..d92174b875 100644 --- a/tests/live_handoff.rs +++ b/tests/live_handoff.rs @@ -1229,7 +1229,7 @@ fn live_handoff_keeps_unmanaged_agent_name_bound_to_saved_session() { fs::write( &fake_pi, format!( - "#!/bin/sh\nexport HERDR_AGENT=pi\necho started > {}\nexec /bin/sleep 30\n", + "#!/bin/sh\nexport HERDR_AGENT=pi\necho started > {}\nexec python3 -c 'import time; time.sleep(30)'\n", started_marker.display() ), )