mirror of
https://github.com/openai/codex.git
synced 2026-11-09 08:40:33 +00:00
Add managed worktrees to codex exec (#42652)
## What changed - Add the experimental `worktrees` feature and a shared `--worktree` flag for new and forked `codex exec` sessions. - Create each enabled session in a managed Git worktree, use that checkout as the session working directory, and bind the checkout to the new thread. - Share the configured worktree pool with Desktop while leaving automatic cleanup disabled for CLI allocations. - Reject unsupported commands, remote execution, ignored user configuration, ephemeral sessions, and use without the feature enabled before allocating a worktree. ## Testing - Cover flag placement and inheritance, supported and rejected command combinations, worktree allocation and thread ownership, configuration gating, and compatibility with existing worktree-backed sessions. GitOrigin-RevId: 011ff4639b09e8992c50d7b823df23e71798670e
This commit is contained in:
1 parent
f46671b14a
commit
eb5a00b068
18 files changed
+631
-9
No files matched your search
Generated
+2
@@ -3037,6 +3037,7 @@ dependencies = [
|
|||||||
"codex-cloud-config",
|
"codex-cloud-config",
|
||||||
"codex-config",
|
"codex-config",
|
||||||
"codex-core",
|
"codex-core",
|
||||||
|
"codex-features",
|
||||||
"codex-feedback",
|
"codex-feedback",
|
||||||
"codex-git-utils",
|
"codex-git-utils",
|
||||||
"codex-history",
|
"codex-history",
|
||||||
@@ -3050,6 +3051,7 @@ dependencies = [
|
|||||||
"codex-utils-cli",
|
"codex-utils-cli",
|
||||||
"codex-utils-oss",
|
"codex-utils-oss",
|
||||||
"codex-utils-sandbox-summary",
|
"codex-utils-sandbox-summary",
|
||||||
|
"codex-worktree",
|
||||||
"core_test_support",
|
"core_test_support",
|
||||||
"libc",
|
"libc",
|
||||||
"opentelemetry",
|
"opentelemetry",
|
||||||
|
|||||||
@@ -290,6 +290,7 @@ codex-utils-pty = { path = "utils/pty" }
|
|||||||
codex-utils-redacted-string = { path = "utils/redacted-string" }
|
codex-utils-redacted-string = { path = "utils/redacted-string" }
|
||||||
codex-utils-rustls-provider = { path = "utils/rustls-provider" }
|
codex-utils-rustls-provider = { path = "utils/rustls-provider" }
|
||||||
codex-utils-sandbox-summary = { path = "utils/sandbox-summary" }
|
codex-utils-sandbox-summary = { path = "utils/sandbox-summary" }
|
||||||
|
codex-worktree = { path = "worktree" }
|
||||||
codex-utils-sleep-inhibitor = { path = "utils/sleep-inhibitor" }
|
codex-utils-sleep-inhibitor = { path = "utils/sleep-inhibitor" }
|
||||||
codex-utils-stream-parser = { path = "utils/stream-parser" }
|
codex-utils-stream-parser = { path = "utils/stream-parser" }
|
||||||
codex-utils-string = { path = "utils/string" }
|
codex-utils-string = { path = "utils/string" }
|
||||||
|
|||||||
@@ -1060,6 +1060,7 @@ async fn cli_main(
|
|||||||
mut interactive,
|
mut interactive,
|
||||||
subcommand,
|
subcommand,
|
||||||
} = MultitoolCli::parse();
|
} = MultitoolCli::parse();
|
||||||
|
reject_unsupported_worktree_for_subcommand(interactive.shared.worktree, &subcommand)?;
|
||||||
// Fold --enable/--disable into config overrides so they flow to all subcommands.
|
// Fold --enable/--disable into config overrides so they flow to all subcommands.
|
||||||
let toggle_overrides = feature_toggles.to_overrides()?;
|
let toggle_overrides = feature_toggles.to_overrides()?;
|
||||||
root_config_overrides.raw_overrides.extend(toggle_overrides);
|
root_config_overrides.raw_overrides.extend(toggle_overrides);
|
||||||
@@ -2395,6 +2396,42 @@ fn reject_remote_mode_for_subcommand(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn reject_unsupported_worktree_for_subcommand(
|
||||||
|
root_worktree: bool,
|
||||||
|
subcommand: &Option<Subcommand>,
|
||||||
|
) -> 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(
|
fn reject_root_strict_config_for_subcommand(
|
||||||
strict_config: bool,
|
strict_config: bool,
|
||||||
subcommand: &Option<Subcommand>,
|
subcommand: &Option<Subcommand>,
|
||||||
@@ -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]
|
#[test]
|
||||||
fn exec_resume_last_accepts_prompt_positional() {
|
fn exec_resume_last_accepts_prompt_positional() {
|
||||||
let cli =
|
let cli =
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ pub(crate) struct QueueCommand {
|
|||||||
remote: InteractiveRemoteOptions,
|
remote: InteractiveRemoteOptions,
|
||||||
|
|
||||||
#[clap(flatten)]
|
#[clap(flatten)]
|
||||||
config_overrides: SessionArchiveConfigOverrides,
|
pub(crate) config_overrides: SessionArchiveConfigOverrides,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn run_queue_command(
|
pub(crate) async fn run_queue_command(
|
||||||
|
|||||||
@@ -1056,6 +1056,9 @@
|
|||||||
"workspace_owner_usage_nudge": {
|
"workspace_owner_usage_nudge": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
"worktrees": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"write_stdin_approval": {
|
"write_stdin_approval": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
}
|
}
|
||||||
@@ -6407,6 +6410,9 @@
|
|||||||
"workspace_owner_usage_nudge": {
|
"workspace_owner_usage_nudge": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
"worktrees": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"write_stdin_approval": {
|
"write_stdin_approval": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ codex-app-server-protocol = { workspace = true }
|
|||||||
codex-cloud-config = { workspace = true }
|
codex-cloud-config = { workspace = true }
|
||||||
codex-config = { workspace = true }
|
codex-config = { workspace = true }
|
||||||
codex-core = { workspace = true }
|
codex-core = { workspace = true }
|
||||||
|
codex-features = { workspace = true }
|
||||||
codex-feedback = { workspace = true }
|
codex-feedback = { workspace = true }
|
||||||
codex-git-utils = { workspace = true }
|
codex-git-utils = { workspace = true }
|
||||||
codex-history = { workspace = true }
|
codex-history = { workspace = true }
|
||||||
@@ -42,6 +43,7 @@ codex-utils-absolute-path = { workspace = true }
|
|||||||
codex-utils-cli = { workspace = true }
|
codex-utils-cli = { workspace = true }
|
||||||
codex-utils-oss = { workspace = true }
|
codex-utils-oss = { workspace = true }
|
||||||
codex-utils-sandbox-summary = { workspace = true }
|
codex-utils-sandbox-summary = { workspace = true }
|
||||||
|
codex-worktree = { workspace = true }
|
||||||
owo-colors = { workspace = true }
|
owo-colors = { workspace = true }
|
||||||
serde = { workspace = true, features = ["derive"] }
|
serde = { workspace = true, features = ["derive"] }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
|
|||||||
@@ -143,6 +143,7 @@ fn mark_exec_global_args(cmd: clap::Command) -> clap::Command {
|
|||||||
arg.global(true)
|
arg.global(true)
|
||||||
})
|
})
|
||||||
.mut_arg("bypass_hook_trust", |arg| arg.global(true))
|
.mut_arg("bypass_hook_trust", |arg| arg.global(true))
|
||||||
|
.mut_arg("worktree", |arg| arg.global(true))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, clap::Subcommand)]
|
#[derive(Debug, clap::Subcommand)]
|
||||||
|
|||||||
@@ -127,3 +127,21 @@ fn approve_for_me_flag_conflicts_with_other_sandbox_modes() {
|
|||||||
assert_eq!(error.kind(), clap::error::ErrorKind::ArgumentConflict);
|
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(_))));
|
||||||
|
}
|
||||||
+111
-3
@@ -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::format_exec_policy_error_with_source;
|
||||||
use codex_core::path_utils;
|
use codex_core::path_utils;
|
||||||
use codex_core::read_session_meta_line;
|
use codex_core::read_session_meta_line;
|
||||||
|
use codex_features::Feature;
|
||||||
use codex_feedback::CodexFeedback;
|
use codex_feedback::CodexFeedback;
|
||||||
use codex_git_utils::get_git_repo_root;
|
use codex_git_utils::get_git_repo_root;
|
||||||
use codex_history::RolloutItem;
|
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_cli::SharedCliOptions;
|
||||||
use codex_utils_oss::ensure_oss_provider_ready;
|
use codex_utils_oss::ensure_oss_provider_ready;
|
||||||
use codex_utils_oss::get_default_model_for_oss_provider;
|
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;
|
use event_processor_with_human_output::EventProcessorWithHumanOutput;
|
||||||
pub use event_processor_with_jsonl_output::CodexStatus;
|
pub use event_processor_with_jsonl_output::CodexStatus;
|
||||||
pub use event_processor_with_jsonl_output::CollectedThreadEvents;
|
pub use event_processor_with_jsonl_output::CollectedThreadEvents;
|
||||||
@@ -218,6 +222,7 @@ struct ExecRunArgs {
|
|||||||
json_mode: bool,
|
json_mode: bool,
|
||||||
last_message_file: Option<PathBuf>,
|
last_message_file: Option<PathBuf>,
|
||||||
model_provider: Option<String>,
|
model_provider: Option<String>,
|
||||||
|
managed_worktree: Option<ManagedExecWorktree>,
|
||||||
oss: bool,
|
oss: bool,
|
||||||
output_schema_path: Option<PathBuf>,
|
output_schema_path: Option<PathBuf>,
|
||||||
prompt: Option<String>,
|
prompt: Option<String>,
|
||||||
@@ -226,6 +231,11 @@ struct ExecRunArgs {
|
|||||||
thread_source: ThreadSource,
|
thread_source: ThreadSource,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct ManagedExecWorktree {
|
||||||
|
manager: WorktreeManager,
|
||||||
|
checkout: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
fn exec_root_span() -> tracing::Span {
|
fn exec_root_span() -> tracing::Span {
|
||||||
info_span!(
|
info_span!(
|
||||||
"codex.exec",
|
"codex.exec",
|
||||||
@@ -277,9 +287,28 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result
|
|||||||
dangerously_bypass_approvals_and_sandbox,
|
dangerously_bypass_approvals_and_sandbox,
|
||||||
bypass_hook_trust,
|
bypass_hook_trust,
|
||||||
cwd,
|
cwd,
|
||||||
add_dir,
|
mut add_dir,
|
||||||
|
worktree,
|
||||||
} = shared;
|
} = 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 {
|
let (_stdout_with_ansi, stderr_with_ansi) = match color {
|
||||||
cli::Color::Always => (true, true),
|
cli::Color::Always => (true, true),
|
||||||
cli::Color::Never => (false, false),
|
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 mut resolved_cwd = cwd.clone();
|
||||||
let config_cwd = match resolved_cwd.as_deref() {
|
let mut config_cwd = match resolved_cwd.as_deref() {
|
||||||
Some(path) => {
|
Some(path) => {
|
||||||
AbsolutePathBuf::from_absolute_path(canonicalize_existing_preserving_symlinks(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()
|
..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(
|
let bootstrap_config = load_bootstrap_config_or_exit(
|
||||||
&codex_home,
|
&codex_home,
|
||||||
Some(&config_cwd),
|
Some(&config_cwd),
|
||||||
@@ -571,6 +671,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result
|
|||||||
json_mode,
|
json_mode,
|
||||||
last_message_file,
|
last_message_file,
|
||||||
model_provider,
|
model_provider,
|
||||||
|
managed_worktree,
|
||||||
oss,
|
oss,
|
||||||
output_schema_path,
|
output_schema_path,
|
||||||
prompt,
|
prompt,
|
||||||
@@ -670,6 +771,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> {
|
|||||||
json_mode,
|
json_mode,
|
||||||
last_message_file,
|
last_message_file,
|
||||||
model_provider,
|
model_provider,
|
||||||
|
managed_worktree,
|
||||||
oss,
|
oss,
|
||||||
output_schema_path,
|
output_schema_path,
|
||||||
prompt,
|
prompt,
|
||||||
@@ -923,6 +1025,12 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> {
|
|||||||
(session_configured.thread_id, session_configured)
|
(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();
|
let primary_thread_id_for_span = primary_thread_id.to_string();
|
||||||
// Use the start/resume response as the authoritative bootstrap payload.
|
// Use the start/resume response as the authoritative bootstrap payload.
|
||||||
// Waiting for a later streamed `SessionConfigured` event adds up to 10s of
|
// Waiting for a later streamed `SessionConfigured` event adds up to 10s of
|
||||||
|
|||||||
@@ -17,3 +17,4 @@ mod sandbox;
|
|||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
mod seatbelt;
|
mod seatbelt;
|
||||||
mod server_error_exit;
|
mod server_error_exit;
|
||||||
|
mod worktree;
|
||||||
@@ -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<String> {
|
||||||
|
assert!(
|
||||||
|
output.status.success(),
|
||||||
|
"{}",
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
let events = String::from_utf8(output.stdout)?
|
||||||
|
.lines()
|
||||||
|
.map(serde_json::from_str::<Value>)
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
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::<Option<String>>::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::<Vec<_>>()
|
||||||
|
.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(())
|
||||||
|
}
|
||||||
@@ -187,6 +187,8 @@ pub enum Feature {
|
|||||||
UnboundedConnectionRetries,
|
UnboundedConnectionRetries,
|
||||||
/// Start the managed network proxy for sandboxed sessions.
|
/// Start the managed network proxy for sandboxed sessions.
|
||||||
NetworkProxy,
|
NetworkProxy,
|
||||||
|
/// Enable managed worktree creation and repository-aware sessions.
|
||||||
|
Worktrees,
|
||||||
/// Respect host system proxy settings for Codex-owned network clients.
|
/// Respect host system proxy settings for Codex-owned network clients.
|
||||||
RespectSystemProxy,
|
RespectSystemProxy,
|
||||||
/// Enable collab tools.
|
/// Enable collab tools.
|
||||||
@@ -1222,6 +1224,16 @@ pub const FEATURES: &[FeatureSpec] = &[
|
|||||||
},
|
},
|
||||||
default_enabled: false,
|
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 {
|
FeatureSpec {
|
||||||
id: Feature::RespectSystemProxy,
|
id: Feature::RespectSystemProxy,
|
||||||
key: "respect_system_proxy",
|
key: "respect_system_proxy",
|
||||||
|
|||||||
+2
@@ -10,5 +10,7 @@ expression: popup
|
|||||||
interactive website debugging and other inline
|
interactive website debugging and other inline
|
||||||
JavaScript execution capabilities.
|
JavaScript execution capabilities.
|
||||||
[x] Shell tool Allow the model to run shell commands.
|
[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
|
Press space to select or enter to save
|
||||||
@@ -2998,6 +2998,7 @@ async fn apps_popup_for_not_installed_app_uses_install_only_selected_description
|
|||||||
async fn experimental_features_popup_snapshot() {
|
async fn experimental_features_popup_snapshot() {
|
||||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||||
|
|
||||||
|
let worktrees = Feature::Worktrees.stage();
|
||||||
let features = vec![
|
let features = vec![
|
||||||
ExperimentalFeatureItem {
|
ExperimentalFeatureItem {
|
||||||
feature: Some(Feature::JsRepl),
|
feature: Some(Feature::JsRepl),
|
||||||
@@ -3011,6 +3012,12 @@ async fn experimental_features_popup_snapshot() {
|
|||||||
description: "Allow the model to run shell commands.".to_string(),
|
description: "Allow the model to run shell commands.".to_string(),
|
||||||
enabled: true,
|
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(
|
let view = ExperimentalFeaturesView::new(
|
||||||
features,
|
features,
|
||||||
|
|||||||
@@ -67,6 +67,10 @@ pub struct SharedCliOptions {
|
|||||||
#[clap(long = "cd", short = 'C', value_name = "DIR")]
|
#[clap(long = "cd", short = 'C', value_name = "DIR")]
|
||||||
pub cwd: Option<PathBuf>,
|
pub cwd: Option<PathBuf>,
|
||||||
|
|
||||||
|
/// 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.
|
/// Additional directories that should be writable alongside the primary workspace.
|
||||||
#[arg(long = "add-dir", value_name = "DIR", value_hint = clap::ValueHint::DirPath)]
|
#[arg(long = "add-dir", value_name = "DIR", value_hint = clap::ValueHint::DirPath)]
|
||||||
pub add_dir: Vec<PathBuf>,
|
pub add_dir: Vec<PathBuf>,
|
||||||
@@ -103,6 +107,7 @@ impl SharedCliOptions {
|
|||||||
dangerously_bypass_approvals_and_sandbox,
|
dangerously_bypass_approvals_and_sandbox,
|
||||||
bypass_hook_trust,
|
bypass_hook_trust,
|
||||||
cwd,
|
cwd,
|
||||||
|
worktree,
|
||||||
add_dir,
|
add_dir,
|
||||||
} = self;
|
} = self;
|
||||||
let Self {
|
let Self {
|
||||||
@@ -116,6 +121,7 @@ impl SharedCliOptions {
|
|||||||
dangerously_bypass_approvals_and_sandbox: root_dangerously_bypass_approvals_and_sandbox,
|
dangerously_bypass_approvals_and_sandbox: root_dangerously_bypass_approvals_and_sandbox,
|
||||||
bypass_hook_trust: root_bypass_hook_trust,
|
bypass_hook_trust: root_bypass_hook_trust,
|
||||||
cwd: root_cwd,
|
cwd: root_cwd,
|
||||||
|
worktree: root_worktree,
|
||||||
add_dir: root_add_dir,
|
add_dir: root_add_dir,
|
||||||
} = root;
|
} = root;
|
||||||
|
|
||||||
@@ -143,6 +149,7 @@ impl SharedCliOptions {
|
|||||||
if cwd.is_none() {
|
if cwd.is_none() {
|
||||||
cwd.clone_from(root_cwd);
|
cwd.clone_from(root_cwd);
|
||||||
}
|
}
|
||||||
|
*worktree |= *root_worktree;
|
||||||
if !root_images.is_empty() {
|
if !root_images.is_empty() {
|
||||||
let mut merged_images = root_images.clone();
|
let mut merged_images = root_images.clone();
|
||||||
merged_images.append(images);
|
merged_images.append(images);
|
||||||
@@ -170,6 +177,7 @@ impl SharedCliOptions {
|
|||||||
dangerously_bypass_approvals_and_sandbox,
|
dangerously_bypass_approvals_and_sandbox,
|
||||||
bypass_hook_trust,
|
bypass_hook_trust,
|
||||||
cwd,
|
cwd,
|
||||||
|
worktree,
|
||||||
add_dir,
|
add_dir,
|
||||||
} = subcommand;
|
} = subcommand;
|
||||||
|
|
||||||
@@ -197,6 +205,7 @@ impl SharedCliOptions {
|
|||||||
if let Some(cwd) = cwd {
|
if let Some(cwd) = cwd {
|
||||||
self.cwd = Some(cwd);
|
self.cwd = Some(cwd);
|
||||||
}
|
}
|
||||||
|
self.worktree |= worktree;
|
||||||
if !images.is_empty() {
|
if !images.is_empty() {
|
||||||
self.images = images;
|
self.images = images;
|
||||||
}
|
}
|
||||||
@@ -205,3 +214,7 @@ impl SharedCliOptions {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "shared_options_tests.rs"]
|
||||||
|
mod tests;
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
//! Parses and validates worktree settings from the existing `[desktop]` config.
|
//! Resolves managed-worktree settings shared with Desktop.
|
||||||
//!
|
//! CLI allocation uses the same root without enabling automatic cleanup.
|
||||||
//! Resolves the managed worktree root and the automatic-cleanup and retention
|
|
||||||
//! settings without introducing a separate configuration format.
|
|
||||||
|
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
@@ -17,7 +15,7 @@ const KEEP_COUNT: &str = "worktree-keep-count";
|
|||||||
|
|
||||||
pub const DEFAULT_WORKTREE_KEEP_COUNT: usize = 15;
|
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)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
pub struct WorktreeSettings {
|
pub struct WorktreeSettings {
|
||||||
pub root: PathBuf,
|
pub root: PathBuf,
|
||||||
@@ -26,6 +24,17 @@ pub struct WorktreeSettings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl WorktreeSettings {
|
impl WorktreeSettings {
|
||||||
|
/// Shares Desktop's allocation root while leaving CLI cleanup disabled.
|
||||||
|
pub fn for_cli(codex_home: &Path, desktop: Option<&HashMap<String, Value>>) -> Result<Self> {
|
||||||
|
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.
|
/// Resolves existing `[desktop]` values without introducing another config format.
|
||||||
pub fn from_desktop_config(
|
pub fn from_desktop_config(
|
||||||
codex_home: &Path,
|
codex_home: &Path,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use codex_worktree::WorktreeSettings;
|
|||||||
use pretty_assertions::assert_eq;
|
use pretty_assertions::assert_eq;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
@@ -110,6 +111,41 @@ fn run_git(repository: &Path, args: &[&str]) -> String {
|
|||||||
.to_owned()
|
.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]
|
#[test]
|
||||||
fn thread_binding_rejects_primary_and_unmanaged_worktrees() {
|
fn thread_binding_rejects_primary_and_unmanaged_worktrees() {
|
||||||
let fixture = RepositoryFixture::new();
|
let fixture = RepositoryFixture::new();
|
||||||
|
|||||||
Reference in new issue
Block a user