From d70c05423b3916a67fc1092b419e71792fc14c39 Mon Sep 17 00:00:00 2001 From: starr-openai Date: Wed, 1 Apr 2026 20:45:26 -0700 Subject: [PATCH] Add startup worktree MVP --- codex-rs/exec/src/cli.rs | 13 + codex-rs/exec/src/lib.rs | 123 ++++++++- codex-rs/git-utils/src/lib.rs | 3 + codex-rs/git-utils/src/worktree.rs | 384 +++++++++++++++++++++++++++++ codex-rs/tui/src/cli.rs | 19 ++ codex-rs/tui/src/lib.rs | 124 +++++++++- 6 files changed, 649 insertions(+), 17 deletions(-) create mode 100644 codex-rs/git-utils/src/worktree.rs diff --git a/codex-rs/exec/src/cli.rs b/codex-rs/exec/src/cli.rs index 2b5932dd56..4ee2c451a2 100644 --- a/codex-rs/exec/src/cli.rs +++ b/codex-rs/exec/src/cli.rs @@ -63,6 +63,10 @@ pub struct Cli { #[clap(long = "cd", short = 'C', value_name = "DIR")] pub cwd: Option, + /// Create a new local git worktree and start the session from that checkout. + #[arg(long = "worktree", default_value_t = false, global = true)] + pub worktree: bool, + /// Allow running Codex outside a Git repository. #[arg(long = "skip-git-repo-check", global = true, default_value_t = false)] pub skip_git_repo_check: bool, @@ -312,4 +316,13 @@ mod tests { assert_eq!(args.session_id.as_deref(), Some("session-123")); assert_eq!(args.prompt.as_deref(), Some(PROMPT)); } + + #[test] + fn parse_worktree_flag() { + let cli = Cli::parse_from(["codex-exec", "--worktree", "--cd", "/tmp/repo", "hello"]); + + assert!(cli.worktree); + assert_eq!(cli.cwd, Some(PathBuf::from("/tmp/repo"))); + assert_eq!(cli.prompt.as_deref(), Some("hello")); + } } diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index ae1285be4d..b4fe948ff3 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -66,6 +66,9 @@ use codex_core::config_loader::format_config_error_with_source; use codex_core::format_exec_policy_error_with_source; use codex_core::path_utils; use codex_feedback::CodexFeedback; +use codex_git_utils::CodexManagedWorktree; +use codex_git_utils::GitToolingError; +use codex_git_utils::create_codex_managed_worktree; use codex_git_utils::get_git_repo_root; use codex_otel::set_parent_from_context; use codex_otel::traceparent_context_from_env; @@ -165,6 +168,14 @@ struct ExecRunArgs { stderr_with_ansi: bool, } +#[derive(Debug, PartialEq, Eq)] +struct StartupCwd { + resolved_cwd: Option, + config_cwd: AbsolutePathBuf, +} + +type WorktreeCreator = fn(&Path, &Path) -> Result; + fn exec_root_span() -> tracing::Span { info_span!( "codex.exec", @@ -174,6 +185,33 @@ fn exec_root_span() -> tracing::Span { ) } +fn resolve_startup_cwd( + requested_cwd: Option, + codex_home: &Path, + worktree_creator: Option, +) -> anyhow::Result { + let config_cwd = match requested_cwd.as_deref() { + Some(path) => AbsolutePathBuf::from_absolute_path(path.canonicalize()?)?, + None => AbsolutePathBuf::current_dir()?, + }; + + let Some(worktree_creator) = worktree_creator else { + return Ok(StartupCwd { + resolved_cwd: requested_cwd, + config_cwd, + }); + }; + + let worktree = worktree_creator(config_cwd.as_path(), codex_home) + .map_err(|err| anyhow::anyhow!("failed to create worktree: {err}"))?; + let config_cwd = AbsolutePathBuf::from_absolute_path(&worktree.worktree_workspace_root)?; + + Ok(StartupCwd { + resolved_cwd: Some(worktree.worktree_workspace_root), + config_cwd, + }) +} + pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { if let Err(err) = set_default_originator("codex_exec".to_string()) { tracing::warn!(?err, "Failed to set codex exec originator override {err:?}"); @@ -189,6 +227,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result full_auto, dangerously_bypass_approvals_and_sandbox, cwd, + worktree, skip_git_repo_check, add_dir, ephemeral, @@ -201,6 +240,14 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result config_overrides, } = cli; + // Fail no-prompt stdin reads before creating a detached worktree so + // `codex-exec --worktree (true, true), cli::Color::Never => (false, false), @@ -240,12 +287,6 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result } }; - let resolved_cwd = cwd.clone(); - let config_cwd = match resolved_cwd.as_deref() { - Some(path) => AbsolutePathBuf::from_absolute_path(path.canonicalize()?)?, - None => AbsolutePathBuf::current_dir()?, - }; - // we load config.toml here to determine project state. #[allow(clippy::print_stderr)] let codex_home = match find_codex_home() { @@ -256,6 +297,15 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result } }; + let StartupCwd { + resolved_cwd, + config_cwd, + } = resolve_startup_cwd( + cwd, + codex_home.as_path(), + worktree.then_some(create_codex_managed_worktree), + )?; + #[allow(clippy::print_stderr)] let config_toml = match load_config_as_toml_with_cli_overrides( &codex_home, @@ -1699,6 +1749,67 @@ mod tests { ); } + #[test] + fn resolve_startup_cwd_uses_requested_cwd_without_worktree() { + let codex_home = tempdir().expect("create temp codex home"); + let cwd = tempdir().expect("create temp cwd"); + + let startup_cwd = resolve_startup_cwd( + Some(cwd.path().to_path_buf()), + codex_home.path(), + /*worktree_creator*/ None, + ) + .expect("resolve startup cwd"); + + assert_eq!( + startup_cwd, + StartupCwd { + resolved_cwd: Some(cwd.path().to_path_buf()), + config_cwd: AbsolutePathBuf::from_absolute_path( + cwd.path().canonicalize().expect("canonicalize cwd") + ) + .expect("absolute cwd"), + } + ); + } + + #[test] + fn resolve_startup_cwd_uses_worktree_workspace_root_when_enabled() { + let codex_home = tempdir().expect("create temp codex home"); + let cwd = tempdir().expect("create temp cwd"); + + let startup_cwd = resolve_startup_cwd( + Some(cwd.path().to_path_buf()), + codex_home.path(), + Some(|source_cwd, codex_home| { + let worktree_git_root = codex_home.join("worktrees/fake/project"); + let worktree_git_dir = worktree_git_root.join(".git"); + let marker_path = worktree_git_dir.join("codex-managed"); + Ok(CodexManagedWorktree { + source_cwd: source_cwd.to_path_buf(), + source_repo_root: source_cwd.to_path_buf(), + worktree_git_root: worktree_git_root.clone(), + worktree_git_dir, + worktree_workspace_root: worktree_git_root.join("nested/path"), + starting_ref: "main".to_string(), + marker_path, + }) + }), + ) + .expect("resolve startup cwd"); + + let expected_worktree_workspace_root = + codex_home.path().join("worktrees/fake/project/nested/path"); + assert_eq!( + startup_cwd, + StartupCwd { + resolved_cwd: Some(expected_worktree_workspace_root.clone()), + config_cwd: AbsolutePathBuf::from_absolute_path(&expected_worktree_workspace_root) + .expect("absolute worktree cwd"), + } + ); + } + #[test] fn builds_uncommitted_review_request() { let args = ReviewArgs { diff --git a/codex-rs/git-utils/src/lib.rs b/codex-rs/git-utils/src/lib.rs index 8f188e4bf0..d74a4de239 100644 --- a/codex-rs/git-utils/src/lib.rs +++ b/codex-rs/git-utils/src/lib.rs @@ -8,6 +8,7 @@ mod ghost_commits; mod info; mod operations; mod platform; +mod worktree; pub use apply::ApplyGitRequest; pub use apply::ApplyGitResult; @@ -49,6 +50,8 @@ use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; use ts_rs::TS; +pub use worktree::CodexManagedWorktree; +pub use worktree::create_codex_managed_worktree; type CommitID = String; diff --git a/codex-rs/git-utils/src/worktree.rs b/codex-rs/git-utils/src/worktree.rs new file mode 100644 index 0000000000..b8c5c5340a --- /dev/null +++ b/codex-rs/git-utils/src/worktree.rs @@ -0,0 +1,384 @@ +use std::collections::hash_map::DefaultHasher; +use std::ffi::OsString; +use std::fs; +use std::hash::Hash; +use std::hash::Hasher; +use std::io::Write; +use std::path::Path; +use std::path::PathBuf; +use std::sync::atomic::AtomicU64; +use std::sync::atomic::Ordering; +use std::time::SystemTime; + +use crate::GitToolingError; +use crate::operations::ensure_git_repository; +use crate::operations::repo_subdir; +use crate::operations::resolve_head; +use crate::operations::resolve_repository_root; +use crate::operations::run_git_for_status; +use crate::operations::run_git_for_stdout; + +static WORKTREE_BUCKET_COUNTER: AtomicU64 = AtomicU64::new(0); + +pub const CODEX_MANAGED_WORKTREE_MARKER_FILE: &str = "codex-managed"; + +/// Metadata for a detached worktree created under `$CODEX_HOME/worktrees`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CodexManagedWorktree { + pub source_cwd: PathBuf, + pub source_repo_root: PathBuf, + pub worktree_git_root: PathBuf, + pub worktree_git_dir: PathBuf, + pub worktree_workspace_root: PathBuf, + pub starting_ref: String, + pub marker_path: PathBuf, +} + +/// Creates a detached worktree for `source_cwd` and returns the mapped cwd +/// inside the new checkout. +pub fn create_codex_managed_worktree( + source_cwd: &Path, + codex_home: &Path, +) -> Result { + ensure_git_repository(source_cwd)?; + + let source_repo_root = resolve_repository_root(source_cwd)?; + let source_cwd = source_cwd.to_path_buf(); + let relative_cwd = repo_subdir(&source_repo_root, &source_cwd); + + let starting_ref = starting_ref_for_repo(source_repo_root.as_path())?; + let worktree_git_root = allocate_worktree_root(codex_home, source_repo_root.as_path())?; + + create_worktree_checkout( + source_repo_root.as_path(), + &worktree_git_root, + &starting_ref, + )?; + let setup_result = setup_worktree_checkout(&worktree_git_root); + let (worktree_git_dir, marker_path) = match setup_result { + Ok(setup) => setup, + Err(err) => { + cleanup_worktree_checkout(source_repo_root.as_path(), &worktree_git_root); + return Err(err); + } + }; + + let worktree_workspace_root = match relative_cwd { + Some(relative) => worktree_git_root.join(relative), + None => worktree_git_root.clone(), + }; + + Ok(CodexManagedWorktree { + source_cwd, + source_repo_root, + worktree_git_root, + worktree_git_dir, + worktree_workspace_root, + starting_ref, + marker_path, + }) +} + +fn starting_ref_for_repo(repo_root: &Path) -> Result { + let branch = run_git_for_stdout( + repo_root, + vec![OsString::from("branch"), OsString::from("--show-current")], + None, + )?; + if !branch.is_empty() { + return Ok(branch); + } + + match resolve_head(repo_root)? { + Some(head) => Ok(head), + None => Ok(String::from("HEAD")), + } +} + +fn allocate_worktree_root( + codex_home: &Path, + source_repo_root: &Path, +) -> Result { + let repo_name = source_repo_root + .file_name() + .and_then(|name| name.to_str()) + .filter(|name| !name.is_empty()) + .unwrap_or("repo"); + let worktrees_root = codex_home.join("worktrees"); + fs::create_dir_all(&worktrees_root)?; + + for _ in 0..64 { + let bucket = next_worktree_bucket(source_repo_root); + let candidate = worktrees_root.join(bucket).join(repo_name); + if candidate.exists() { + continue; + } + if let Some(parent) = candidate.parent() { + fs::create_dir_all(parent)?; + } + return Ok(candidate); + } + + Err(GitToolingError::Io(std::io::Error::new( + std::io::ErrorKind::AlreadyExists, + "unable to allocate a unique codex worktree path", + ))) +} + +fn next_worktree_bucket(source_repo_root: &Path) -> String { + let mut hasher = DefaultHasher::new(); + source_repo_root.hash(&mut hasher); + std::process::id().hash(&mut hasher); + SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + .hash(&mut hasher); + WORKTREE_BUCKET_COUNTER + .fetch_add(1, Ordering::Relaxed) + .hash(&mut hasher); + format!("{:04x}", (hasher.finish() & 0xffff) as u16) +} + +fn create_worktree_checkout( + source_repo_root: &Path, + worktree_git_root: &Path, + starting_ref: &str, +) -> Result<(), GitToolingError> { + let result = run_git_for_status( + source_repo_root, + vec![ + OsString::from("worktree"), + OsString::from("add"), + OsString::from("--detach"), + OsString::from(worktree_git_root.as_os_str()), + OsString::from(starting_ref), + ], + None, + ); + + if let Err(err) = result { + let _ = fs::remove_dir_all(worktree_git_root); + return Err(err); + } + + Ok(()) +} + +fn setup_worktree_checkout( + worktree_git_root: &Path, +) -> Result<(PathBuf, PathBuf), GitToolingError> { + let worktree_git_dir = worktree_git_dir(worktree_git_root)?; + let marker_path = write_codex_managed_marker(&worktree_git_dir)?; + Ok((worktree_git_dir, marker_path)) +} + +fn cleanup_worktree_checkout(source_repo_root: &Path, worktree_git_root: &Path) { + let _ = run_git_for_status( + source_repo_root, + vec![ + OsString::from("worktree"), + OsString::from("remove"), + OsString::from("--force"), + OsString::from(worktree_git_root.as_os_str()), + ], + /*env*/ None, + ); + let _ = fs::remove_dir_all(worktree_git_root); +} + +fn write_codex_managed_marker(worktree_git_dir: &Path) -> Result { + let marker_path = worktree_git_dir.join(CODEX_MANAGED_WORKTREE_MARKER_FILE); + let mut marker = fs::File::create(&marker_path)?; + marker.write_all(b"codex-managed\n")?; + Ok(marker_path) +} + +fn worktree_git_dir(worktree_git_root: &Path) -> Result { + let git_dir = run_git_for_stdout( + worktree_git_root, + vec![OsString::from("rev-parse"), OsString::from("--git-dir")], + None, + )?; + let git_dir = PathBuf::from(git_dir); + if git_dir.is_absolute() { + Ok(git_dir) + } else { + Ok(worktree_git_root.join(git_dir)) + } +} + +#[cfg(test)] +mod tests { + use super::CODEX_MANAGED_WORKTREE_MARKER_FILE; + use super::CodexManagedWorktree; + use super::allocate_worktree_root; + use super::cleanup_worktree_checkout; + use super::create_codex_managed_worktree; + use super::create_worktree_checkout; + use super::starting_ref_for_repo; + use crate::GitToolingError; + #[cfg(unix)] + use crate::platform::create_symlink; + use pretty_assertions::assert_eq; + use std::fs; + use std::path::Path; + use std::path::PathBuf; + use std::process::Command; + use tempfile::tempdir; + + fn run_git_in(repo_path: &Path, args: &[&str]) { + let status = Command::new("git") + .current_dir(repo_path) + .args(args) + .status() + .expect("git command"); + assert!(status.success(), "git command failed: {args:?}"); + } + + fn git_stdout_in(repo_path: &Path, args: &[&str]) -> String { + let output = Command::new("git") + .current_dir(repo_path) + .args(args) + .output() + .expect("git command"); + assert!(output.status.success(), "git command failed: {args:?}"); + String::from_utf8(output.stdout).expect("git stdout utf8") + } + + fn init_test_repo(repo_path: &Path) { + run_git_in(repo_path, &["init", "--initial-branch=main"]); + run_git_in(repo_path, &["config", "core.autocrlf", "false"]); + run_git_in(repo_path, &["config", "user.name", "Tester"]); + run_git_in(repo_path, &["config", "user.email", "test@example.com"]); + } + + fn commit(repo_path: &Path, message: &str) { + run_git_in(repo_path, &["add", "."]); + run_git_in( + repo_path, + &[ + "-c", + "user.name=Tester", + "-c", + "user.email=test@example.com", + "commit", + "-m", + message, + ], + ); + } + + fn create_repo_with_nested_cwd() -> (tempfile::TempDir, PathBuf, PathBuf) { + let temp = tempdir().expect("tempdir"); + let repo = temp.path().join("repo"); + let nested = repo.join("nested").join("path"); + fs::create_dir_all(&nested).expect("nested dir"); + init_test_repo(&repo); + fs::write(repo.join("README.md"), "hello\n").expect("write file"); + fs::write(nested.join("marker.txt"), "nested\n").expect("write nested file"); + commit(&repo, "initial"); + (temp, repo, nested) + } + + fn assert_worktree_result( + result: &CodexManagedWorktree, + codex_home: &Path, + repo: &Path, + nested: &Path, + ) { + let expected_repo_root = repo.canonicalize().expect("repo canonicalized"); + assert_eq!(result.source_repo_root, expected_repo_root); + assert_eq!( + result.worktree_workspace_root, + result.worktree_git_root.join("nested/path") + ); + assert_eq!(result.source_cwd, nested); + assert!( + result + .worktree_git_root + .starts_with(codex_home.join("worktrees")) + ); + assert!(result.worktree_git_dir.exists()); + assert_eq!( + result.marker_path, + result + .worktree_git_dir + .join(CODEX_MANAGED_WORKTREE_MARKER_FILE) + ); + } + + #[test] + fn create_codex_managed_worktree_preserves_nested_cwd_mapping() -> Result<(), GitToolingError> { + let (_temp, repo, nested) = create_repo_with_nested_cwd(); + let codex_home = tempdir().expect("codex home"); + + let result = create_codex_managed_worktree(&nested, codex_home.path())?; + + assert_worktree_result(&result, codex_home.path(), &repo, &nested); + assert!(result.worktree_workspace_root.exists()); + Ok(()) + } + + #[test] + #[cfg(unix)] + fn create_codex_managed_worktree_preserves_nested_cwd_mapping_from_symlink() + -> Result<(), GitToolingError> { + let (temp, repo, _nested) = create_repo_with_nested_cwd(); + let repo_symlink = temp.path().join("repo-symlink"); + create_symlink(&repo, &repo, &repo_symlink)?; + let symlinked_nested = repo_symlink.join("nested/path"); + let codex_home = tempdir().expect("codex home"); + + let result = create_codex_managed_worktree(&symlinked_nested, codex_home.path())?; + + assert_eq!( + result.worktree_workspace_root, + result.worktree_git_root.join("nested/path") + ); + assert_eq!(result.source_cwd, symlinked_nested); + Ok(()) + } + + #[test] + fn create_codex_managed_worktree_writes_marker_file() -> Result<(), GitToolingError> { + let (_temp, repo, nested) = create_repo_with_nested_cwd(); + let codex_home = tempdir().expect("codex home"); + + let result = create_codex_managed_worktree(&nested, codex_home.path())?; + + let marker = fs::read_to_string(&result.marker_path)?; + assert_eq!(marker, "codex-managed\n"); + assert_eq!( + result.marker_path, + result + .worktree_git_dir + .join(CODEX_MANAGED_WORKTREE_MARKER_FILE) + ); + assert!(repo.exists()); + Ok(()) + } + + #[test] + fn cleanup_worktree_checkout_removes_worktree_registration() -> Result<(), GitToolingError> { + let (_temp, repo, _nested) = create_repo_with_nested_cwd(); + let codex_home = tempdir().expect("codex home"); + let starting_ref = starting_ref_for_repo(&repo)?; + let worktree_git_root = allocate_worktree_root(codex_home.path(), &repo)?; + create_worktree_checkout(&repo, &worktree_git_root, &starting_ref)?; + assert!(worktree_git_root.exists()); + assert!( + git_stdout_in(&repo, &["worktree", "list", "--porcelain"]) + .contains(&worktree_git_root.to_string_lossy().to_string()) + ); + + cleanup_worktree_checkout(&repo, &worktree_git_root); + + assert!(!worktree_git_root.exists()); + assert!( + !git_stdout_in(&repo, &["worktree", "list", "--porcelain"]) + .contains(&worktree_git_root.to_string_lossy().to_string()) + ); + Ok(()) + } +} diff --git a/codex-rs/tui/src/cli.rs b/codex-rs/tui/src/cli.rs index e3087af2ae..194af85951 100644 --- a/codex-rs/tui/src/cli.rs +++ b/codex-rs/tui/src/cli.rs @@ -98,6 +98,10 @@ pub struct Cli { #[clap(long = "cd", short = 'C', value_name = "DIR")] pub cwd: Option, + /// Create a new local git worktree and start the session from that checkout. + #[arg(long = "worktree", default_value_t = false)] + pub worktree: bool, + /// Enable live web search. When enabled, the native Responses `web_search` tool is available to the model (no per‑call approval). #[arg(long = "search", default_value_t = false)] pub web_search: bool, @@ -117,3 +121,18 @@ pub struct Cli { #[clap(skip)] pub config_overrides: CliConfigOverrides, } + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn parse_worktree_flag() { + let cli = Cli::parse_from(["codex-tui", "--worktree", "--cd", "/tmp/repo", "hello"]); + + assert!(cli.worktree); + assert_eq!(cli.cwd, Some(PathBuf::from("/tmp/repo"))); + assert_eq!(cli.prompt.as_deref(), Some("hello")); + } +} diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index df52f28fa5..709da092a4 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -40,6 +40,9 @@ use codex_core::path_utils; use codex_core::read_session_meta_line; use codex_core::state_db::get_state_db; use codex_core::windows_sandbox::WindowsSandboxLevelExt; +use codex_git_utils::CodexManagedWorktree; +use codex_git_utils::GitToolingError; +use codex_git_utils::create_codex_managed_worktree; use codex_protocol::ThreadId; use codex_protocol::config_types::AltScreenMode; use codex_protocol::config_types::SandboxMode; @@ -79,10 +82,9 @@ mod app_event; mod app_event_sender; mod app_server_session; mod ascii_animation; -#[cfg(not(target_os = "linux"))] +#[cfg(all(not(target_os = "linux"), feature = "voice-input"))] mod audio_device; -#[cfg(target_os = "linux")] -#[allow(dead_code)] +#[cfg(all(not(target_os = "linux"), not(feature = "voice-input")))] mod audio_device { use crate::app_event::RealtimeAudioDeviceKind; @@ -152,10 +154,9 @@ pub mod update_action; mod update_prompt; mod updates; mod version; -#[cfg(not(target_os = "linux"))] +#[cfg(all(not(target_os = "linux"), feature = "voice-input"))] mod voice; -#[cfg(target_os = "linux")] -#[allow(dead_code)] +#[cfg(all(not(target_os = "linux"), not(feature = "voice-input")))] mod voice { use crate::app_event_sender::AppEventSender; use codex_core::config::Config; @@ -221,6 +222,14 @@ use crate::onboarding::onboarding_screen::run_onboarding_app; use crate::tui::Tui; pub use cli::Cli; use codex_arg0::Arg0DispatchPaths; + +#[derive(Debug, PartialEq, Eq)] +struct StartupCwd { + cwd: Option, + config_cwd: AbsolutePathBuf, +} + +type WorktreeCreator = fn(&Path, &Path) -> Result; pub use markdown_render::render_markdown_text; pub use public_widgets::composer_input::ComposerAction; pub use public_widgets::composer_input::ComposerInput; @@ -586,6 +595,33 @@ fn latest_session_lookup_params( } } +fn resolve_startup_cwd( + requested_cwd: Option, + codex_home: &Path, + worktree_creator: Option, +) -> std::io::Result { + let config_cwd = match requested_cwd.as_deref() { + Some(path) => AbsolutePathBuf::from_absolute_path(path.canonicalize()?)?, + None => AbsolutePathBuf::current_dir()?, + }; + + let Some(worktree_creator) = worktree_creator else { + return Ok(StartupCwd { + cwd: requested_cwd, + config_cwd, + }); + }; + + let worktree = worktree_creator(config_cwd.as_path(), codex_home) + .map_err(|err| std::io::Error::other(format!("Error creating worktree: {err}")))?; + let config_cwd = AbsolutePathBuf::from_absolute_path(&worktree.worktree_workspace_root)?; + + Ok(StartupCwd { + cwd: Some(worktree.worktree_workspace_root), + config_cwd, + }) +} + pub async fn run_main( mut cli: Cli, arg0_paths: Arg0DispatchPaths, @@ -604,6 +640,11 @@ pub async fn run_main( auth_token: remote_auth_token.clone(), }) .unwrap_or(AppServerTarget::Embedded); + if cli.worktree && matches!(app_server_target, AppServerTarget::Remote { .. }) { + return Err(std::io::Error::other( + "--worktree is only supported for local Codex sessions", + )); + } let (sandbox_mode, approval_policy) = if cli.full_auto { ( Some(SandboxMode::WorkspaceWrite), @@ -653,11 +694,11 @@ pub async fn run_main( } }; - let cwd = cli.cwd.clone(); - let config_cwd = match cwd.as_deref() { - Some(path) => AbsolutePathBuf::from_absolute_path(path.canonicalize()?)?, - None => AbsolutePathBuf::current_dir()?, - }; + let StartupCwd { cwd, config_cwd } = resolve_startup_cwd( + cli.cwd.clone(), + codex_home.as_path(), + cli.worktree.then_some(create_codex_managed_worktree), + )?; #[allow(clippy::print_stderr)] let config_toml = match load_config_as_toml_with_cli_overrides( @@ -1701,6 +1742,67 @@ mod tests { ); } + #[test] + fn resolve_startup_cwd_uses_requested_cwd_without_worktree() { + let codex_home = TempDir::new().expect("create temp codex home"); + let cwd = TempDir::new().expect("create temp cwd"); + + let startup_cwd = resolve_startup_cwd( + Some(cwd.path().to_path_buf()), + codex_home.path(), + /*worktree_creator*/ None, + ) + .expect("resolve startup cwd"); + + assert_eq!( + startup_cwd, + StartupCwd { + cwd: Some(cwd.path().to_path_buf()), + config_cwd: AbsolutePathBuf::from_absolute_path( + &cwd.path().canonicalize().expect("canonicalize cwd") + ) + .expect("absolute cwd"), + } + ); + } + + #[test] + fn resolve_startup_cwd_uses_worktree_workspace_root_when_enabled() { + let codex_home = TempDir::new().expect("create temp codex home"); + let cwd = TempDir::new().expect("create temp cwd"); + + let startup_cwd = resolve_startup_cwd( + Some(cwd.path().to_path_buf()), + codex_home.path(), + Some(|source_cwd, codex_home| { + let worktree_git_root = codex_home.join("worktrees/fake/project"); + let worktree_git_dir = worktree_git_root.join(".git"); + let marker_path = worktree_git_dir.join("codex-managed"); + Ok(CodexManagedWorktree { + source_cwd: source_cwd.to_path_buf(), + source_repo_root: source_cwd.to_path_buf(), + worktree_git_root: worktree_git_root.clone(), + worktree_git_dir, + worktree_workspace_root: worktree_git_root.join("nested/path"), + starting_ref: "main".to_string(), + marker_path, + }) + }), + ) + .expect("resolve startup cwd"); + + let expected_worktree_workspace_root = + codex_home.path().join("worktrees/fake/project/nested/path"); + assert_eq!( + startup_cwd, + StartupCwd { + cwd: Some(expected_worktree_workspace_root.clone()), + config_cwd: AbsolutePathBuf::from_absolute_path(&expected_worktree_workspace_root) + .expect("absolute worktree cwd"), + } + ); + } + #[test] fn normalize_remote_addr_accepts_secure_websocket_url() { assert_eq!(