From bdd376dfc94754eeb327c245b3792035ab7cdb39 Mon Sep 17 00:00:00 2001 From: pash Date: Wed, 28 Jan 2026 23:53:58 -0800 Subject: [PATCH] add workspace git context to session prefix --- codex-rs/core/src/codex.rs | 40 ++++++++ codex-rs/core/src/environment_context.rs | 116 ++++++++++++++++++++--- codex-rs/core/src/git_info.rs | 35 +++++++ 3 files changed, 176 insertions(+), 15 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 5a1ce90dd9..ec151265df 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -1,6 +1,8 @@ +use std::collections::BTreeMap; use std::collections::HashMap; use std::collections::HashSet; use std::fmt::Debug; +use std::path::Path; use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::AtomicBool; @@ -110,6 +112,8 @@ use crate::config::types::McpServerConfig; use crate::config::types::ShellEnvironmentPolicy; use crate::context_manager::ContextManager; use crate::environment_context::EnvironmentContext; +use crate::environment_context::WorkspaceConfiguration; +use crate::environment_context::WorkspaceEntry; use crate::error::CodexErr; use crate::error::Result as CodexResult; #[cfg(test)] @@ -1867,6 +1871,34 @@ impl Session { &self, turn_context: &TurnContext, ) -> Vec { + fn build_workspace_configuration( + cwd: &Path, + git_info: Option<&codex_protocol::protocol::GitInfo>, + remote_urls: Option<&Vec>, + ) -> Option { + let latest_git_commit_hash = git_info.and_then(|info| info.commit_hash.clone()); + let associated_remote_urls = remote_urls.cloned(); + if latest_git_commit_hash.is_none() && associated_remote_urls.is_none() { + return None; + } + + let hint = cwd.file_name().and_then(|name| name.to_str()).map_or_else( + || cwd.to_string_lossy().into_owned(), + |name| name.to_string(), + ); + + let mut workspaces = BTreeMap::new(); + workspaces.insert( + cwd.to_string_lossy().into_owned(), + WorkspaceEntry { + hint, + associated_remote_urls, + latest_git_commit_hash, + }, + ); + Some(WorkspaceConfiguration { workspaces }) + } + let mut items = Vec::::with_capacity(4); let shell = self.user_shell(); items.push( @@ -1919,9 +1951,17 @@ impl Session { .into(), ); } + let git_info = crate::git_info::collect_git_info(turn_context.cwd.as_path()).await; + let remote_urls = crate::git_info::get_git_remote_urls(turn_context.cwd.as_path()).await; + let workspace_configuration = build_workspace_configuration( + turn_context.cwd.as_path(), + git_info.as_ref(), + remote_urls.as_ref(), + ); items.push(ResponseItem::from(EnvironmentContext::new( Some(turn_context.cwd.clone()), shell.as_ref().clone(), + workspace_configuration, ))); items } diff --git a/codex-rs/core/src/environment_context.rs b/codex-rs/core/src/environment_context.rs index f0b0877eba..482cec4b97 100644 --- a/codex-rs/core/src/environment_context.rs +++ b/codex-rs/core/src/environment_context.rs @@ -6,6 +6,7 @@ use codex_protocol::protocol::ENVIRONMENT_CONTEXT_CLOSE_TAG; use codex_protocol::protocol::ENVIRONMENT_CONTEXT_OPEN_TAG; use serde::Deserialize; use serde::Serialize; +use std::collections::BTreeMap; use std::path::PathBuf; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] @@ -13,11 +14,22 @@ use std::path::PathBuf; pub(crate) struct EnvironmentContext { pub cwd: Option, pub shell: Shell, + /// Workspace metadata, structured to support future multi-root workspaces. + #[serde(skip_serializing_if = "Option::is_none")] + pub workspace_configuration: Option, } impl EnvironmentContext { - pub fn new(cwd: Option, shell: Shell) -> Self { - Self { cwd, shell } + pub fn new( + cwd: Option, + shell: Shell, + workspace_configuration: Option, + ) -> Self { + Self { + cwd, + shell, + workspace_configuration, + } } /// Compares two environment contexts, ignoring the shell. Useful when @@ -28,6 +40,7 @@ impl EnvironmentContext { cwd, // should compare all fields except shell shell: _, + .. } = other; self.cwd == *cwd @@ -39,14 +52,31 @@ impl EnvironmentContext { } else { None }; - EnvironmentContext::new(cwd, shell.clone()) + // Only include workspace configuration on the initial prefix message. + EnvironmentContext::new(cwd, shell.clone(), None) } pub fn from_turn_context(turn_context: &TurnContext, shell: &Shell) -> Self { - Self::new(Some(turn_context.cwd.clone()), shell.clone()) + // Only include workspace configuration on the initial prefix message. + Self::new(Some(turn_context.cwd.clone()), shell.clone(), None) } } +/// Multi-root-friendly workspace metadata modeled after Cline's structure. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub(crate) struct WorkspaceConfiguration { + pub workspaces: BTreeMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub(crate) struct WorkspaceEntry { + pub hint: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub associated_remote_urls: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub latest_git_commit_hash: Option, +} + impl EnvironmentContext { /// Serializes the environment context to XML. Libraries like `quick-xml` /// require custom macros to handle Enums with newtypes, so we just do it @@ -66,6 +96,17 @@ impl EnvironmentContext { let shell_name = self.shell.name(); lines.push(format!(" {shell_name}")); + + if let Some(workspace_configuration) = self.workspace_configuration + && let Ok(json) = serde_json::to_string_pretty(&workspace_configuration) + { + lines.push(" ".to_string()); + for line in json.lines() { + lines.push(format!(" {line}")); + } + lines.push(" ".to_string()); + } + lines.push(ENVIRONMENT_CONTEXT_CLOSE_TAG.to_string()); lines.join("\n") } @@ -91,6 +132,7 @@ mod tests { use super::*; use core_test_support::test_path_buf; use pretty_assertions::assert_eq; + use std::collections::BTreeMap; fn fake_shell() -> Shell { Shell { @@ -103,7 +145,7 @@ mod tests { #[test] fn serialize_workspace_write_environment_context() { let cwd = test_path_buf("/repo"); - let context = EnvironmentContext::new(Some(cwd.clone()), fake_shell()); + let context = EnvironmentContext::new(Some(cwd.clone()), fake_shell(), None); let expected = format!( r#" @@ -118,7 +160,7 @@ mod tests { #[test] fn serialize_read_only_environment_context() { - let context = EnvironmentContext::new(None, fake_shell()); + let context = EnvironmentContext::new(None, fake_shell(), None); let expected = r#" bash @@ -129,7 +171,7 @@ mod tests { #[test] fn serialize_external_sandbox_environment_context() { - let context = EnvironmentContext::new(None, fake_shell()); + let context = EnvironmentContext::new(None, fake_shell(), None); let expected = r#" bash @@ -140,7 +182,7 @@ mod tests { #[test] fn serialize_external_sandbox_with_restricted_network_environment_context() { - let context = EnvironmentContext::new(None, fake_shell()); + let context = EnvironmentContext::new(None, fake_shell(), None); let expected = r#" bash @@ -151,7 +193,7 @@ mod tests { #[test] fn serialize_full_access_environment_context() { - let context = EnvironmentContext::new(None, fake_shell()); + let context = EnvironmentContext::new(None, fake_shell(), None); let expected = r#" bash @@ -162,23 +204,23 @@ mod tests { #[test] fn equals_except_shell_compares_cwd() { - let context1 = EnvironmentContext::new(Some(PathBuf::from("/repo")), fake_shell()); - let context2 = EnvironmentContext::new(Some(PathBuf::from("/repo")), fake_shell()); + let context1 = EnvironmentContext::new(Some(PathBuf::from("/repo")), fake_shell(), None); + let context2 = EnvironmentContext::new(Some(PathBuf::from("/repo")), fake_shell(), None); assert!(context1.equals_except_shell(&context2)); } #[test] fn equals_except_shell_ignores_sandbox_policy() { - let context1 = EnvironmentContext::new(Some(PathBuf::from("/repo")), fake_shell()); - let context2 = EnvironmentContext::new(Some(PathBuf::from("/repo")), fake_shell()); + let context1 = EnvironmentContext::new(Some(PathBuf::from("/repo")), fake_shell(), None); + let context2 = EnvironmentContext::new(Some(PathBuf::from("/repo")), fake_shell(), None); assert!(context1.equals_except_shell(&context2)); } #[test] fn equals_except_shell_compares_cwd_differences() { - let context1 = EnvironmentContext::new(Some(PathBuf::from("/repo1")), fake_shell()); - let context2 = EnvironmentContext::new(Some(PathBuf::from("/repo2")), fake_shell()); + let context1 = EnvironmentContext::new(Some(PathBuf::from("/repo1")), fake_shell(), None); + let context2 = EnvironmentContext::new(Some(PathBuf::from("/repo2")), fake_shell(), None); assert!(!context1.equals_except_shell(&context2)); } @@ -192,6 +234,7 @@ mod tests { shell_path: "/bin/bash".into(), shell_snapshot: crate::shell::empty_shell_snapshot_receiver(), }, + None, ); let context2 = EnvironmentContext::new( Some(PathBuf::from("/repo")), @@ -200,8 +243,51 @@ mod tests { shell_path: "/bin/zsh".into(), shell_snapshot: crate::shell::empty_shell_snapshot_receiver(), }, + None, ); assert!(context1.equals_except_shell(&context2)); } + + #[test] + fn serialize_environment_context_with_workspace_configuration() { + let cwd = test_path_buf("/repo"); + let mut workspaces = BTreeMap::new(); + workspaces.insert( + cwd.to_string_lossy().to_string(), + WorkspaceEntry { + hint: "repo".to_string(), + associated_remote_urls: Some(vec![ + "origin: https://example.com/repo.git".to_string(), + ]), + latest_git_commit_hash: Some("abc123".to_string()), + }, + ); + let workspace_configuration = WorkspaceConfiguration { workspaces }; + let context = EnvironmentContext::new( + Some(cwd.clone()), + fake_shell(), + Some(workspace_configuration), + ); + + let expected = r#" + /repo + bash + + { + "workspaces": { + "/repo": { + "hint": "repo", + "associated_remote_urls": [ + "origin: https://example.com/repo.git" + ], + "latest_git_commit_hash": "abc123" + } + } + } + +"#; + + assert_eq!(context.serialize_to_xml(), expected); + } } diff --git a/codex-rs/core/src/git_info.rs b/codex-rs/core/src/git_info.rs index 065f1280a4..a363bb4e7b 100644 --- a/codex-rs/core/src/git_info.rs +++ b/codex-rs/core/src/git_info.rs @@ -1,3 +1,4 @@ +use std::collections::BTreeSet; use std::collections::HashSet; use std::path::Path; use std::path::PathBuf; @@ -109,6 +110,40 @@ pub async fn collect_git_info(cwd: &Path) -> Option { Some(git_info) } +/// Collect fetch remotes in a multi-root-friendly format: ["origin: https://..."]. +pub async fn get_git_remote_urls(cwd: &Path) -> Option> { + let is_git_repo = run_git_command_with_timeout(&["rev-parse", "--git-dir"], cwd) + .await? + .status + .success(); + if !is_git_repo { + return None; + } + + let output = run_git_command_with_timeout(&["remote", "-v"], cwd).await?; + if !output.status.success() { + return None; + } + + let stdout = String::from_utf8(output.stdout).ok()?; + let mut remotes = BTreeSet::new(); + for line in stdout.lines() { + if !line.contains("(fetch)") { + continue; + } + let mut parts = line.split_whitespace(); + if let (Some(name), Some(url)) = (parts.next(), parts.next()) { + remotes.insert(format!("{name}: {url}")); + } + } + + if remotes.is_empty() { + None + } else { + Some(remotes.into_iter().collect()) + } +} + /// A minimal commit summary entry used for pickers (subject + timestamp + sha). #[derive(Clone, Debug, Serialize, Deserialize)] pub struct CommitLogEntry {