mirror of
https://github.com/openai/codex.git
synced 2026-02-02 06:57:03 +00:00
Compare commits
4 Commits
input-vali
...
worktree-c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ffc4f74598 | ||
|
|
886fd9a7e9 | ||
|
|
d4750bc6fd | ||
|
|
103265bac6 |
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
355
codex-rs/core/src/git_worktree.rs
Normal file
355
codex-rs/core/src/git_worktree.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(_)
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
};
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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` | 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.<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`.
|
||||
|
||||
Reference in New Issue
Block a user