Compare commits

...

4 Commits

Author SHA1 Message Date
Ahmed Ibrahim
ffc4f74598 d 2025-09-29 10:03:12 -07:00
Ahmed Ibrahim
886fd9a7e9 Merge branch 'main' into worktree-core 2025-09-29 09:50:57 -07:00
Ahmed Ibrahim
d4750bc6fd core/tui: complete worktree integration (services) and update TUI test for new SessionConfiguredEvent field 2025-09-29 09:02:41 -07:00
Ahmed Ibrahim
103265bac6 core: add experimental per-session git worktree mode
Introduce WorktreeHandle to create/reuse/remove linked checkouts under <cwd>/codex/<conversation>; add config flag enable_git_worktree (and trust logic for worktrees); plumb protocol: RemoveWorktree, WorktreeRemoved, SessionConfigured.worktree_path; update exec/TUI integration; docs: experimental config section.

# Conflicts:
#	codex-rs/core/src/codex.rs
#	codex-rs/tui/src/chatwidget.rs
2025-09-29 08:58:37 -07:00
20 changed files with 531 additions and 2 deletions

View File

@@ -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/<conversation>`.
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<S: Into<String>>(&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<Option<PathBuf>> {
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<WorktreeHandle>,
Option<PathBuf>,
Option<Event>,
) {
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,
@@ -3483,6 +3577,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,

View File

@@ -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/<conversation>`.
pub enable_git_worktree: bool,
/// The active profile name used to derive this `Config` (if any).
pub active_profile: Option<String>,
@@ -689,6 +691,9 @@ pub struct ConfigToml {
/// Defaults to `false`.
pub show_raw_agent_reasoning: Option<bool>,
/// Enable per-session git worktree checkouts.
pub enable_git_worktree: Option<bool>,
pub model_reasoning_effort: Option<ReasoningEffort>,
pub model_reasoning_summary: Option<ReasoningSummary>,
/// Optional verbosity control for GPT-5 models (Responses API `text.verbosity`).
@@ -859,6 +864,7 @@ pub struct ConfigOverrides {
pub include_view_image_tool: Option<bool>,
pub show_raw_agent_reasoning: Option<bool>,
pub tools_web_search_request: Option<bool>,
pub enable_git_worktree: Option<bool>,
}
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(),

View File

@@ -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
/// `<repo_root>/codex/worktree/<conversation_id>`.
pub async fn create(repo_root: &Path, conversation_id: &ConversationId) -> Result<Self> {
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<bool> {
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<Item = &'a str>,
) -> Result<std::process::Output> {
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<Item = &'a str>,
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");
}
}

View File

@@ -29,6 +29,7 @@ mod exec_command;
pub mod exec_env;
mod flags;
pub mod git_info;
mod git_worktree;
pub mod landlock;
mod mcp_connection_manager;
mod mcp_tool_call;

View File

@@ -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,
}
}

View File

@@ -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<Option<RolloutRecorder>>,
pub(crate) worktree: Mutex<Option<WorktreeHandle>>,
pub(crate) codex_linux_sandbox_exe: Option<PathBuf>,
pub(crate) user_shell: crate::shell::Shell,
pub(crate) show_raw_agent_reasoning: bool,

View File

@@ -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) => {

View File

@@ -171,6 +171,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> 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() {

View File

@@ -57,6 +57,7 @@ fn session_configured_produces_session_created_event() {
history_entry_count: 0,
initial_messages: None,
rollout_path,
worktree_path: None,
}),
);
let out = ep.collect_conversation_events(&ev);

View File

@@ -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

View File

@@ -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

View File

@@ -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(_)

View File

@@ -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(),

View File

@@ -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<Vec<EventMsg>>,
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<PathBuf>,
}
#[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,
}),
};

View File

@@ -530,6 +530,7 @@ mod tests {
history_entry_count: 0,
initial_messages: None,
rollout_path: PathBuf::new(),
worktree_path: None,
};
Arc::new(new_session_info(
app.chat_widget.config_ref(),

View File

@@ -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;
@@ -593,6 +594,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));
@@ -1416,6 +1423,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)
}

View File

@@ -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 {

View File

@@ -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)

View File

@@ -140,6 +140,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 };

View File

@@ -644,6 +644,7 @@ notifications = [ "agent-turn-complete", "approval-requested" ]
| `tui.notifications` | boolean \| array<string> | 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/<conversation_id>` (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` | GPT5 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.<path>.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
`<cwd>/codex/<conversation_id>` 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 <branch>` (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`.