diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 55d130f749..5883b4019e 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -3037,6 +3037,7 @@ dependencies = [ "codex-cloud-config", "codex-config", "codex-core", + "codex-features", "codex-feedback", "codex-git-utils", "codex-history", @@ -3050,6 +3051,7 @@ dependencies = [ "codex-utils-cli", "codex-utils-oss", "codex-utils-sandbox-summary", + "codex-worktree", "core_test_support", "libc", "opentelemetry", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 3746877dba..3868eada9b 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -290,6 +290,7 @@ codex-utils-pty = { path = "utils/pty" } codex-utils-redacted-string = { path = "utils/redacted-string" } codex-utils-rustls-provider = { path = "utils/rustls-provider" } codex-utils-sandbox-summary = { path = "utils/sandbox-summary" } +codex-worktree = { path = "worktree" } codex-utils-sleep-inhibitor = { path = "utils/sleep-inhibitor" } codex-utils-stream-parser = { path = "utils/stream-parser" } codex-utils-string = { path = "utils/string" } diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 928e08b217..e339cc2ea1 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -1060,6 +1060,7 @@ async fn cli_main( mut interactive, subcommand, } = MultitoolCli::parse(); + reject_unsupported_worktree_for_subcommand(interactive.shared.worktree, &subcommand)?; // Fold --enable/--disable into config overrides so they flow to all subcommands. let toggle_overrides = feature_toggles.to_overrides()?; root_config_overrides.raw_overrides.extend(toggle_overrides); @@ -2395,6 +2396,42 @@ fn reject_remote_mode_for_subcommand( Ok(()) } +fn reject_unsupported_worktree_for_subcommand( + root_worktree: bool, + subcommand: &Option, +) -> anyhow::Result<()> { + let subcommand_worktree = match subcommand { + Some(Subcommand::Exec(command)) => command.shared.worktree, + Some(Subcommand::Resume(command)) => command.config_overrides.0.shared.worktree, + Some(Subcommand::Fork(command)) => command.config_overrides.0.shared.worktree, + Some(Subcommand::Archive(command)) | Some(Subcommand::Unarchive(command)) => { + command.config_overrides.shared.worktree + } + Some(Subcommand::Delete(command)) => command.session.config_overrides.shared.worktree, + Some(Subcommand::Queue(command)) => command.config_overrides.shared.worktree, + _ => false, + }; + + if !root_worktree && !subcommand_worktree { + return Ok(()); + } + + match subcommand { + Some(Subcommand::Exec(command)) => match &command.command { + None | Some(ExecCommand::Fork(_)) => Ok(()), + Some(ExecCommand::Resume(_)) => anyhow::bail!( + "`--worktree` cannot resume an existing session; use `codex exec fork --worktree`" + ), + Some(ExecCommand::Review(_)) => { + anyhow::bail!("`--worktree` is not supported for code review") + } + }, + _ => { + anyhow::bail!("`--worktree` currently supports only `codex exec` and `codex exec fork`") + } + } +} + fn reject_root_strict_config_for_subcommand( strict_config: bool, subcommand: &Option, @@ -3211,6 +3248,66 @@ mod tests { ); } + #[test] + fn exec_worktree_flag_supports_root_local_and_nested_fork_positions() { + let arguments = [ + vec!["codex", "--worktree", "exec", "hello"], + vec!["codex", "exec", "--worktree", "hello"], + vec![ + "codex", + "exec", + "fork", + "--worktree", + "019f1234-5678-7000-8000-000000000001", + ], + ]; + + for arguments in arguments { + let cli = MultitoolCli::try_parse_from(&arguments).expect("parse worktree command"); + assert!( + reject_unsupported_worktree_for_subcommand( + cli.interactive.shared.worktree, + &cli.subcommand, + ) + .is_ok(), + "headless worktree command should be accepted: {arguments:?}", + ); + } + } + + #[test] + fn worktree_flag_rejects_unsupported_session_and_management_commands() { + let arguments = [ + vec!["codex", "--worktree"], + vec!["codex", "--worktree", "login"], + vec!["codex", "exec", "resume", "--worktree", "session"], + vec!["codex", "exec", "review", "--worktree"], + vec!["codex", "resume", "--worktree", "session"], + vec!["codex", "archive", "session", "--worktree"], + vec![ + "codex", + "queue", + "--thread", + "session", + "--message", + "hi", + "--worktree", + ], + ]; + + for arguments in arguments { + let cli = MultitoolCli::try_parse_from(&arguments).expect("parse shared worktree flag"); + assert!( + reject_unsupported_worktree_for_subcommand( + cli.interactive.shared.worktree, + &cli.subcommand, + ) + .is_err(), + "unsupported command must be rejected: {arguments:?}", + ); + } + } + #[test] fn exec_resume_last_accepts_prompt_positional() { let cli = diff --git a/codex-rs/cli/src/queue_cmd.rs b/codex-rs/cli/src/queue_cmd.rs index d096598237..c8bf9f707d 100644 --- a/codex-rs/cli/src/queue_cmd.rs +++ b/codex-rs/cli/src/queue_cmd.rs @@ -23,7 +23,7 @@ pub(crate) struct QueueCommand { remote: InteractiveRemoteOptions, #[clap(flatten)] - config_overrides: SessionArchiveConfigOverrides, + pub(crate) config_overrides: SessionArchiveConfigOverrides, } pub(crate) async fn run_queue_command( diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 5fc55ff225..6f912f3b2a 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -1056,6 +1056,9 @@ "workspace_owner_usage_nudge": { "type": "boolean" }, + "worktrees": { + "type": "boolean" + }, "write_stdin_approval": { "type": "boolean" } @@ -6407,6 +6410,9 @@ "workspace_owner_usage_nudge": { "type": "boolean" }, + "worktrees": { + "type": "boolean" + }, "write_stdin_approval": { "type": "boolean" } diff --git a/codex-rs/exec/Cargo.toml b/codex-rs/exec/Cargo.toml index 83da982309..a8ced9cdd7 100644 --- a/codex-rs/exec/Cargo.toml +++ b/codex-rs/exec/Cargo.toml @@ -30,6 +30,7 @@ codex-app-server-protocol = { workspace = true } codex-cloud-config = { workspace = true } codex-config = { workspace = true } codex-core = { workspace = true } +codex-features = { workspace = true } codex-feedback = { workspace = true } codex-git-utils = { workspace = true } codex-history = { workspace = true } @@ -42,6 +43,7 @@ codex-utils-absolute-path = { workspace = true } codex-utils-cli = { workspace = true } codex-utils-oss = { workspace = true } codex-utils-sandbox-summary = { workspace = true } +codex-worktree = { workspace = true } owo-colors = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } diff --git a/codex-rs/exec/src/cli.rs b/codex-rs/exec/src/cli.rs index 7e2f35e2af..0ef0be6af9 100644 --- a/codex-rs/exec/src/cli.rs +++ b/codex-rs/exec/src/cli.rs @@ -143,6 +143,7 @@ fn mark_exec_global_args(cmd: clap::Command) -> clap::Command { arg.global(true) }) .mut_arg("bypass_hook_trust", |arg| arg.global(true)) + .mut_arg("worktree", |arg| arg.global(true)) } #[derive(Debug, clap::Subcommand)] diff --git a/codex-rs/exec/src/cli_tests.rs b/codex-rs/exec/src/cli_tests.rs index 304fc8072f..423935904a 100644 --- a/codex-rs/exec/src/cli_tests.rs +++ b/codex-rs/exec/src/cli_tests.rs @@ -127,3 +127,21 @@ fn approve_for_me_flag_conflicts_with_other_sandbox_modes() { assert_eq!(error.kind(), clap::error::ErrorKind::ArgumentConflict); } } + +#[test] +fn worktree_flag_is_accepted_after_fork_subcommand() { + let cli = Cli::try_parse_from(["codex-exec", "fork", "session-id", "--worktree"]) + .expect("worktree should be a global exec argument"); + + assert!(cli.worktree); + assert!(matches!(cli.command, Some(Command::Fork(_)))); +} + +#[test] +fn worktree_flag_is_accepted_before_fork_subcommand() { + let cli = Cli::try_parse_from(["codex-exec", "--worktree", "fork", "session-id"]) + .expect("worktree should be accepted before the fork subcommand"); + + assert!(cli.worktree); + assert!(matches!(cli.command, Some(Command::Fork(_)))); +} diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index aa03a05a9b..50f3311431 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -76,6 +76,7 @@ use codex_core::find_thread_meta_by_name_str; use codex_core::format_exec_policy_error_with_source; use codex_core::path_utils; use codex_core::read_session_meta_line; +use codex_features::Feature; use codex_feedback::CodexFeedback; use codex_git_utils::get_git_repo_root; use codex_history::RolloutItem; @@ -105,6 +106,9 @@ use codex_utils_absolute_path::canonicalize_existing_preserving_symlinks; use codex_utils_cli::SharedCliOptions; use codex_utils_oss::ensure_oss_provider_ready; use codex_utils_oss::get_default_model_for_oss_provider; +use codex_worktree::CreateWorktree; +use codex_worktree::WorktreeManager; +use codex_worktree::WorktreeSettings; use event_processor_with_human_output::EventProcessorWithHumanOutput; pub use event_processor_with_jsonl_output::CodexStatus; pub use event_processor_with_jsonl_output::CollectedThreadEvents; @@ -218,6 +222,7 @@ struct ExecRunArgs { json_mode: bool, last_message_file: Option, model_provider: Option, + managed_worktree: Option, oss: bool, output_schema_path: Option, prompt: Option, @@ -226,6 +231,11 @@ struct ExecRunArgs { thread_source: ThreadSource, } +struct ManagedExecWorktree { + manager: WorktreeManager, + checkout: PathBuf, +} + fn exec_root_span() -> tracing::Span { info_span!( "codex.exec", @@ -277,9 +287,28 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result dangerously_bypass_approvals_and_sandbox, bypass_hook_trust, cwd, - add_dir, + mut add_dir, + worktree, } = shared; + if worktree { + if ignore_user_config { + anyhow::bail!("--worktree cannot be combined with --ignore-user-config"); + } + if ephemeral { + anyhow::bail!("--worktree cannot be combined with --ephemeral"); + } + match command.as_ref() { + Some(ExecCommand::Resume(_)) => { + anyhow::bail!("--worktree is not supported with `codex exec resume`"); + } + Some(ExecCommand::Review(_)) => { + anyhow::bail!("--worktree is not supported with `codex exec review`"); + } + Some(ExecCommand::Fork(_)) | None => {} + } + } + let (_stdout_with_ansi, stderr_with_ansi) = match color { cli::Color::Always => (true, true), cli::Color::Never => (false, false), @@ -309,8 +338,8 @@ 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() { + let mut resolved_cwd = cwd.clone(); + let mut config_cwd = match resolved_cwd.as_deref() { Some(path) => { AbsolutePathBuf::from_absolute_path(canonicalize_existing_preserving_symlinks(path)?)? } @@ -337,6 +366,77 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result ..Default::default() }; + if worktree + && EnvironmentManager::prepare_from_codex_home(&codex_home) + .await? + .default_environment_is_remote() + { + anyhow::bail!("--worktree requires local execution"); + } + + let managed_worktree = if worktree { + let gate_bootstrap = load_bootstrap_config_or_exit( + &codex_home, + /*cwd*/ None, + cli_kv_overrides.clone(), + loader_overrides.clone(), + strict_config, + CloudConfigBundleLoader::default(), + ) + .await; + let gate_cloud_config = cloud_config_bundle_loader_for_storage( + bootstrap_auth_config(&codex_home, &gate_bootstrap)?, + /*enable_codex_api_key_env*/ false, + ) + .await?; + let gate_config = ConfigBuilder::default() + .codex_home(codex_home.to_path_buf()) + .cli_overrides(cli_kv_overrides.clone()) + .loader_overrides(LoaderOverrides { + ignore_project_config: true, + ..loader_overrides.clone() + }) + .fallback_cwd(Some(config_cwd.to_path_buf())) + .strict_config(strict_config) + .cloud_config_bundle(gate_cloud_config) + .build() + .await?; + if !gate_config.features.enabled(Feature::Worktrees) { + anyhow::bail!( + "--worktree requires the worktrees feature; enable it with --enable worktrees" + ); + } + for path in &mut add_dir { + if path.is_relative() { + *path = config_cwd.as_path().join(&*path); + } + } + // Allocation belongs to the host, not this session's project, profile, or overrides. + let host_config = load_bootstrap_config_or_exit( + &codex_home, + /*cwd*/ None, + Vec::new(), + LoaderOverrides::default(), + strict_config, + CloudConfigBundleLoader::default(), + ) + .await; + let settings = + WorktreeSettings::for_cli(&codex_home, host_config.config_toml.desktop.as_ref())?; + let manager = WorktreeManager::new(settings); + let checkout = manager.create(&CreateWorktree { + source_cwd: config_cwd.as_path().to_path_buf(), + base: None, + })?; + resolved_cwd = Some(checkout.cwd.clone()); + config_cwd = AbsolutePathBuf::from_absolute_path(checkout.cwd.clone())?; + Some(ManagedExecWorktree { + manager, + checkout: checkout.root, + }) + } else { + None + }; let bootstrap_config = load_bootstrap_config_or_exit( &codex_home, Some(&config_cwd), @@ -571,6 +671,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result json_mode, last_message_file, model_provider, + managed_worktree, oss, output_schema_path, prompt, @@ -670,6 +771,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { json_mode, last_message_file, model_provider, + managed_worktree, oss, output_schema_path, prompt, @@ -923,6 +1025,12 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { (session_configured.thread_id, session_configured) }; + if let Some(worktree) = managed_worktree.as_ref() { + worktree + .manager + .bind_thread(&worktree.checkout, &primary_thread_id.to_string())?; + } + let primary_thread_id_for_span = primary_thread_id.to_string(); // Use the start/resume response as the authoritative bootstrap payload. // Waiting for a later streamed `SessionConfigured` event adds up to 10s of diff --git a/codex-rs/exec/tests/suite/mod.rs b/codex-rs/exec/tests/suite/mod.rs index b0ac5fa042..4f7a3ab89f 100644 --- a/codex-rs/exec/tests/suite/mod.rs +++ b/codex-rs/exec/tests/suite/mod.rs @@ -17,3 +17,4 @@ mod sandbox; #[cfg(target_os = "macos")] mod seatbelt; mod server_error_exit; +mod worktree; diff --git a/codex-rs/exec/tests/suite/worktree.rs b/codex-rs/exec/tests/suite/worktree.rs new file mode 100644 index 0000000000..2a924a3764 --- /dev/null +++ b/codex-rs/exec/tests/suite/worktree.rs @@ -0,0 +1,277 @@ +//! Exercises gated shared-pool execution and preserves existing checkout ownership. +//! Observe ownership at the first model request, before the process can finish. + +use anyhow::Context; +use codex_utils_absolute_path::AbsolutePathBuf; +use codex_worktree::CreateWorktree; +use codex_worktree::WorktreeManager; +use codex_worktree::WorktreeSettings; +use core_test_support::responses; +use core_test_support::test_codex_exec::test_codex_exec; +use pretty_assertions::assert_eq; +use serde_json::Value; +use std::fs; +use std::path::Path; +use std::process::Command; +use std::process::Output; +use std::sync::Arc; +use std::sync::Mutex; + +fn git(cwd: &Path, args: &[&str]) -> anyhow::Result<()> { + let output = Command::new("git") + .current_dir(cwd) + .args([ + "-c", + "user.name=Worktree Test", + "-c", + "user.email=test@example.invalid", + ]) + .args(args) + .output()?; + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + Ok(()) +} + +fn started_thread(output: Output) -> anyhow::Result { + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let events = String::from_utf8(output.stdout)? + .lines() + .map(serde_json::from_str::) + .collect::, _>>()?; + events + .iter() + .find(|event| event["type"] == "thread.started") + .and_then(|event| event["thread_id"].as_str()) + .map(str::to_owned) + .context("exec should report its thread id") +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn worktree_start_and_fork_use_host_pool_and_preserve_legacy_resume() -> anyhow::Result<()> { + let test = test_codex_exec(); + let home = AbsolutePathBuf::from_absolute_path(test.home_path())? + .canonicalize()? + .into_path_buf(); + let source = AbsolutePathBuf::from_absolute_path(test.cwd_path())? + .canonicalize()? + .into_path_buf(); + let extra = source.join("extra"); + fs::create_dir(&extra)?; + fs::write(extra.join("tracked.txt"), "tracked")?; + fs::write(source.join("AGENTS.md"), "managed checkout instructions")?; + git(&source, &["init", "--quiet"])?; + git(&source, &["add", "."])?; + git( + &source, + &["commit", "--quiet", "--no-gpg-sign", "-m", "initial"], + )?; + let pool = home.join("shared-worktrees"); + let other_pool = serde_json::to_string(&home.join("session-only-pool"))?; + fs::write( + home.join("config.toml"), + format!( + "sandbox_mode = \"workspace-write\"\nfeatures.worktrees = true\n[desktop]\ngit-worktree-root = {}\n[projects.{}]\ntrust_level = \"trusted\"\n", + serde_json::to_string(&pool)?, + serde_json::to_string(&source)?, + ), + )?; + fs::write( + home.join("demo.config.toml"), + format!("features.worktrees = false\n[desktop]\ngit-worktree-root = {other_pool}\n"), + )?; + let mut settings = WorktreeSettings::for_cli(&home, /*desktop*/ None)?; + settings.root = pool; + let legacy_manager = WorktreeManager::new(WorktreeSettings { + root: home.join("worktrees-cli"), + ..settings.clone() + }); + let legacy = legacy_manager.create(&CreateWorktree { + source_cwd: source.clone(), + base: None, + })?; + let manager = WorktreeManager::new(settings); + let body = responses::sse(vec![ + responses::ev_response_created("response_1"), + responses::ev_assistant_message("message_1", "done"), + responses::ev_completed("response_1"), + ]); + let server = responses::start_mock_server().await; + let seed_mock = responses::mount_sse_once(&server, body.clone()).await; + let legacy_id = started_thread( + test.cmd_with_server(&server) + .current_dir(&legacy.cwd) + .args(["--json", "legacy seed"]) + .output()?, + )?; + seed_mock.single_request(); + legacy_manager.bind_thread(&legacy.root, &legacy_id)?; + fs::write(source.join("AGENTS.md"), "uncommitted source instructions")?; + fs::create_dir(source.join(".codex"))?; + fs::write(source.join(".codex/config.toml"), "[invalid source config")?; + + let mut known_roots = Vec::new(); + for (fork, overrides) in [ + (false, Vec::new()), + ( + true, + vec![ + "--profile".to_owned(), + "demo".to_owned(), + "-c".to_owned(), + "features.worktrees=true".to_owned(), + "-c".to_owned(), + format!("desktop.git-worktree-root={other_pool}"), + "-c".to_owned(), + format!("desktop={{git-worktree-root={other_pool}}}"), + ], + ), + ] { + let server = responses::start_mock_server().await; + let observed_owner = Arc::new(Mutex::>::default()); + let captured_owner = Arc::clone(&observed_owner); + let observed_manager = manager.clone(); + let observed_source = source.clone(); + let previous_roots = known_roots.clone(); + // A matcher is needed here to inspect metadata at request time, not after exit. + let response_mock = responses::mount_sse_once_match( + &server, + move |_: &wiremock::Request| { + let owner = observed_manager + .list(&observed_source) + .expect("list shared checkouts") + .into_iter() + .find(|checkout| !previous_roots.contains(&checkout.root)) + .and_then(|checkout| { + observed_manager.owner(&checkout.root).expect("read owner") + }); + *captured_owner.lock().expect("capture request owner") = owner; + true + }, + body.clone(), + ) + .await; + let mut command = test.cmd_with_server(&server); + command + .args(["--json", "--worktree", "--add-dir", "extra"]) + .args(overrides); + if fork { + command.arg("fork").arg(&legacy_id); + } + let thread_id = started_thread(command.arg("describe checkout").output()?)?; + assert_eq!( + *observed_owner.lock().expect("read captured owner"), + Some(thread_id) + ); + let checkout = manager + .list(&source)? + .into_iter() + .find(|checkout| !known_roots.contains(&checkout.root)) + .context("one new shared checkout")?; + let request = response_mock.single_request(); + let context = request + .message_input_texts("user") + .into_iter() + .chain(request.message_input_texts("developer")) + .collect::>() + .join("\n"); + let checkout_cwd = AbsolutePathBuf::from_absolute_path(&checkout.cwd)?.canonicalize()?; + assert!( + context.contains(checkout_cwd.to_string_lossy().as_ref()), + "{context}" + ); + assert!( + context.contains(extra.to_string_lossy().as_ref()), + "{context}" + ); + assert!( + context.contains("managed checkout instructions"), + "{context}" + ); + assert!( + !context.contains("uncommitted source instructions"), + "{context}" + ); + known_roots.push(checkout.root); + } + + let server = responses::start_mock_server().await; + let resume_mock = responses::mount_sse_once(&server, body).await; + let resumed_id = started_thread( + test.cmd_with_server(&server) + .current_dir(&legacy.cwd) + .args(["--json", "resume", &legacy_id, "continue legacy"]) + .output()?, + )?; + assert_eq!(resumed_id, legacy_id); + assert_eq!(legacy_manager.owner(&legacy.root)?, Some(legacy_id)); + let legacy_cwd = AbsolutePathBuf::from_absolute_path(&legacy.cwd)?.canonicalize()?; + assert!( + resume_mock + .single_request() + .message_input_texts("user") + .iter() + .any(|text| text.contains(legacy_cwd.to_string_lossy().as_ref())) + ); + Ok(()) +} + +#[test] +fn worktree_rejects_remote_defaults_before_allocation() -> anyhow::Result<()> { + for route in ["environment", "file"] { + let test = test_codex_exec(); + let mut command = test.cmd(); + if route == "environment" { + command.env("CODEX_EXEC_SERVER_URL", "ws://127.0.0.1:9"); + } else { + fs::write( + test.home_path().join("environments.toml"), + "default = 'remote'\n[[environments]]\nid = 'remote'\nurl = 'ws://127.0.0.1:9'\n", + )?; + } + let output = command + .args(["--worktree", "-c", "features.worktrees=true", "prompt"]) + .output()?; + assert!(!output.status.success(), "{route}"); + assert!( + String::from_utf8_lossy(&output.stderr).contains("--worktree requires local execution"), + "{route}: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(!test.home_path().join("worktrees").exists(), "{route}"); + } + Ok(()) +} + +#[test] +fn worktree_rejects_disabled_features_and_ignored_config_before_allocation() -> anyhow::Result<()> { + for (config, args, expected) in [ + ( + "", + vec!["--ignore-user-config", "prompt"], + "--worktree cannot be combined with --ignore-user-config", + ), + ("", vec!["prompt"], "--enable worktrees"), + ("", vec!["fork", "missing"], "--enable worktrees"), + ( + "features.worktrees = true", + vec!["-c", "features.worktrees=false", "prompt"], + "--enable worktrees", + ), + ] { + let test = test_codex_exec(); + fs::write(test.home_path().join("config.toml"), config)?; + let output = test.cmd().arg("--worktree").args(args).output()?; + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr).contains(expected)); + assert!(!test.home_path().join("worktrees").exists()); + } + Ok(()) +} diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index 1c0d0cd09b..f11cc6eb3f 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -187,6 +187,8 @@ pub enum Feature { UnboundedConnectionRetries, /// Start the managed network proxy for sandboxed sessions. NetworkProxy, + /// Enable managed worktree creation and repository-aware sessions. + Worktrees, /// Respect host system proxy settings for Codex-owned network clients. RespectSystemProxy, /// Enable collab tools. @@ -1222,6 +1224,16 @@ pub const FEATURES: &[FeatureSpec] = &[ }, default_enabled: false, }, + FeatureSpec { + id: Feature::Worktrees, + key: "worktrees", + stage: Stage::Experimental { + name: "Worktrees", + menu_description: "Create isolated Git worktrees and group sessions by repository.", + announcement: "NEW: Worktrees can now be enabled from /experimental. Restart Codex after enabling it.", + }, + default_enabled: false, + }, FeatureSpec { id: Feature::RespectSystemProxy, key: "respect_system_proxy", diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__experimental_features_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__experimental_features_popup.snap index 6b2bb50133..a3d78715e7 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__experimental_features_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__experimental_features_popup.snap @@ -10,5 +10,7 @@ expression: popup interactive website debugging and other inline JavaScript execution capabilities. [x] Shell tool Allow the model to run shell commands. + [ ] Worktrees Create isolated Git worktrees and group sessions by + repository. Press space to select or enter to save diff --git a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs index f2257c38c6..36f6db5f54 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -2998,6 +2998,7 @@ async fn apps_popup_for_not_installed_app_uses_install_only_selected_description async fn experimental_features_popup_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let worktrees = Feature::Worktrees.stage(); let features = vec![ ExperimentalFeatureItem { feature: Some(Feature::JsRepl), @@ -3011,6 +3012,12 @@ async fn experimental_features_popup_snapshot() { description: "Allow the model to run shell commands.".to_string(), enabled: true, }, + ExperimentalFeatureItem { + feature: Some(Feature::Worktrees), + name: worktrees.experimental_menu_name().unwrap().to_string(), + description: worktrees.experimental_menu_description().unwrap().to_string(), + enabled: false, + }, ]; let view = ExperimentalFeaturesView::new( features, diff --git a/codex-rs/utils/cli/src/shared_options.rs b/codex-rs/utils/cli/src/shared_options.rs index c6c3e05e65..5e525662d4 100644 --- a/codex-rs/utils/cli/src/shared_options.rs +++ b/codex-rs/utils/cli/src/shared_options.rs @@ -67,6 +67,10 @@ pub struct SharedCliOptions { #[clap(long = "cd", short = 'C', value_name = "DIR")] pub cwd: Option, + /// Run the session in a new managed Git worktree. + #[arg(long = "worktree", default_value_t = false)] + pub worktree: bool, + /// Additional directories that should be writable alongside the primary workspace. #[arg(long = "add-dir", value_name = "DIR", value_hint = clap::ValueHint::DirPath)] pub add_dir: Vec, @@ -103,6 +107,7 @@ impl SharedCliOptions { dangerously_bypass_approvals_and_sandbox, bypass_hook_trust, cwd, + worktree, add_dir, } = self; let Self { @@ -116,6 +121,7 @@ impl SharedCliOptions { dangerously_bypass_approvals_and_sandbox: root_dangerously_bypass_approvals_and_sandbox, bypass_hook_trust: root_bypass_hook_trust, cwd: root_cwd, + worktree: root_worktree, add_dir: root_add_dir, } = root; @@ -143,6 +149,7 @@ impl SharedCliOptions { if cwd.is_none() { cwd.clone_from(root_cwd); } + *worktree |= *root_worktree; if !root_images.is_empty() { let mut merged_images = root_images.clone(); merged_images.append(images); @@ -170,6 +177,7 @@ impl SharedCliOptions { dangerously_bypass_approvals_and_sandbox, bypass_hook_trust, cwd, + worktree, add_dir, } = subcommand; @@ -197,6 +205,7 @@ impl SharedCliOptions { if let Some(cwd) = cwd { self.cwd = Some(cwd); } + self.worktree |= worktree; if !images.is_empty() { self.images = images; } @@ -205,3 +214,7 @@ impl SharedCliOptions { } } } + +#[cfg(test)] +#[path = "shared_options_tests.rs"] +mod tests; diff --git a/codex-rs/utils/cli/src/shared_options_tests.rs b/codex-rs/utils/cli/src/shared_options_tests.rs new file mode 100644 index 0000000000..67cffa3670 --- /dev/null +++ b/codex-rs/utils/cli/src/shared_options_tests.rs @@ -0,0 +1,30 @@ +//! Tests shared command-line option inheritance. + +use super::SharedCliOptions; + +#[test] +fn inherits_worktree_from_root_without_clearing_subcommand_choice() { + let mut options = SharedCliOptions::default(); + let root = SharedCliOptions { + worktree: true, + ..Default::default() + }; + options.inherit_exec_root_options(&root); + assert!(options.worktree); + + options.inherit_exec_root_options(&SharedCliOptions::default()); + assert!(options.worktree); +} + +#[test] +fn applies_worktree_subcommand_override_without_clearing_root_choice() { + let mut options = SharedCliOptions::default(); + options.apply_subcommand_overrides(SharedCliOptions { + worktree: true, + ..Default::default() + }); + assert!(options.worktree); + + options.apply_subcommand_overrides(SharedCliOptions::default()); + assert!(options.worktree); +} diff --git a/codex-rs/worktree/src/settings.rs b/codex-rs/worktree/src/settings.rs index 4a2fb83ea5..b0d4492f74 100644 --- a/codex-rs/worktree/src/settings.rs +++ b/codex-rs/worktree/src/settings.rs @@ -1,7 +1,5 @@ -//! Parses and validates worktree settings from the existing `[desktop]` config. -//! -//! Resolves the managed worktree root and the automatic-cleanup and retention -//! settings without introducing a separate configuration format. +//! Resolves managed-worktree settings shared with Desktop. +//! CLI allocation uses the same root without enabling automatic cleanup. use anyhow::Context; use anyhow::Result; @@ -17,7 +15,7 @@ const KEEP_COUNT: &str = "worktree-keep-count"; pub const DEFAULT_WORKTREE_KEEP_COUNT: usize = 15; -/// Effective host-local settings already understood by Codex Desktop. +/// Effective host-local settings for managed worktrees. #[derive(Clone, Debug, PartialEq, Eq)] pub struct WorktreeSettings { pub root: PathBuf, @@ -26,6 +24,17 @@ pub struct WorktreeSettings { } impl WorktreeSettings { + /// Shares Desktop's allocation root while leaving CLI cleanup disabled. + pub fn for_cli(codex_home: &Path, desktop: Option<&HashMap>) -> Result { + let desktop = desktop + .and_then(|settings| settings.get(WORKTREE_ROOT)) + .map(|root| HashMap::from([(WORKTREE_ROOT.to_owned(), root.clone())])); + Ok(Self { + auto_cleanup_enabled: false, + ..Self::from_desktop_config(codex_home, desktop.as_ref())? + }) + } + /// Resolves existing `[desktop]` values without introducing another config format. pub fn from_desktop_config( codex_home: &Path, diff --git a/codex-rs/worktree/tests/worktree.rs b/codex-rs/worktree/tests/worktree.rs index 1c59ba82ec..8f6c679674 100644 --- a/codex-rs/worktree/tests/worktree.rs +++ b/codex-rs/worktree/tests/worktree.rs @@ -7,6 +7,7 @@ use codex_worktree::WorktreeSettings; use pretty_assertions::assert_eq; use serde_json::Value; use serde_json::json; +use std::collections::HashMap; use std::fs; use std::path::Path; use std::path::PathBuf; @@ -110,6 +111,41 @@ fn run_git(repository: &Path, args: &[&str]) -> String { .to_owned() } +#[test] +fn cli_creation_shares_default_and_custom_desktop_pools_without_enabling_cleanup() { + let fixture = RepositoryFixture::new(); + for root in [None, Some(fixture.codex_home.join("custom-desktop"))] { + let desktop = + root.map(|root| HashMap::from([("git-worktree-root".to_owned(), json!(root))])); + let desktop_settings = + WorktreeSettings::from_desktop_config(&fixture.codex_home, desktop.as_ref()) + .expect("resolve desktop settings"); + let mut cli_desktop = desktop.unwrap_or_default(); + cli_desktop.insert("worktree-auto-cleanup-enabled".to_owned(), json!("unused")); + cli_desktop.insert("worktree-keep-count".to_owned(), json!(0)); + let manager = WorktreeManager::new( + WorktreeSettings::for_cli(&fixture.codex_home, Some(&cli_desktop)) + .expect("resolve CLI settings"), + ); + let worktree = create_worktree(&manager, &fixture.repository, /*base*/ None) + .expect("create CLI worktree"); + assert!(worktree.root.starts_with(&desktop_settings.root)); + assert_eq!( + manager.settings(), + &WorktreeSettings { + auto_cleanup_enabled: false, + ..desktop_settings.clone() + } + ); + assert_eq!( + WorktreeManager::new(desktop_settings) + .list(&fixture.repository) + .expect("list desktop worktrees"), + vec![worktree], + ); + } +} + #[test] fn thread_binding_rejects_primary_and_unmanaged_worktrees() { let fixture = RepositoryFixture::new();