diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 8b792887c0..009fa3bf95 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -68,6 +68,7 @@ use crate::exec_command::ExecSessionManager; use crate::exec_command::WRITE_STDIN_TOOL_NAME; use crate::exec_command::WriteStdinParams; use crate::exec_env::create_env; +use crate::git_worktree::WorktreeHandle; use crate::mcp_connection_manager::McpConnectionManager; use crate::mcp_tool_call::handle_mcp_tool_call; use crate::model_family::find_family_for_model; @@ -109,6 +110,7 @@ use crate::protocol::TokenCountEvent; use crate::protocol::TokenUsage; use crate::protocol::TurnDiffEvent; use crate::protocol::WebSearchBeginEvent; +use crate::protocol::WorktreeRemovedEvent; use crate::rollout::RolloutRecorder; use crate::rollout::RolloutRecorderParams; use crate::safety::SafetyCheck; @@ -125,6 +127,7 @@ use crate::unified_exec::UnifiedExecSessionManager; use crate::user_instructions::UserInstructions; use crate::user_notification::UserNotification; use crate::util::backoff; +use anyhow::Context; use codex_protocol::config_types::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; use codex_protocol::custom_prompts::CustomPrompt; @@ -422,6 +425,14 @@ impl Session { } } + // Prepare the per-session working directory. When git worktrees are enabled + // we create (or reuse) a linked checkout under `cwd/codex/`. + let (effective_cwd, worktree_handle_opt, worktree_path_opt, worktree_error_event) = + maybe_initialize_worktree(&cwd, &conversation_id, config.enable_git_worktree).await; + if let Some(event) = worktree_error_event { + post_session_configured_error_events.push(event); + } + // Now that the conversation id is final (may have been updated by resume), // construct the model client. let client = ModelClient::new( @@ -448,7 +459,7 @@ impl Session { approval_policy, sandbox_policy, shell_environment_policy: config.shell_environment_policy.clone(), - cwd, + cwd: effective_cwd.clone(), is_review_mode: false, final_output_json_schema: None, }; @@ -458,6 +469,7 @@ impl Session { unified_exec_manager: UnifiedExecSessionManager::default(), notifier: notify, rollout: Mutex::new(Some(rollout_recorder)), + worktree: Mutex::new(worktree_handle_opt), codex_linux_sandbox_exe: config.codex_linux_sandbox_exe.clone(), user_shell: default_shell, show_raw_agent_reasoning: config.show_raw_agent_reasoning, @@ -488,6 +500,7 @@ impl Session { history_entry_count, initial_messages, rollout_path, + worktree_path: worktree_path_opt, }), }) .chain(post_session_configured_error_events.into_iter()); @@ -732,6 +745,33 @@ impl Session { state.history_snapshot() } + async fn send_error>(&self, sub_id: &str, message: S) { + let event = Event { + id: sub_id.to_string(), + msg: EventMsg::Error(ErrorEvent { + message: message.into(), + }), + }; + self.send_event(event).await; + } + + async fn remove_worktree(&self) -> anyhow::Result> { + let handle_opt = { + let mut guard = self.services.worktree.lock().await; + guard.take() + }; + if let Some(handle) = handle_opt { + let path = handle.path().to_path_buf(); + handle + .remove() + .await + .with_context(|| format!("failed to remove git worktree `{}`", path.display()))?; + Ok(Some(path)) + } else { + Ok(None) + } + } + async fn update_token_usage_info( &self, sub_id: &str, @@ -1051,6 +1091,41 @@ impl Session { } } +async fn maybe_initialize_worktree( + base_cwd: &Path, + conversation_id: &ConversationId, + enable_git_worktree: bool, +) -> ( + PathBuf, + Option, + Option, + Option, +) { + if !enable_git_worktree { + return (base_cwd.to_path_buf(), None, None, None); + } + + match WorktreeHandle::create(base_cwd, conversation_id).await { + Ok(handle) => { + let path = handle.path().to_path_buf(); + (path.clone(), Some(handle), Some(path), None) + } + Err(e) => { + let message = format!("Failed to create git worktree: {e:#}"); + error!("{message}"); + ( + base_cwd.to_path_buf(), + None, + None, + Some(Event { + id: INITIAL_SUBMIT_ID.to_owned(), + msg: EventMsg::Error(ErrorEvent { message }), + }), + ) + } + } +} + impl Drop for Session { fn drop(&mut self) { self.interrupt_task_sync(); @@ -1427,6 +1502,24 @@ async fn submission_loop( ) .await; } + Op::RemoveWorktree => match sess.remove_worktree().await { + Ok(Some(path)) => { + let event = Event { + id: sub.id.clone(), + msg: EventMsg::WorktreeRemoved(WorktreeRemovedEvent { path }), + }; + sess.send_event(event).await; + } + Ok(None) => { + sess.send_error(&sub.id, "No git worktree is active for this session") + .await; + } + Err(e) => { + error!("failed to remove git worktree: {e:#}"); + sess.send_error(&sub.id, format!("Failed to remove git worktree: {e:#}")) + .await; + } + }, _ => { // Ignore unknown ops; enum is non_exhaustive to allow extensions. } @@ -3416,6 +3509,7 @@ mod tests { unified_exec_manager: UnifiedExecSessionManager::default(), notifier: UserNotifier::default(), rollout: Mutex::new(None), + worktree: Mutex::new(None), codex_linux_sandbox_exe: None, user_shell: shell::Shell::Unknown, show_raw_agent_reasoning: config.show_raw_agent_reasoning, diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs index 292b9f7b51..c6cc0fb7cb 100644 --- a/codex-rs/core/src/config.rs +++ b/codex-rs/core/src/config.rs @@ -191,6 +191,8 @@ pub struct Config { /// Include the `view_image` tool that lets the agent attach a local image path to context. pub include_view_image_tool: bool, + /// When true, sessions run inside a linked git worktree under `cwd/codex/`. + pub enable_git_worktree: bool, /// The active profile name used to derive this `Config` (if any). pub active_profile: Option, @@ -689,6 +691,9 @@ pub struct ConfigToml { /// Defaults to `false`. pub show_raw_agent_reasoning: Option, + /// Enable per-session git worktree checkouts. + pub enable_git_worktree: Option, + pub model_reasoning_effort: Option, pub model_reasoning_summary: Option, /// Optional verbosity control for GPT-5 models (Responses API `text.verbosity`). @@ -859,6 +864,7 @@ pub struct ConfigOverrides { pub include_view_image_tool: Option, pub show_raw_agent_reasoning: Option, pub tools_web_search_request: Option, + pub enable_git_worktree: Option, } impl Config { @@ -887,6 +893,7 @@ impl Config { include_view_image_tool, show_raw_agent_reasoning, tools_web_search_request: override_tools_web_search_request, + enable_git_worktree: override_enable_git_worktree, } = overrides; let active_profile_name = config_profile_key @@ -960,6 +967,10 @@ impl Config { .or(cfg.tools.as_ref().and_then(|t| t.view_image)) .unwrap_or(true); + let enable_git_worktree = override_enable_git_worktree + .or(cfg.enable_git_worktree) + .unwrap_or(false); + let model = model .or(config_profile.model) .or(cfg.model) @@ -1061,6 +1072,7 @@ impl Config { .unwrap_or(false), use_experimental_use_rmcp_client: cfg.experimental_use_rmcp_client.unwrap_or(false), include_view_image_tool, + enable_git_worktree, active_profile: active_profile_name, disable_paste_burst: cfg.disable_paste_burst.unwrap_or(false), tui_notifications: cfg @@ -1806,6 +1818,7 @@ model_verbosity = "high" use_experimental_unified_exec_tool: false, use_experimental_use_rmcp_client: false, include_view_image_tool: true, + enable_git_worktree: false, active_profile: Some("o3".to_string()), disable_paste_burst: false, tui_notifications: Default::default(), @@ -1865,6 +1878,7 @@ model_verbosity = "high" use_experimental_unified_exec_tool: false, use_experimental_use_rmcp_client: false, include_view_image_tool: true, + enable_git_worktree: false, active_profile: Some("gpt3".to_string()), disable_paste_burst: false, tui_notifications: Default::default(), @@ -1939,6 +1953,7 @@ model_verbosity = "high" use_experimental_unified_exec_tool: false, use_experimental_use_rmcp_client: false, include_view_image_tool: true, + enable_git_worktree: false, active_profile: Some("zdr".to_string()), disable_paste_burst: false, tui_notifications: Default::default(), @@ -1999,6 +2014,7 @@ model_verbosity = "high" use_experimental_unified_exec_tool: false, use_experimental_use_rmcp_client: false, include_view_image_tool: true, + enable_git_worktree: false, active_profile: Some("gpt5".to_string()), disable_paste_burst: false, tui_notifications: Default::default(), diff --git a/codex-rs/core/src/git_worktree.rs b/codex-rs/core/src/git_worktree.rs new file mode 100644 index 0000000000..7345c2009e --- /dev/null +++ b/codex-rs/core/src/git_worktree.rs @@ -0,0 +1,355 @@ +use std::fs as std_fs; +use std::path::Path; +use std::path::PathBuf; + +use anyhow::Context; +use anyhow::Result; +use anyhow::anyhow; +use codex_protocol::mcp_protocol::ConversationId; +use tokio::fs; +use tokio::fs::OpenOptions; +use tokio::io::AsyncWriteExt; +use tokio::process::Command; +use tracing::info; +use tracing::warn; + +/// Represents a linked git worktree managed by Codex. +/// +/// The handle tracks whether Codex created the worktree for the current +/// conversation. It leaves the checkout in place until [`remove`] is invoked. +pub struct WorktreeHandle { + repo_root: PathBuf, + path: PathBuf, +} + +impl WorktreeHandle { + /// Create (or reuse) a worktree rooted at + /// `/codex/worktree/`. + pub async fn create(repo_root: &Path, conversation_id: &ConversationId) -> Result { + if !repo_root.exists() { + return Err(anyhow!( + "git worktree root `{}` does not exist", + repo_root.display() + )); + } + + let repo_root = repo_root.to_path_buf(); + let codex_dir = repo_root.join("codex"); + let codex_worktree_dir = codex_dir.join("worktree"); + fs::create_dir_all(&codex_worktree_dir) + .await + .with_context(|| { + format!( + "failed to create codex worktree directory at `{}`", + codex_worktree_dir.display() + ) + })?; + + let path = codex_worktree_dir.join(conversation_id.to_string()); + let is_registered = worktree_registered(&repo_root, &path).await?; + + if is_registered { + if path.exists() { + if let Err(err) = ensure_codex_excluded(&repo_root).await { + warn!("failed to add codex worktree path to git exclude: {err:#}"); + } + info!( + worktree = %path.display(), + "reusing existing git worktree for conversation" + ); + return Ok(Self { repo_root, path }); + } + + warn!( + worktree = %path.display(), + "git worktree is registered but missing on disk; pruning stale entry" + ); + run_git_command(&repo_root, ["worktree", "prune", "--expire", "now"]) + .await + .with_context(|| { + format!( + "failed to prune git worktrees while recovering `{}`", + path.display() + ) + })?; + + if worktree_registered(&repo_root, &path).await? { + return Err(anyhow!( + "git worktree `{}` is registered but missing on disk; run `git worktree prune --expire now` to remove the stale entry", + path.display() + )); + } + + info!( + worktree = %path.display(), + "recreating git worktree for conversation after pruning stale registration" + ); + } + + if path.exists() { + return Err(anyhow!( + "git worktree path `{}` already exists but is not registered; remove it manually", + path.display() + )); + } + + run_git_command( + &repo_root, + [ + "worktree", + "add", + "--detach", + path.to_str().ok_or_else(|| { + anyhow!( + "failed to convert worktree path `{}` to UTF-8", + path.display() + ) + })?, + "HEAD", + ], + ) + .await + .with_context(|| format!("failed to create git worktree at `{}`", path.display()))?; + + if let Err(err) = ensure_codex_excluded(&repo_root).await { + warn!("failed to add codex worktree path to git exclude: {err:#}"); + } + + info!( + worktree = %path.display(), + "created git worktree for conversation" + ); + + Ok(Self { repo_root, path }) + } + + /// Absolute path to the worktree checkout on disk. + pub fn path(&self) -> &Path { + &self.path + } + + /// Remove the worktree and prune metadata from the repository. + pub async fn remove(self) -> Result<()> { + let path = self.path.clone(); + + // `git worktree remove` fails if refs are missing or the checkout is dirty. + // Use --force to ensure best effort removal; the user explicitly requested it. + run_git_command( + &self.repo_root, + [ + "worktree", + "remove", + "--force", + path.to_str().ok_or_else(|| { + anyhow!( + "failed to convert worktree path `{}` to UTF-8", + path.display() + ) + })?, + ], + ) + .await + .with_context(|| format!("failed to remove git worktree `{}`", path.display()))?; + + // Prune dangling metadata so repeated sessions do not accumulate entries. + if let Err(err) = + run_git_command(&self.repo_root, ["worktree", "prune", "--expire", "now"]).await + { + warn!("failed to prune git worktrees: {err:#}"); + } + + Ok(()) + } +} + +async fn worktree_registered(repo_root: &Path, target: &Path) -> Result { + let output = run_git_command(repo_root, ["worktree", "list", "--porcelain"]).await?; + let stdout = String::from_utf8(output.stdout)?; + + let target_canon = std_fs::canonicalize(target).unwrap_or_else(|_| target.to_path_buf()); + + for line in stdout.lines() { + if let Some(path) = line.strip_prefix("worktree ") { + let candidate = Path::new(path); + let candidate_canon = + std_fs::canonicalize(candidate).unwrap_or_else(|_| candidate.to_path_buf()); + if candidate_canon == target_canon { + return Ok(true); + } + } + } + Ok(false) +} + +async fn run_git_command<'a>( + repo_root: &Path, + args: impl IntoIterator, +) -> Result { + let mut cmd = Command::new("git"); + cmd.args(args); + cmd.current_dir(repo_root); + let output = cmd + .output() + .await + .with_context(|| format!("failed to execute git command in `{}`", repo_root.display()))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let status = output + .status + .code() + .map(|c| c.to_string()) + .unwrap_or_else(|| "signal".to_string()); + return Err(anyhow!("git command exited with status {status}: {stderr}",)); + } + + Ok(output) +} + +async fn ensure_codex_excluded(repo_root: &Path) -> Result<()> { + const PATTERN: &str = "/codex/"; + + let git_dir_out = run_git_command(repo_root, ["rev-parse", "--git-dir"]).await?; + let git_dir_str = String::from_utf8(git_dir_out.stdout)?.trim().to_string(); + let git_dir_path = if Path::new(&git_dir_str).is_absolute() { + PathBuf::from(&git_dir_str) + } else { + repo_root.join(&git_dir_str) + }; + + let info_dir = git_dir_path.join("info"); + fs::create_dir_all(&info_dir).await?; + let exclude_path = info_dir.join("exclude"); + + let existing_bytes = fs::read(&exclude_path).await.unwrap_or_default(); + let existing = String::from_utf8(existing_bytes).unwrap_or_default(); + if existing.lines().any(|line| line.trim() == PATTERN) { + return Ok(()); + } + + let mut file = OpenOptions::new() + .create(true) + .append(true) + .open(&exclude_path) + .await?; + + if !existing.is_empty() && !existing.ends_with('\n') { + file.write_all(b"\n").await?; + } + file.write_all(PATTERN.as_bytes()).await?; + file.write_all(b"\n").await?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + use tempfile::TempDir; + + const GIT_ENV: [(&str, &str); 2] = [ + ("GIT_CONFIG_GLOBAL", "/dev/null"), + ("GIT_CONFIG_NOSYSTEM", "1"), + ]; + + async fn init_repo() -> (TempDir, PathBuf) { + let temp = TempDir::new().expect("tempdir"); + let repo_path = temp.path().join("repo"); + fs::create_dir_all(&repo_path) + .await + .expect("create repo dir"); + + run_git_with_env(&repo_path, ["init"], &GIT_ENV) + .await + .expect("git init"); + run_git_with_env(&repo_path, ["config", "user.name", "Test User"], &GIT_ENV) + .await + .expect("config user.name"); + run_git_with_env( + &repo_path, + ["config", "user.email", "test@example.com"], + &GIT_ENV, + ) + .await + .expect("config user.email"); + + fs::write(repo_path.join("README.md"), b"hello world") + .await + .expect("write file"); + run_git_with_env(&repo_path, ["add", "README.md"], &GIT_ENV) + .await + .expect("git add"); + run_git_with_env(&repo_path, ["commit", "-m", "init"], &GIT_ENV) + .await + .expect("git commit"); + + (temp, repo_path) + } + + async fn run_git_with_env<'a>( + cwd: &Path, + args: impl IntoIterator, + envs: &[(&str, &str)], + ) -> Result<()> { + let mut cmd = Command::new("git"); + cmd.args(args); + cmd.current_dir(cwd); + for (key, value) in envs { + cmd.env(key, value); + } + let status = cmd.status().await.context("failed to spawn git command")?; + if !status.success() { + return Err(anyhow!( + "git command exited with status {status} (cwd: {})", + cwd.display() + )); + } + Ok(()) + } + + async fn is_registered(repo_root: &Path, path: &Path) -> bool { + worktree_registered(repo_root, path).await.unwrap_or(false) + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn creates_and_removes_worktree() { + let (_temp, repo) = init_repo().await; + let conversation_id = ConversationId::new(); + + let handle = WorktreeHandle::create(&repo, &conversation_id) + .await + .expect("create worktree"); + let path = handle.path().to_path_buf(); + assert!(path.exists(), "worktree path should exist on disk"); + assert!( + is_registered(&repo, &path).await, + "worktree should be registered" + ); + + handle.remove().await.expect("remove worktree"); + assert!( + !is_registered(&repo, &path).await, + "worktree should be removed from registration" + ); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn reuses_existing_worktree() { + let (_temp, repo) = init_repo().await; + let conversation_id = ConversationId::new(); + + let first = WorktreeHandle::create(&repo, &conversation_id) + .await + .expect("create worktree"); + let path = first.path().to_path_buf(); + drop(first); + + let second = WorktreeHandle::create(&repo, &conversation_id) + .await + .expect("reuse worktree"); + assert_eq!(path, second.path()); + assert!(is_registered(&repo, second.path()).await); + + second.remove().await.expect("remove worktree"); + } +} diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index ad040ec855..ac12b87227 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -29,6 +29,7 @@ mod exec_command; pub mod exec_env; mod flags; pub mod git_info; +mod git_worktree; pub mod internal_storage; pub mod landlock; mod mcp_connection_manager; diff --git a/codex-rs/core/src/rollout/policy.rs b/codex-rs/core/src/rollout/policy.rs index 2fd0efb0dc..78bdd8c770 100644 --- a/codex-rs/core/src/rollout/policy.rs +++ b/codex-rs/core/src/rollout/policy.rs @@ -70,6 +70,7 @@ pub(crate) fn should_persist_event_msg(ev: &EventMsg) -> bool { | EventMsg::ListCustomPromptsResponse(_) | EventMsg::PlanUpdate(_) | EventMsg::ShutdownComplete - | EventMsg::ConversationPath(_) => false, + | EventMsg::ConversationPath(_) + | EventMsg::WorktreeRemoved(_) => false, } } diff --git a/codex-rs/core/src/state/service.rs b/codex-rs/core/src/state/service.rs index a67b9dda93..13d5acff02 100644 --- a/codex-rs/core/src/state/service.rs +++ b/codex-rs/core/src/state/service.rs @@ -1,5 +1,6 @@ use crate::RolloutRecorder; use crate::exec_command::ExecSessionManager; +use crate::git_worktree::WorktreeHandle; use crate::mcp_connection_manager::McpConnectionManager; use crate::unified_exec::UnifiedExecSessionManager; use crate::user_notification::UserNotifier; @@ -12,6 +13,7 @@ pub(crate) struct SessionServices { pub(crate) unified_exec_manager: UnifiedExecSessionManager, pub(crate) notifier: UserNotifier, pub(crate) rollout: Mutex>, + pub(crate) worktree: Mutex>, pub(crate) codex_linux_sandbox_exe: Option, pub(crate) user_shell: crate::shell::Shell, pub(crate) show_raw_agent_reasoning: bool, diff --git a/codex-rs/exec/src/event_processor_with_human_output.rs b/codex-rs/exec/src/event_processor_with_human_output.rs index 4f231bab84..96d1bbc16f 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -26,6 +26,7 @@ use codex_core::protocol::TurnAbortReason; use codex_core::protocol::TurnDiffEvent; use codex_core::protocol::WebSearchBeginEvent; use codex_core::protocol::WebSearchEndEvent; +use codex_core::protocol::WorktreeRemovedEvent; use codex_protocol::num_format::format_with_separators; use owo_colors::OwoColorize; use owo_colors::Style; @@ -199,6 +200,14 @@ impl EventProcessor for EventProcessorWithHumanOutput { ); } } + EventMsg::WorktreeRemoved(WorktreeRemovedEvent { path }) => { + ts_println!( + self, + "{} {}", + "git worktree removed:".style(self.cyan), + path.display() + ); + } EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta }) => { if !self.answer_started { ts_println!(self, "{}\n", "codex".style(self.italic).style(self.magenta)); @@ -525,6 +534,7 @@ impl EventProcessor for EventProcessorWithHumanOutput { history_entry_count: _, initial_messages: _, rollout_path: _, + worktree_path, } = session_configured_event; ts_println!( @@ -535,6 +545,9 @@ impl EventProcessor for EventProcessorWithHumanOutput { ); ts_println!(self, "model: {}", model); + if let Some(path) = worktree_path { + ts_println!(self, "git worktree: {}", path.display()); + } println!(); } EventMsg::PlanUpdate(plan_update_event) => { diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index da23fb1b23..9dcdd281d9 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -171,6 +171,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any include_view_image_tool: None, show_raw_agent_reasoning: oss.then_some(true), tools_web_search_request: None, + enable_git_worktree: None, }; // Parse `-c` overrides. let cli_kv_overrides = match config_overrides.parse_overrides() { diff --git a/codex-rs/mcp-server/src/codex_message_processor.rs b/codex-rs/mcp-server/src/codex_message_processor.rs index eec3a62a2c..4e54ec1ac5 100644 --- a/codex-rs/mcp-server/src/codex_message_processor.rs +++ b/codex-rs/mcp-server/src/codex_message_processor.rs @@ -1273,6 +1273,7 @@ fn derive_config_from_params( include_view_image_tool: None, show_raw_agent_reasoning: None, tools_web_search_request: None, + enable_git_worktree: None, }; let cli_overrides = cli_overrides diff --git a/codex-rs/mcp-server/src/codex_tool_config.rs b/codex-rs/mcp-server/src/codex_tool_config.rs index d90924aa94..ab69c8510b 100644 --- a/codex-rs/mcp-server/src/codex_tool_config.rs +++ b/codex-rs/mcp-server/src/codex_tool_config.rs @@ -165,6 +165,7 @@ impl CodexToolCallParam { include_view_image_tool: None, show_raw_agent_reasoning: None, tools_web_search_request: None, + enable_git_worktree: None, }; let cli_overrides = cli_overrides diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index db48da28e2..8a2bd81445 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -278,6 +278,7 @@ async fn run_codex_tool_session_inner( | EventMsg::PlanUpdate(_) | EventMsg::TurnAborted(_) | EventMsg::ConversationPath(_) + | EventMsg::WorktreeRemoved(_) | EventMsg::UserMessage(_) | EventMsg::ShutdownComplete | EventMsg::EnteredReviewMode(_) diff --git a/codex-rs/mcp-server/src/outgoing_message.rs b/codex-rs/mcp-server/src/outgoing_message.rs index 6f376b1df5..cc9ee0ec80 100644 --- a/codex-rs/mcp-server/src/outgoing_message.rs +++ b/codex-rs/mcp-server/src/outgoing_message.rs @@ -286,6 +286,7 @@ mod tests { history_entry_count: 1000, initial_messages: None, rollout_path: rollout_file.path().to_path_buf(), + worktree_path: None, }), }; @@ -321,6 +322,7 @@ mod tests { history_entry_count: 1000, initial_messages: None, rollout_path: rollout_file.path().to_path_buf(), + worktree_path: None, }; let event = Event { id: "1".to_string(), diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index bf0b192548..93c4c8723b 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -174,6 +174,9 @@ pub enum Op { /// Request a code review from the agent. Review { review_request: ReviewRequest }, + /// Remove the git worktree associated with the current session, if any. + RemoveWorktree, + /// Request to shut down codex instance. Shutdown, } @@ -519,6 +522,9 @@ pub enum EventMsg { /// Exited review mode with an optional final result to apply. ExitedReviewMode(ExitedReviewModeEvent), + + /// Confirmation that a git worktree has been removed. + WorktreeRemoved(WorktreeRemovedEvent), } #[derive(Debug, Clone, Deserialize, Serialize, TS)] @@ -1203,6 +1209,16 @@ pub struct SessionConfiguredEvent { pub initial_messages: Option>, pub rollout_path: PathBuf, + + /// When set, the session is running inside this git worktree checkout. + #[serde(skip_serializing_if = "Option::is_none")] + pub worktree_path: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize, TS)] +pub struct WorktreeRemovedEvent { + /// Filesystem path that was removed, relative to the host running Codex. + pub path: PathBuf, } /// User's decision in response to an ExecApprovalRequest. @@ -1286,6 +1302,7 @@ mod tests { history_entry_count: 0, initial_messages: None, rollout_path: rollout_file.path().to_path_buf(), + worktree_path: None, }), }; diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 85ecad284a..bab56cb350 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -41,6 +41,7 @@ use codex_core::protocol::TurnDiffEvent; use codex_core::protocol::UserMessageEvent; use codex_core::protocol::WebSearchBeginEvent; use codex_core::protocol::WebSearchEndEvent; +use codex_core::protocol::WorktreeRemovedEvent; use codex_protocol::mcp_protocol::ConversationId; use codex_protocol::parse_command::ParsedCommand; use crossterm::event::KeyCode; @@ -590,6 +591,12 @@ impl ChatWidget { debug!("BackgroundEvent: {message}"); } + fn on_worktree_removed(&mut self, event: WorktreeRemovedEvent) { + let message = format!("Git worktree removed: {}", event.path.display()); + self.add_to_history(history_cell::new_info_event(message, None)); + self.request_redraw(); + } + fn on_stream_error(&mut self, message: String) { // Show stream errors in the transcript so users see retry/backoff info. self.add_to_history(history_cell::new_stream_error_event(message)); @@ -1400,6 +1407,7 @@ impl ChatWidget { self.app_event_tx .send(crate::app_event::AppEvent::ConversationHistory(ev)); } + EventMsg::WorktreeRemoved(ev) => self.on_worktree_removed(ev), EventMsg::EnteredReviewMode(review_request) => { self.on_entered_review_mode(review_request) } diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 0c9ebc73aa..fbffca147a 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -165,6 +165,7 @@ fn resumed_initial_messages_render_history() { }), ]), rollout_path: rollout_file.path().to_path_buf(), + worktree_path: None, }; chat.handle_codex_event(Event { diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 2951ca5b78..2c5cd30f18 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -378,6 +378,7 @@ pub(crate) fn new_session_info( history_entry_count: _, initial_messages: _, rollout_path: _, + worktree_path: _, } = event; if is_first_event { // Header box rendered as history (so it appears at the very top) diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 4c5e7ee97c..5d4863eb23 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -146,6 +146,7 @@ pub async fn run_main( include_view_image_tool: None, show_raw_agent_reasoning: cli.oss.then_some(true), tools_web_search_request: cli.web_search.then_some(true), + enable_git_worktree: None, }; let raw_overrides = cli.config_overrides.raw_overrides.clone(); let overrides_cli = codex_common::CliConfigOverrides { raw_overrides }; diff --git a/docs/config.md b/docs/config.md index ba204ee00a..66584128fd 100644 --- a/docs/config.md +++ b/docs/config.md @@ -644,6 +644,7 @@ notifications = [ "agent-turn-complete", "approval-requested" ] | `tui.notifications` | boolean \| array | Enable desktop notifications in the tui (default: false). | | `hide_agent_reasoning` | boolean | Hide model reasoning events. | | `show_raw_agent_reasoning` | boolean | Show raw reasoning (when available). | +| `[Experimental] enable_git_worktree` | boolean | Create a linked git worktree per session under `cwd/codex/` (default: false). | | `model_reasoning_effort` | `minimal` \| `low` \| `medium` \| `high` | Responses API reasoning effort. | | `model_reasoning_summary` | `auto` \| `concise` \| `detailed` \| `none` | Reasoning summaries. | | `model_verbosity` | `low` \| `medium` \| `high` | GPT‑5 text verbosity (Responses API). | @@ -656,3 +657,12 @@ notifications = [ "agent-turn-complete", "approval-requested" ] | `responses_originator_header_internal_override` | string | Override `originator` header value. | | `projects..trust_level` | string | Mark project/worktree as trusted (only `"trusted"` is recognized). | | `tools.web_search` | boolean | Enable web search tool (alias: `web_search_request`) (default: false). | + +#### Experimental git worktree mode + +Enabling `[Experimental] enable_git_worktree` creates a linked checkout at +`/codex/` via `git worktree add --detach HEAD`. The worktree starts in a +detached HEAD state so Codex never clashes with a branch you already have checked out. When you +want to commit, run `git switch -c ` (or attach to an existing branch) inside the worktree +and work as usual. Codex automatically appends `/codex/` to `.git/info/exclude` so these +directories stay out of `git status` without touching your tracked `.gitignore`.