diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index b9e98e0cd8..e822c6ec3f 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -7,6 +7,7 @@ use std::sync::atomic::AtomicU64; use crate::AuthManager; use crate::client_common::REVIEW_PROMPT; use crate::function_tool::FunctionCallError; +use crate::git_info::collect_git_info; use crate::mcp::auth::McpAuthStatusEntry; use crate::mcp_connection_manager::DEFAULT_STARTUP_TIMEOUT; use crate::parse_command::parse_command; @@ -22,6 +23,7 @@ use codex_protocol::ConversationId; use codex_protocol::items::TurnItem; use codex_protocol::protocol::ConversationPathResponseEvent; use codex_protocol::protocol::ExitedReviewModeEvent; +use codex_protocol::protocol::GitInfo; use codex_protocol::protocol::ItemCompletedEvent; use codex_protocol::protocol::ItemStartedEvent; use codex_protocol::protocol::ReviewRequest; @@ -177,6 +179,7 @@ impl Codex { 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), }; @@ -271,6 +274,7 @@ pub(crate) struct TurnContext { pub(crate) is_review_mode: bool, pub(crate) final_output_json_schema: Option, pub(crate) codex_linux_sandbox_exe: Option, + pub(crate) git_info: Option, } impl TurnContext { @@ -312,6 +316,8 @@ pub(crate) struct SessionConfiguration { /// operate deterministically. cwd: PathBuf, + pub(crate) git_info: Option, + // TODO(pakrym): Remove config from here original_config_do_not_use: Arc, } @@ -406,11 +412,12 @@ impl Session { is_review_mode: false, final_output_json_schema: None, codex_linux_sandbox_exe: config.codex_linux_sandbox_exe.clone(), + git_info: session_configuration.git_info.clone(), } } async fn new( - session_configuration: SessionConfiguration, + mut session_configuration: SessionConfiguration, config: Arc, auth_manager: Arc, tx_event: Sender, @@ -467,6 +474,7 @@ impl Session { config.mcp_servers.iter(), config.mcp_oauth_credentials_store_mode, ); + let git_info_fut = collect_git_info(&session_configuration.cwd); // Join all independent futures. let ( @@ -475,14 +483,18 @@ impl Session { default_shell, (history_log_id, history_entry_count), auth_statuses, + git_info, ) = tokio::join!( rollout_fut, mcp_fut, default_shell_fut, history_meta_fut, - auth_statuses_fut + auth_statuses_fut, + git_info_fut ); + session_configuration.git_info = git_info; + let rollout_recorder = rollout_recorder.map_err(|e| { error!("failed to initialize rollout recorder: {e:#}"); anyhow::anyhow!("failed to initialize rollout recorder: {e:#}") @@ -630,9 +642,7 @@ impl Session { } pub(crate) async fn update_settings(&self, updates: SessionSettingsUpdate) { - let mut state = self.state.lock().await; - - state.session_configuration = state.session_configuration.apply(&updates); + self.apply_session_updates(&updates).await; } pub(crate) async fn new_turn(&self, updates: SessionSettingsUpdate) -> Arc { @@ -645,12 +655,7 @@ impl Session { sub_id: String, updates: SessionSettingsUpdate, ) -> Arc { - let session_configuration = { - let mut state = self.state.lock().await; - let session_configuration = state.session_configuration.clone().apply(&updates); - state.session_configuration = session_configuration.clone(); - session_configuration - }; + let session_configuration = self.apply_session_updates(&updates).await; let mut turn_context: TurnContext = Self::make_turn_context( Some(Arc::clone(&self.services.auth_manager)), @@ -666,6 +671,26 @@ impl Session { Arc::new(turn_context) } + async fn apply_session_updates(&self, updates: &SessionSettingsUpdate) -> SessionConfiguration { + let needs_git_refresh = updates.cwd.is_some(); + let mut state = self.state.lock().await; + let mut session_configuration = state.session_configuration.clone().apply(updates); + if needs_git_refresh { + session_configuration.git_info = None; + } + state.session_configuration = session_configuration.clone(); + drop(state); + + if needs_git_refresh { + let git_info = collect_git_info(&session_configuration.cwd).await; + session_configuration.git_info = git_info; + let mut state = self.state.lock().await; + state.session_configuration = session_configuration.clone(); + } + + session_configuration + } + fn build_environment_update_item( &self, previous: Option<&Arc>, @@ -919,6 +944,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 } @@ -1472,6 +1498,7 @@ async fn spawn_review_thread( is_review_mode: true, final_output_json_schema: None, codex_linux_sandbox_exe: parent_turn_context.codex_linux_sandbox_exe.clone(), + git_info: parent_turn_context.git_info.clone(), }; // Seed the child task with the review prompt as the initial user message. @@ -2549,6 +2576,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), }; @@ -2617,6 +2645,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..5cc3a9d95d 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,7 @@ pub(crate) struct EnvironmentContext { pub network_access: Option, pub writable_roots: Option>, pub shell: Option, + pub git_info: Option, } impl EnvironmentContext { @@ -37,6 +39,7 @@ impl EnvironmentContext { approval_policy: Option, sandbox_policy: Option, shell: Option, + git_info: Option, ) -> Self { Self { cwd, @@ -70,6 +73,7 @@ impl EnvironmentContext { _ => None, }, shell, + git_info, } } @@ -85,6 +89,7 @@ impl EnvironmentContext { writable_roots, // should compare all fields except shell shell: _, + git_info, } = other; self.cwd == *cwd @@ -92,6 +97,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 +116,12 @@ impl EnvironmentContext { } else { None }; - EnvironmentContext::new(cwd, approval_policy, sandbox_policy, None) + let git_info = if before.git_info != after.git_info { + after.git_info.clone() + } else { + None + }; + EnvironmentContext::new(cwd, approval_policy, sandbox_policy, None, git_info) } } @@ -122,6 +133,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 +151,7 @@ impl EnvironmentContext { /// ... /// ... /// ... + /// ... /// /// ``` pub fn serialize_to_xml(self) -> String { @@ -174,6 +187,26 @@ impl EnvironmentContext { { lines.push(format!(" {shell_name}")); } + if let Some(git_info) = self.git_info { + let has_data = git_info.commit_hash.is_some() + || git_info.branch.is_some() + || git_info.repository_url.is_some(); + if has_data { + 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()); + } + } lines.push(ENVIRONMENT_CONTEXT_CLOSE_TAG.to_string()); lines.join("\n") } @@ -215,6 +248,7 @@ mod tests { Some(AskForApproval::OnRequest), Some(workspace_write_policy(vec!["/repo", "/tmp"], false)), None, + None, ); let expected = r#" @@ -238,6 +272,7 @@ mod tests { Some(AskForApproval::Never), Some(SandboxPolicy::ReadOnly), None, + None, ); let expected = r#" @@ -256,6 +291,7 @@ mod tests { Some(AskForApproval::OnFailure), Some(SandboxPolicy::DangerFullAccess), None, + None, ); let expected = r#" @@ -267,6 +303,31 @@ mod tests { assert_eq!(context.serialize_to_xml(), expected); } + #[test] + fn serialize_environment_context_with_git_info() { + let context = EnvironmentContext::new( + None, + None, + None, + None, + Some(GitInfo { + commit_hash: Some("abc123".to_string()), + branch: Some("main".to_string()), + repository_url: Some("git@example.com:repo.git".to_string()), + }), + ); + + let expected = r#" + + abc123 + main + git@example.com:repo.git + +"#; + + assert_eq!(context.serialize_to_xml(), expected); + } + #[test] fn equals_except_shell_compares_approval_policy() { // Approval policy @@ -275,12 +336,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 +355,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 +375,14 @@ 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)); @@ -331,6 +398,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 +408,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/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 1bf12eb954..175d6374b3 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -21,6 +21,9 @@ use core_test_support::load_sse_fixture_with_id; use core_test_support::skip_if_no_network; use core_test_support::wait_for_event; use std::collections::HashMap; +use std::fs; +use std::path::Path; +use std::process::Command; use tempfile::TempDir; use wiremock::Mock; use wiremock::MockServer; @@ -52,6 +55,75 @@ fn default_env_context_str(cwd: &str, shell: &Shell) -> String { ) } +fn git_command(repo_path: &Path) -> Command { + let mut command = Command::new("git"); + command.current_dir(repo_path); + command.env("GIT_CONFIG_GLOBAL", "/dev/null"); + command.env("GIT_CONFIG_NOSYSTEM", "1"); + command +} + +fn run_git_command(repo_path: &Path, args: &[&str]) { + let output = git_command(repo_path) + .args(args) + .output() + .unwrap_or_else(|err| panic!("failed to run git command: {err}")); + assert!( + output.status.success(), + "git {} failed: {}", + args.join(" "), + String::from_utf8_lossy(&output.stderr) + ); +} + +fn init_git_repo_with_remote() -> (TempDir, String) { + let repo = TempDir::new().unwrap(); + let repo_path = repo.path(); + + run_git_command(repo_path, &["init"]); + run_git_command(repo_path, &["config", "user.name", "Integration Test"]); + run_git_command(repo_path, &["config", "user.email", "test@example.com"]); + + fs::write(repo_path.join("tracked.txt"), "tracked file").unwrap(); + run_git_command(repo_path, &["add", "."]); + run_git_command(repo_path, &["commit", "-m", "initial commit"]); + run_git_command(repo_path, &["checkout", "-b", "integration-test-branch"]); + run_git_command( + repo_path, + &[ + "remote", + "add", + "origin", + "https://github.com/example/git-info-test.git", + ], + ); + + let output = git_command(repo_path) + .args(["rev-parse", "HEAD"]) + .output() + .unwrap_or_else(|err| panic!("failed to read git commit hash: {err}")); + assert!(output.status.success(), "git rev-parse failed"); + let commit_hash = String::from_utf8(output.stdout) + .unwrap_or_else(|err| panic!("commit hash should be utf8: {err}")) + .trim() + .to_string(); + + (repo, commit_hash) +} + +fn find_environment_context(body: &serde_json::Value) -> &str { + body["input"] + .as_array() + .and_then(|items| { + items.iter().find_map(|item| { + let content = item.get("content")?.as_array()?; + let text = content.first()?.get("text")?.as_str()?; + text.starts_with("").then_some(text) + }) + }) + .unwrap_or_else(|| panic!("environment_context message not found")) +} + /// Build minimal SSE stream with completed marker using the JSON fixture. fn sse_completed(id: &str) -> String { load_sse_fixture_with_id("tests/fixtures/completed_template.json", id) @@ -883,3 +955,88 @@ async fn send_user_turn_with_changes_sends_environment_context() { ]); assert_eq!(body2["input"], expected_input_2); } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn environment_context_includes_git_info_when_available() { + skip_if_no_network!(); + + let server = MockServer::start().await; + + let sse = sse_completed("resp"); + let template = ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_raw(sse, "text/event-stream"); + + Mock::given(method("POST")) + .and(path("/v1/responses")) + .respond_with(template) + .expect(1) + .mount(&server) + .await; + + let model_provider = ModelProviderInfo { + base_url: Some(format!("{}/v1", server.uri())), + ..built_in_model_providers()["openai"].clone() + }; + + let (git_repo, commit_hash) = init_git_repo_with_remote(); + + let codex_home = TempDir::new().unwrap(); + let mut config = load_default_config_for_test(&codex_home); + config.cwd = git_repo.path().to_path_buf(); + config.model_provider = model_provider; + + let default_cwd = config.cwd.clone(); + let default_approval_policy = config.approval_policy; + let default_sandbox_policy = config.sandbox_policy.clone(); + let default_model = config.model.clone(); + let default_effort = config.model_reasoning_effort; + let default_summary = config.model_reasoning_summary; + + let conversation_manager = + ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key")); + let codex = conversation_manager + .new_conversation(config) + .await + .expect("create new conversation") + .conversation; + + codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "git info turn".into(), + }], + cwd: default_cwd, + approval_policy: default_approval_policy, + sandbox_policy: default_sandbox_policy, + model: default_model, + effort: default_effort, + summary: default_summary, + final_output_json_schema: None, + }) + .await + .unwrap(); + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; + + let requests = server.received_requests().await.unwrap(); + assert_eq!(requests.len(), 1, "expected a single POST request"); + + let body = requests[0].body_json::().unwrap(); + let env_text = find_environment_context(&body); + + assert!(env_text.contains(""), "git info block missing"); + assert!( + env_text.contains(&format!("{commit_hash}")), + "commit hash missing from git info" + ); + assert!( + env_text.contains("integration-test-branch"), + "branch missing from git info" + ); + assert!( + env_text.contains( + "https://github.com/example/git-info-test.git" + ), + "repository url missing from git info" + ); +} diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 105f028049..58fe0b028e 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1002,7 +1002,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")]