From 15cd23845c40f803b082eada753096cee3357150 Mon Sep 17 00:00:00 2001 From: Dylan Hurd Date: Sun, 26 Oct 2025 21:54:40 -0700 Subject: [PATCH] add(core): Add GitInfo to EnvironmentContext --- codex-rs/core/src/codex.rs | 29 ++++- codex-rs/core/src/environment_context.rs | 129 ++++++++++++++++++++++- codex-rs/protocol/src/protocol.rs | 2 +- 3 files changed, 156 insertions(+), 4 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index d89ce5f886..c104db6142 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -65,6 +65,7 @@ use crate::error::CodexErr; use crate::error::Result as CodexResult; #[cfg(test)] use crate::exec::StreamOutput; +use crate::git_info::collect_git_info; // Removed: legacy executor wiring replaced by ToolOrchestrator flows. // legacy normalize_exec_result no longer used after orchestrator migration use crate::mcp::auth::compute_auth_statuses; @@ -127,6 +128,7 @@ use codex_protocol::models::ContentItem; use codex_protocol::models::FunctionCallOutputPayload; use codex_protocol::models::ResponseInputItem; use codex_protocol::models::ResponseItem; +use codex_protocol::protocol::GitInfo; use codex_protocol::protocol::InitialHistory; use codex_protocol::user_input::UserInput; @@ -168,6 +170,8 @@ impl Codex { let config = Arc::new(config); + let git_info = collect_git_info(&config.cwd).await; + let session_configuration = SessionConfiguration { provider: config.model_provider.clone(), model: config.model.clone(), @@ -178,6 +182,7 @@ impl Codex { approval_policy: config.approval_policy, sandbox_policy: config.sandbox_policy.clone(), cwd: config.cwd.clone(), + git_info, original_config_do_not_use: Arc::clone(&config), }; @@ -263,6 +268,7 @@ pub(crate) struct TurnContext { /// the model as well as sandbox policies are resolved against this path /// instead of `std::env::current_dir()`. pub(crate) cwd: PathBuf, + pub(crate) git_info: Option, pub(crate) base_instructions: Option, pub(crate) user_instructions: Option, pub(crate) approval_policy: AskForApproval, @@ -313,6 +319,9 @@ pub(crate) struct SessionConfiguration { /// operate deterministically. cwd: PathBuf, + /// Git metadata for the current working directory, if available. + git_info: Option, + // TODO(pakrym): Remove config from here original_config_do_not_use: Arc, } @@ -338,6 +347,9 @@ impl SessionConfiguration { if let Some(cwd) = updates.cwd.clone() { next_configuration.cwd = cwd; } + if let Some(git_info) = updates.git_info.clone() { + next_configuration.git_info = git_info; + } next_configuration } } @@ -345,6 +357,7 @@ impl SessionConfiguration { #[derive(Default, Clone)] pub(crate) struct SessionSettingsUpdate { pub(crate) cwd: Option, + pub(crate) git_info: Option>, pub(crate) approval_policy: Option, pub(crate) sandbox_policy: Option, pub(crate) model: Option, @@ -398,6 +411,7 @@ impl Session { sub_id, client, cwd: session_configuration.cwd.clone(), + git_info: session_configuration.git_info.clone(), base_instructions: session_configuration.base_instructions.clone(), user_instructions: session_configuration.user_instructions.clone(), approval_policy: session_configuration.approval_policy, @@ -962,6 +976,7 @@ impl Session { Some(turn_context.approval_policy), Some(turn_context.sandbox_policy.clone()), Some(self.user_shell().clone()), + turn_context.git_info.clone(), ))); items } @@ -1190,7 +1205,7 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv effort, summary, } => { - let updates = SessionSettingsUpdate { + let mut updates = SessionSettingsUpdate { cwd, approval_policy, sandbox_policy, @@ -1199,11 +1214,14 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv reasoning_summary: summary, ..Default::default() }; + if let Some(cwd) = updates.cwd.clone() { + updates.git_info = Some(collect_git_info(&cwd).await); + } sess.update_settings(updates).await; } Op::UserInput { .. } | Op::UserTurn { .. } => { - let (items, updates) = match sub.op { + let (items, mut updates) = match sub.op { Op::UserTurn { cwd, approval_policy, @@ -1217,6 +1235,7 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv items, SessionSettingsUpdate { cwd: Some(cwd), + git_info: None, approval_policy: Some(approval_policy), sandbox_policy: Some(sandbox_policy), model: Some(model), @@ -1228,6 +1247,9 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv Op::UserInput { items } => (items, SessionSettingsUpdate::default()), _ => unreachable!(), }; + if let Some(cwd) = updates.cwd.clone() { + updates.git_info = Some(collect_git_info(&cwd).await); + } let current_context = sess.new_turn_with_sub_id(sub.id.clone(), updates).await; current_context .client @@ -1515,6 +1537,7 @@ async fn spawn_review_thread( sandbox_policy: parent_turn_context.sandbox_policy.clone(), shell_environment_policy: parent_turn_context.shell_environment_policy.clone(), cwd: parent_turn_context.cwd.clone(), + git_info: parent_turn_context.git_info.clone(), is_review_mode: true, final_output_json_schema: None, codex_linux_sandbox_exe: parent_turn_context.codex_linux_sandbox_exe.clone(), @@ -2601,6 +2624,7 @@ mod tests { approval_policy: config.approval_policy, sandbox_policy: config.sandbox_policy.clone(), cwd: config.cwd.clone(), + git_info: None, original_config_do_not_use: Arc::clone(&config), }; @@ -2669,6 +2693,7 @@ mod tests { approval_policy: config.approval_policy, sandbox_policy: config.sandbox_policy.clone(), cwd: config.cwd.clone(), + git_info: None, original_config_do_not_use: Arc::clone(&config), }; diff --git a/codex-rs/core/src/environment_context.rs b/codex-rs/core/src/environment_context.rs index e7b2e19ffb..187a0fa897 100644 --- a/codex-rs/core/src/environment_context.rs +++ b/codex-rs/core/src/environment_context.rs @@ -11,6 +11,7 @@ use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::ENVIRONMENT_CONTEXT_CLOSE_TAG; use codex_protocol::protocol::ENVIRONMENT_CONTEXT_OPEN_TAG; +use codex_protocol::protocol::GitInfo; use std::path::PathBuf; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, DeriveDisplay)] @@ -29,6 +30,8 @@ pub(crate) struct EnvironmentContext { pub network_access: Option, pub writable_roots: Option>, pub shell: Option, + pub git_info: Option, + pub git_info_cleared: bool, } impl EnvironmentContext { @@ -37,6 +40,7 @@ impl EnvironmentContext { approval_policy: Option, sandbox_policy: Option, shell: Option, + git_info: Option, ) -> Self { Self { cwd, @@ -70,6 +74,8 @@ impl EnvironmentContext { _ => None, }, shell, + git_info, + git_info_cleared: false, } } @@ -83,8 +89,10 @@ impl EnvironmentContext { sandbox_mode, network_access, writable_roots, + git_info, // should compare all fields except shell shell: _, + git_info_cleared: _, } = other; self.cwd == *cwd @@ -92,6 +100,7 @@ impl EnvironmentContext { && self.sandbox_mode == *sandbox_mode && self.network_access == *network_access && self.writable_roots == *writable_roots + && self.git_info == *git_info } pub fn diff(before: &TurnContext, after: &TurnContext) -> Self { @@ -110,7 +119,22 @@ impl EnvironmentContext { } else { None }; - EnvironmentContext::new(cwd, approval_policy, sandbox_policy, None) + let git_info_changed = before.git_info != after.git_info; + let mut context = EnvironmentContext::new( + cwd, + approval_policy, + sandbox_policy, + None, + if git_info_changed { + after.git_info.clone() + } else { + None + }, + ); + if git_info_changed && before.git_info.is_some() && after.git_info.is_none() { + context.git_info_cleared = true; + } + context } } @@ -122,6 +146,7 @@ impl From<&TurnContext> for EnvironmentContext { Some(turn_context.sandbox_policy.clone()), // Shell is not configurable from turn to turn None, + turn_context.git_info.clone(), ) } } @@ -139,6 +164,7 @@ impl EnvironmentContext { /// ... /// ... /// ... + /// ... /// /// ``` pub fn serialize_to_xml(self) -> String { @@ -174,6 +200,23 @@ impl EnvironmentContext { { lines.push(format!(" {shell_name}")); } + if let Some(git_info) = self.git_info { + lines.push(" ".to_string()); + if let Some(commit_hash) = git_info.commit_hash { + lines.push(format!(" {commit_hash}")); + } + if let Some(branch) = git_info.branch { + lines.push(format!(" {branch}")); + } + if let Some(repository_url) = git_info.repository_url { + lines.push(format!( + " {repository_url}" + )); + } + lines.push(" ".to_string()); + } else if self.git_info_cleared { + lines.push(" ".to_string()); + } lines.push(ENVIRONMENT_CONTEXT_CLOSE_TAG.to_string()); lines.join("\n") } @@ -215,6 +258,7 @@ mod tests { Some(AskForApproval::OnRequest), Some(workspace_write_policy(vec!["/repo", "/tmp"], false)), None, + None, ); let expected = r#" @@ -238,6 +282,7 @@ mod tests { Some(AskForApproval::Never), Some(SandboxPolicy::ReadOnly), None, + None, ); let expected = r#" @@ -256,6 +301,7 @@ mod tests { Some(AskForApproval::OnFailure), Some(SandboxPolicy::DangerFullAccess), None, + None, ); let expected = r#" @@ -267,6 +313,48 @@ mod tests { assert_eq!(context.serialize_to_xml(), expected); } + #[test] + fn serialize_environment_context_with_git_info() { + let git_info = GitInfo { + commit_hash: Some("abc123".to_string()), + branch: Some("main".to_string()), + repository_url: Some("https://example.com/repo.git".to_string()), + }; + let context = EnvironmentContext::new( + Some(PathBuf::from("/repo")), + Some(AskForApproval::OnRequest), + Some(SandboxPolicy::ReadOnly), + None, + Some(git_info), + ); + + let expected = r#" + /repo + on-request + read-only + restricted + + abc123 + main + https://example.com/repo.git + +"#; + + assert_eq!(context.serialize_to_xml(), expected); + } + + #[test] + fn serialize_environment_context_with_cleared_git_info() { + let mut context = EnvironmentContext::new(None, None, None, None, None); + context.git_info_cleared = true; + + let expected = r#" + +"#; + + assert_eq!(context.serialize_to_xml(), expected); + } + #[test] fn equals_except_shell_compares_approval_policy() { // Approval policy @@ -275,12 +363,14 @@ mod tests { Some(AskForApproval::OnRequest), Some(workspace_write_policy(vec!["/repo"], false)), None, + None, ); let context2 = EnvironmentContext::new( Some(PathBuf::from("/repo")), Some(AskForApproval::Never), Some(workspace_write_policy(vec!["/repo"], true)), None, + None, ); assert!(!context1.equals_except_shell(&context2)); } @@ -292,12 +382,14 @@ mod tests { Some(AskForApproval::OnRequest), Some(SandboxPolicy::new_read_only_policy()), None, + None, ); let context2 = EnvironmentContext::new( Some(PathBuf::from("/repo")), Some(AskForApproval::OnRequest), Some(SandboxPolicy::new_workspace_write_policy()), None, + None, ); assert!(!context1.equals_except_shell(&context2)); @@ -310,12 +402,45 @@ mod tests { Some(AskForApproval::OnRequest), Some(workspace_write_policy(vec!["/repo", "/tmp", "/var"], false)), None, + None, ); let context2 = EnvironmentContext::new( Some(PathBuf::from("/repo")), Some(AskForApproval::OnRequest), Some(workspace_write_policy(vec!["/repo", "/tmp"], true)), None, + None, + ); + + assert!(!context1.equals_except_shell(&context2)); + } + + #[test] + fn equals_except_shell_compares_git_info() { + let git_info1 = Some(GitInfo { + commit_hash: Some("abc".to_string()), + branch: Some("main".to_string()), + repository_url: None, + }); + let git_info2 = Some(GitInfo { + commit_hash: Some("def".to_string()), + branch: Some("feature".to_string()), + repository_url: None, + }); + + let context1 = EnvironmentContext::new( + Some(PathBuf::from("/repo")), + Some(AskForApproval::OnRequest), + Some(workspace_write_policy(vec!["/repo"], false)), + None, + git_info1, + ); + let context2 = EnvironmentContext::new( + Some(PathBuf::from("/repo")), + Some(AskForApproval::OnRequest), + Some(workspace_write_policy(vec!["/repo"], false)), + None, + git_info2, ); assert!(!context1.equals_except_shell(&context2)); @@ -331,6 +456,7 @@ mod tests { shell_path: "/bin/bash".into(), bashrc_path: "/home/user/.bashrc".into(), })), + None, ); let context2 = EnvironmentContext::new( Some(PathBuf::from("/repo")), @@ -340,6 +466,7 @@ mod tests { shell_path: "/bin/zsh".into(), zshrc_path: "/home/user/.zshrc".into(), })), + None, ); assert!(context1.equals_except_shell(&context2)); diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 2334561e0e..b1adb076ec 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1010,7 +1010,7 @@ pub struct RolloutLine { pub item: RolloutItem, } -#[derive(Serialize, Deserialize, Clone, Debug, JsonSchema, TS)] +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema, TS)] pub struct GitInfo { /// Current commit hash (SHA) #[serde(skip_serializing_if = "Option::is_none")]